refactor(routes): move storefront routes to their modules

Phase 4 of storefront restructure plan - move API routes from legacy
app/api/v1/storefront/ to their respective modules:

- customers: auth, profile, addresses routes combined into storefront.py
- orders: order history viewing routes
- checkout: order placement (place_order endpoint)
- messaging: customer messaging routes

Updated app/api/v1/storefront/__init__.py to import from modules:
- cart_router from app.modules.cart
- catalog_router from app.modules.catalog
- checkout_router from app.modules.checkout
- customers_router from app.modules.customers
- orders_router from app.modules.orders
- messaging_router from app.modules.messaging

Legacy route files in app/api/v1/storefront/ can now be deleted
in Phase 6.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-29 23:00:01 +01:00
parent 0845555413
commit 2755c2f780
9 changed files with 1663 additions and 35 deletions

View File

@@ -6,28 +6,33 @@ This module aggregates all storefront-related JSON API endpoints (public facing)
Uses vendor context from middleware - no vendor_id in URLs.
Endpoints:
- Products: Browse catalog, search products
- Cart: Shopping cart operations (session-based)
- Orders: Order placement and history (requires auth)
- Auth: Customer login, registration, password reset
- Content Pages: CMS pages (about, faq, etc.)
- Products: Browse catalog, search products (catalog module)
- Cart: Shopping cart operations (cart module)
- Orders: Order history viewing (orders module)
- Checkout: Order placement (checkout module)
- Auth: Customer login, registration, password reset (customers module)
- Profile/Addresses: Customer profile management (customers module)
- Messages: Customer messaging (messaging module)
- Content Pages: CMS pages (cms module)
Authentication:
- Products, Cart, Content Pages: No auth required
- Orders: Requires customer authentication (get_current_customer_api)
- Orders, Profile, Messages: Requires customer authentication
- Auth: Public (login, register)
Note: Previously named "shop", renamed to "storefront" as not all platforms
sell items - storefront is a more accurate term for the customer-facing interface.
Note: Routes are now served from their respective modules.
"""
from fastapi import APIRouter
# Import storefront routers
from . import addresses, auth, carts, messages, orders, products, profile
# CMS module router
# Import module routers
from app.modules.cart.routes.api import storefront_router as cart_router
from app.modules.catalog.routes.api import storefront_router as catalog_router
from app.modules.checkout.routes.api import storefront_router as checkout_router
from app.modules.cms.routes.api.storefront import router as cms_storefront_router
from app.modules.customers.routes.api import storefront_router as customers_router
from app.modules.messaging.routes.api import storefront_router as messaging_router
from app.modules.orders.routes.api import storefront_router as orders_router
# Create storefront router
router = APIRouter()
@@ -36,28 +41,25 @@ router = APIRouter()
# STOREFRONT API ROUTES (All vendor-context aware via middleware)
# ============================================================================
# Addresses (authenticated)
router.include_router(addresses.router, tags=["storefront-addresses"])
# Customer authentication and account management (customers module)
router.include_router(customers_router, tags=["storefront-auth", "storefront-profile", "storefront-addresses"])
# Authentication (public)
router.include_router(auth.router, tags=["storefront-auth"])
# Product catalog browsing (catalog module)
router.include_router(catalog_router, tags=["storefront-products"])
# Products (public)
router.include_router(products.router, tags=["storefront-products"])
# Shopping cart (cart module)
router.include_router(cart_router, tags=["storefront-cart"])
# Shopping cart (public - session based)
router.include_router(carts.router, tags=["storefront-cart"])
# Order placement (checkout module)
router.include_router(checkout_router, tags=["storefront-checkout"])
# Orders (authenticated)
router.include_router(orders.router, tags=["storefront-orders"])
# Order history viewing (orders module)
router.include_router(orders_router, tags=["storefront-orders"])
# Messages (authenticated)
router.include_router(messages.router, tags=["storefront-messages"])
# Customer messaging (messaging module)
router.include_router(messaging_router, tags=["storefront-messages"])
# Profile (authenticated)
router.include_router(profile.router, tags=["storefront-profile"])
# CMS module router (self-contained module)
# CMS content pages (cms module)
router.include_router(
cms_storefront_router, prefix="/content-pages", tags=["storefront-content-pages"]
)

View File

@@ -2,33 +2,150 @@
"""
Checkout Module - Storefront API Routes
Public endpoints for checkout in storefront.
Endpoints for checkout and order creation in storefront:
- Place order (convert cart to order)
- Future: checkout session management
Uses vendor from middleware context (VendorContextMiddleware).
Note: These endpoints are placeholders for future checkout functionality.
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
Requires customer authentication for order placement.
"""
import logging
from datetime import UTC, datetime
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Request
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import VendorNotFoundException
from app.modules.cart.services import cart_service
from app.modules.checkout.schemas import (
CheckoutRequest,
CheckoutResponse,
CheckoutSessionResponse,
)
from app.modules.checkout.services import checkout_service
from app.modules.orders.services import order_service
from app.services.email_service import EmailService
from middleware.vendor_context import require_vendor_context
from models.database.customer import Customer
from models.database.vendor import Vendor
from models.schema.order import OrderCreate, OrderResponse
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# ORDER PLACEMENT (converts cart to order)
# ============================================================================
@router.post("/orders", response_model=OrderResponse) # authenticated
def place_order(
request: Request,
order_data: OrderCreate,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Place a new order for current vendor.
Vendor is automatically determined from request context.
Customer must be authenticated to place an order.
Creates an order from the customer's cart.
Request Body:
- Order data including shipping address, payment method, etc.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CHECKOUT_STOREFRONT] place_order for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
},
)
# Create order
order = order_service.create_order(
db=db, vendor_id=vendor.id, order_data=order_data
)
db.commit()
logger.info(
f"Order {order.order_number} placed for vendor {vendor.subdomain}, "
f"total: €{order.total_amount:.2f}",
extra={
"order_id": order.id,
"order_number": order.order_number,
"customer_id": customer.id,
"total_amount": float(order.total_amount),
},
)
# Update customer stats
customer.total_orders = (customer.total_orders or 0) + 1
customer.total_spent = (customer.total_spent or 0) + order.total_amount
customer.last_order_date = datetime.now(UTC)
db.flush()
logger.debug(
f"Updated customer stats: total_orders={customer.total_orders}, "
f"total_spent={customer.total_spent}"
)
# Clear cart (get session_id from request cookies or headers)
session_id = request.cookies.get("cart_session_id") or request.headers.get(
"X-Cart-Session-Id"
)
if session_id:
try:
cart_service.clear_cart(db, vendor.id, session_id)
logger.debug(f"Cleared cart for session {session_id}")
except Exception as e:
logger.warning(f"Failed to clear cart: {e}")
# Send order confirmation email
try:
email_service = EmailService(db)
email_service.send_template(
template_code="order_confirmation",
to_email=customer.email,
to_name=customer.full_name,
language=customer.preferred_language or "en",
variables={
"customer_name": customer.first_name or customer.full_name,
"order_number": order.order_number,
"order_total": f"{order.total_amount:.2f}",
"order_items_count": len(order.items),
"order_date": order.order_date.strftime("%d.%m.%Y")
if order.order_date
else "",
"shipping_address": f"{order.ship_address_line_1}, {order.ship_postal_code} {order.ship_city}",
},
vendor_id=vendor.id,
related_type="order",
related_id=order.id,
)
logger.info(f"Sent order confirmation email to {customer.email}")
except Exception as e:
logger.warning(f"Failed to send order confirmation email: {e}")
return OrderResponse.model_validate(order)
# ============================================================================
# CHECKOUT SESSION (future implementation)
# ============================================================================
@router.post("/checkout/session", response_model=CheckoutSessionResponse)
def create_checkout_session(
checkout_data: CheckoutRequest,
@@ -41,6 +158,8 @@ def create_checkout_session(
Validates the cart and prepares for checkout.
Vendor is automatically determined from request context.
Note: This is a placeholder endpoint for future checkout session workflow.
Request Body:
- session_id: Cart session ID
- shipping_address: Shipping address details
@@ -78,6 +197,8 @@ def complete_checkout(
Converts the cart to an order and processes payment.
Vendor is automatically determined from request context.
Note: This is a placeholder endpoint for future checkout completion workflow.
Query Parameters:
- checkout_session_id: The checkout session ID from create_checkout_session
"""

View File

@@ -0,0 +1,9 @@
# app/modules/customers/routes/api/__init__.py
"""Customers module API routes."""
from app.modules.customers.routes.api.storefront import router as storefront_router
# Tag for OpenAPI documentation
STOREFRONT_TAG = "Customer Account (Storefront)"
__all__ = ["storefront_router", "STOREFRONT_TAG"]

View File

@@ -0,0 +1,730 @@
# app/modules/customers/routes/api/storefront.py
"""
Customers Module - Storefront API Routes
Public and authenticated endpoints for customer operations in storefront:
- Authentication (register, login, logout, password reset)
- Profile management
- Address management
Uses vendor from middleware context (VendorContextMiddleware).
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/shop (restricted to shop routes only)
- Returns token in response for localStorage (API calls)
"""
import logging
from fastapi import APIRouter, Depends, Path, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.core.environment import should_use_secure_cookies
from app.exceptions import ValidationException, VendorNotFoundException
from app.modules.customers.services import (
customer_address_service,
customer_service,
)
from app.services.auth_service import AuthService
from app.services.email_service import EmailService
from models.database.customer import Customer
from models.database.password_reset_token import PasswordResetToken
from models.schema.auth import (
LogoutResponse,
PasswordResetRequestResponse,
PasswordResetResponse,
UserLogin,
)
from models.schema.customer import (
CustomerAddressCreate,
CustomerAddressListResponse,
CustomerAddressResponse,
CustomerAddressUpdate,
CustomerPasswordChange,
CustomerRegister,
CustomerResponse,
CustomerUpdate,
)
router = APIRouter()
logger = logging.getLogger(__name__)
# Auth service for password operations
auth_service = AuthService()
# ============================================================================
# Response Models
# ============================================================================
class CustomerLoginResponse(BaseModel):
"""Customer login response with token and customer data."""
access_token: str
token_type: str
expires_in: int
user: CustomerResponse
# ============================================================================
# AUTHENTICATION ENDPOINTS
# ============================================================================
@router.post("/auth/register", response_model=CustomerResponse)
def register_customer(
request: Request, customer_data: CustomerRegister, db: Session = Depends(get_db)
):
"""
Register a new customer for current vendor.
Vendor is automatically determined from request context.
Customer accounts are vendor-scoped - each vendor has independent customers.
Same email can be used for different vendors.
Request Body:
- email: Customer email address
- password: Customer password
- first_name: Customer first name
- last_name: Customer last name
- phone: Customer phone number (optional)
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] register_customer for vendor {vendor.subdomain}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"email": customer_data.email,
},
)
customer = customer_service.register_customer(
db=db, vendor_id=vendor.id, customer_data=customer_data
)
db.commit()
logger.info(
f"New customer registered: {customer.email} for vendor {vendor.subdomain}",
extra={
"customer_id": customer.id,
"vendor_id": vendor.id,
"email": customer.email,
},
)
return CustomerResponse.model_validate(customer)
@router.post("/auth/login", response_model=CustomerLoginResponse)
def customer_login(
request: Request,
user_credentials: UserLogin,
response: Response,
db: Session = Depends(get_db),
):
"""
Customer login for current vendor.
Vendor is automatically determined from request context.
Authenticates customer and returns JWT token.
Customer must belong to the specified vendor.
Sets token in two places:
1. HTTP-only cookie with path=/shop (for browser page navigation)
2. Response body (for localStorage and API calls)
Request Body:
- email_or_username: Customer email or username
- password: Customer password
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] customer_login for vendor {vendor.subdomain}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"email_or_username": user_credentials.email_or_username,
},
)
login_result = customer_service.login_customer(
db=db, vendor_id=vendor.id, credentials=user_credentials
)
logger.info(
f"Customer login successful: {login_result['customer'].email} for vendor {vendor.subdomain}",
extra={
"customer_id": login_result["customer"].id,
"vendor_id": vendor.id,
"email": login_result["customer"].email,
},
)
# Calculate cookie path based on vendor access method
vendor_context = getattr(request.state, "vendor_context", None)
access_method = (
vendor_context.get("detection_method", "unknown")
if vendor_context
else "unknown"
)
cookie_path = "/shop"
if access_method == "path":
full_prefix = (
vendor_context.get("full_prefix", "/vendor/")
if vendor_context
else "/vendor/"
)
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
response.set_cookie(
key="customer_token",
value=login_result["token_data"]["access_token"],
httponly=True,
secure=should_use_secure_cookies(),
samesite="lax",
max_age=login_result["token_data"]["expires_in"],
path=cookie_path,
)
logger.debug(
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(path={cookie_path}, httponly=True, secure={should_use_secure_cookies()})",
)
return CustomerLoginResponse(
access_token=login_result["token_data"]["access_token"],
token_type=login_result["token_data"]["token_type"],
expires_in=login_result["token_data"]["expires_in"],
user=CustomerResponse.model_validate(login_result["customer"]),
)
@router.post("/auth/logout", response_model=LogoutResponse)
def customer_logout(request: Request, response: Response):
"""
Customer logout for current vendor.
Vendor is automatically determined from request context.
Clears the customer_token cookie.
Client should also remove token from localStorage.
"""
vendor = getattr(request.state, "vendor", None)
logger.info(
f"Customer logout for vendor {vendor.subdomain if vendor else 'unknown'}",
extra={
"vendor_id": vendor.id if vendor else None,
"vendor_code": vendor.subdomain if vendor else None,
},
)
vendor_context = getattr(request.state, "vendor_context", None)
access_method = (
vendor_context.get("detection_method", "unknown")
if vendor_context
else "unknown"
)
cookie_path = "/shop"
if access_method == "path" and vendor:
full_prefix = (
vendor_context.get("full_prefix", "/vendor/")
if vendor_context
else "/vendor/"
)
cookie_path = f"{full_prefix}{vendor.subdomain}/shop"
response.delete_cookie(key="customer_token", path=cookie_path)
logger.debug(f"Deleted customer_token cookie (path={cookie_path})")
return LogoutResponse(message="Logged out successfully")
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse)
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
"""
Request password reset for customer.
Vendor is automatically determined from request context.
Sends password reset email to customer if account exists.
Request Body:
- email: Customer email address
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] forgot_password for vendor {vendor.subdomain}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"email": email,
},
)
customer = customer_service.get_customer_for_password_reset(db, vendor.id, email)
if customer:
try:
plaintext_token = PasswordResetToken.create_for_customer(db, customer.id)
scheme = "https" if should_use_secure_cookies() else "http"
host = request.headers.get("host", "localhost")
reset_link = f"{scheme}://{host}/shop/account/reset-password?token={plaintext_token}"
email_service = EmailService(db)
email_service.send_template(
template_code="password_reset",
to_email=customer.email,
to_name=customer.full_name,
language=customer.preferred_language or "en",
variables={
"customer_name": customer.first_name or customer.full_name,
"reset_link": reset_link,
"expiry_hours": str(PasswordResetToken.TOKEN_EXPIRY_HOURS),
},
vendor_id=vendor.id,
related_type="customer",
related_id=customer.id,
)
db.commit()
logger.info(
f"Password reset email sent to {email} (vendor: {vendor.subdomain})"
)
except Exception as e:
db.rollback()
logger.error(f"Failed to send password reset email: {e}")
else:
logger.info(
f"Password reset requested for non-existent email {email} (vendor: {vendor.subdomain})"
)
return PasswordResetRequestResponse(
message="If an account exists with this email, a password reset link has been sent."
)
@router.post("/auth/reset-password", response_model=PasswordResetResponse)
def reset_password(
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
):
"""
Reset customer password using reset token.
Vendor is automatically determined from request context.
Request Body:
- reset_token: Password reset token from email
- new_password: New password (minimum 8 characters)
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] reset_password for vendor {vendor.subdomain}",
extra={"vendor_id": vendor.id, "vendor_code": vendor.subdomain},
)
customer = customer_service.validate_and_reset_password(
db=db,
vendor_id=vendor.id,
reset_token=reset_token,
new_password=new_password,
)
db.commit()
logger.info(
f"Password reset completed for customer {customer.id} (vendor: {vendor.subdomain})"
)
return PasswordResetResponse(
message="Password reset successfully. You can now log in with your new password."
)
# ============================================================================
# PROFILE ENDPOINTS
# ============================================================================
@router.get("/profile", response_model=CustomerResponse) # authenticated
def get_profile(
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get current customer profile.
Returns the authenticated customer's profile information.
"""
logger.debug(
f"[CUSTOMER_STOREFRONT] get_profile for customer {customer.id}",
extra={"customer_id": customer.id, "email": customer.email},
)
return CustomerResponse.model_validate(customer)
@router.put("/profile", response_model=CustomerResponse)
def update_profile(
update_data: CustomerUpdate,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Update current customer profile.
Allows updating profile fields like name, phone, marketing consent, etc.
Email changes require the new email to be unique within the vendor.
Request Body:
- email: New email address (optional)
- first_name: First name (optional)
- last_name: Last name (optional)
- phone: Phone number (optional)
- marketing_consent: Marketing consent (optional)
- preferred_language: Preferred language (optional)
"""
logger.debug(
f"[CUSTOMER_STOREFRONT] update_profile for customer {customer.id}",
extra={
"customer_id": customer.id,
"email": customer.email,
"update_fields": [
k for k, v in update_data.model_dump().items() if v is not None
],
},
)
if update_data.email and update_data.email != customer.email:
existing = customer_service.get_customer_by_email(
db, customer.vendor_id, update_data.email
)
if existing and existing.id != customer.id:
raise ValidationException("Email already in use")
update_dict = update_data.model_dump(exclude_unset=True)
for field, value in update_dict.items():
if value is not None:
setattr(customer, field, value)
db.commit()
db.refresh(customer)
logger.info(
f"Customer {customer.id} updated profile",
extra={"customer_id": customer.id, "updated_fields": list(update_dict.keys())},
)
return CustomerResponse.model_validate(customer)
@router.put("/profile/password", response_model=dict)
def change_password(
password_data: CustomerPasswordChange,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Change customer password.
Requires current password verification and matching new password confirmation.
Request Body:
- current_password: Current password
- new_password: New password (min 8 chars, must contain letter and digit)
- confirm_password: Confirmation of new password
"""
logger.debug(
f"[CUSTOMER_STOREFRONT] change_password for customer {customer.id}",
extra={"customer_id": customer.id, "email": customer.email},
)
if not auth_service.auth_manager.verify_password(
password_data.current_password, customer.hashed_password
):
raise ValidationException("Current password is incorrect")
if password_data.new_password != password_data.confirm_password:
raise ValidationException("New passwords do not match")
if password_data.new_password == password_data.current_password:
raise ValidationException("New password must be different from current password")
customer.hashed_password = auth_service.hash_password(password_data.new_password)
db.commit()
logger.info(
f"Customer {customer.id} changed password",
extra={"customer_id": customer.id, "email": customer.email},
)
return {"message": "Password changed successfully"}
# ============================================================================
# ADDRESS ENDPOINTS
# ============================================================================
@router.get("/addresses", response_model=CustomerAddressListResponse) # authenticated
def list_addresses(
request: Request,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
List all addresses for authenticated customer.
Vendor is automatically determined from request context.
Returns all addresses sorted by default first, then by creation date.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] list_addresses for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
},
)
addresses = customer_address_service.list_addresses(
db=db, vendor_id=vendor.id, customer_id=customer.id
)
return CustomerAddressListResponse(
addresses=[CustomerAddressResponse.model_validate(a) for a in addresses],
total=len(addresses),
)
@router.get("/addresses/{address_id}", response_model=CustomerAddressResponse)
def get_address(
request: Request,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get specific address by ID.
Vendor is automatically determined from request context.
Customer can only access their own addresses.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] get_address {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.get_address(
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
)
return CustomerAddressResponse.model_validate(address)
@router.post("/addresses", response_model=CustomerAddressResponse, status_code=201)
def create_address(
request: Request,
address_data: CustomerAddressCreate,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Create new address for authenticated customer.
Vendor is automatically determined from request context.
Maximum 10 addresses per customer.
If is_default=True, clears default flag on other addresses of same type.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] create_address for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_type": address_data.address_type,
},
)
address = customer_address_service.create_address(
db=db,
vendor_id=vendor.id,
customer_id=customer.id,
address_data=address_data,
)
db.commit()
logger.info(
f"Created address {address.id} for customer {customer.id} "
f"(type={address_data.address_type})",
extra={
"address_id": address.id,
"customer_id": customer.id,
"address_type": address_data.address_type,
},
)
return CustomerAddressResponse.model_validate(address)
@router.put("/addresses/{address_id}", response_model=CustomerAddressResponse)
def update_address(
request: Request,
address_data: CustomerAddressUpdate,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Update existing address.
Vendor is automatically determined from request context.
Customer can only update their own addresses.
If is_default=True, clears default flag on other addresses of same type.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] update_address {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.update_address(
db=db,
vendor_id=vendor.id,
customer_id=customer.id,
address_id=address_id,
address_data=address_data,
)
db.commit()
logger.info(
f"Updated address {address_id} for customer {customer.id}",
extra={"address_id": address_id, "customer_id": customer.id},
)
return CustomerAddressResponse.model_validate(address)
@router.delete("/addresses/{address_id}", status_code=204)
def delete_address(
request: Request,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Delete address.
Vendor is automatically determined from request context.
Customer can only delete their own addresses.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] delete_address {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
customer_address_service.delete_address(
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
)
db.commit()
logger.info(
f"Deleted address {address_id} for customer {customer.id}",
extra={"address_id": address_id, "customer_id": customer.id},
)
@router.put("/addresses/{address_id}/default", response_model=CustomerAddressResponse)
def set_address_default(
request: Request,
address_id: int = Path(..., description="Address ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Set address as default for its type.
Vendor is automatically determined from request context.
Clears default flag on other addresses of the same type.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] set_address_default {address_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.set_default(
db=db, vendor_id=vendor.id, customer_id=customer.id, address_id=address_id
)
db.commit()
logger.info(
f"Set address {address_id} as default for customer {customer.id}",
extra={
"address_id": address_id,
"customer_id": customer.id,
"address_type": address.address_type,
},
)
return CustomerAddressResponse.model_validate(address)

View File

@@ -0,0 +1,9 @@
# app/modules/messaging/routes/api/__init__.py
"""Messaging module API routes."""
from app.modules.messaging.routes.api.storefront import router as storefront_router
# Tag for OpenAPI documentation
STOREFRONT_TAG = "Messages (Storefront)"
__all__ = ["storefront_router", "STOREFRONT_TAG"]

View File

@@ -0,0 +1,529 @@
# app/modules/messaging/routes/api/storefront.py
"""
Messaging Module - Storefront API Routes
Authenticated endpoints for customer messaging:
- View conversations
- View/send messages
- Download attachments
- Mark as read
Uses vendor from middleware context (VendorContextMiddleware).
Requires customer authentication.
Customers can only:
- View their own vendor_customer conversations
- Reply to existing conversations
- Mark conversations as read
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, Depends, File, Form, Path, Query, Request, UploadFile
from fastapi.responses import FileResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import (
AttachmentNotFoundException,
ConversationClosedException,
ConversationNotFoundException,
VendorNotFoundException,
)
from app.modules.messaging.models.message import ConversationType, ParticipantType
from app.modules.messaging.schemas import (
ConversationDetailResponse,
ConversationListResponse,
ConversationSummary,
MessageResponse,
UnreadCountResponse,
)
from app.modules.messaging.services import (
message_attachment_service,
messaging_service,
)
from models.database.customer import Customer
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# Response Models
# ============================================================================
class SendMessageResponse(BaseModel):
"""Response for send message."""
success: bool
message: MessageResponse
# ============================================================================
# API Endpoints
# ============================================================================
@router.get("/messages", response_model=ConversationListResponse) # authenticated
def list_conversations(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
status: Optional[str] = Query(None, pattern="^(open|closed)$"),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
List conversations for authenticated customer.
Customers only see their vendor_customer conversations.
Query Parameters:
- skip: Pagination offset
- limit: Max items to return
- status: Filter by open/closed
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[MESSAGING_STOREFRONT] list_conversations for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"skip": skip,
"limit": limit,
"status": status,
},
)
is_closed = None
if status == "open":
is_closed = False
elif status == "closed":
is_closed = True
conversations, total = messaging_service.list_conversations(
db=db,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
conversation_type=ConversationType.VENDOR_CUSTOMER,
is_closed=is_closed,
skip=skip,
limit=limit,
)
summaries = []
for conv, unread in conversations:
summaries.append(
ConversationSummary(
id=conv.id,
subject=conv.subject,
conversation_type=conv.conversation_type.value,
is_closed=conv.is_closed,
last_message_at=conv.last_message_at,
message_count=conv.message_count,
unread_count=unread,
other_participant_name=_get_other_participant_name(conv, customer.id),
)
)
return ConversationListResponse(
conversations=summaries,
total=total,
skip=skip,
limit=limit,
)
@router.get("/messages/unread-count", response_model=UnreadCountResponse)
def get_unread_count(
request: Request,
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get total unread message count for header badge.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
count = messaging_service.get_unread_count(
db=db,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
return UnreadCountResponse(unread_count=count)
@router.get("/messages/{conversation_id}", response_model=ConversationDetailResponse)
def get_conversation(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get conversation detail with messages.
Validates that customer is a participant.
Automatically marks conversation as read.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[MESSAGING_STOREFRONT] get_conversation {conversation_id} for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"conversation_id": conversation_id,
},
)
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
)
messages = []
for msg in conversation.messages:
if msg.is_deleted:
continue
messages.append(
MessageResponse(
id=msg.id,
content=msg.content,
sender_type=msg.sender_type.value,
sender_id=msg.sender_id,
sender_name=_get_sender_name(msg),
is_system_message=msg.is_system_message,
attachments=[
{
"id": att.id,
"filename": att.original_filename,
"file_size": att.file_size,
"mime_type": att.mime_type,
"is_image": att.is_image,
"download_url": f"/api/v1/storefront/messages/{conversation_id}/attachments/{att.id}",
"thumbnail_url": f"/api/v1/storefront/messages/{conversation_id}/attachments/{att.id}/thumbnail"
if att.thumbnail_path
else None,
}
for att in msg.attachments
],
created_at=msg.created_at,
)
)
return ConversationDetailResponse(
id=conversation.id,
subject=conversation.subject,
conversation_type=conversation.conversation_type.value,
is_closed=conversation.is_closed,
closed_at=conversation.closed_at,
last_message_at=conversation.last_message_at,
message_count=conversation.message_count,
messages=messages,
other_participant_name=_get_other_participant_name(conversation, customer.id),
)
@router.post("/messages/{conversation_id}/messages", response_model=SendMessageResponse)
async def send_message(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
content: str = Form(..., min_length=1, max_length=10000),
attachments: List[UploadFile] = File(default=[]),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Send a message in a conversation.
Validates that customer is a participant.
Supports file attachments.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[MESSAGING_STOREFRONT] send_message in {conversation_id} from customer {customer.id}",
extra={
"vendor_id": vendor.id,
"customer_id": customer.id,
"conversation_id": conversation_id,
"attachment_count": len(attachments),
},
)
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
if conversation.is_closed:
raise ConversationClosedException(conversation_id)
attachment_data = []
for upload_file in attachments:
if upload_file.filename:
file_data = await message_attachment_service.validate_and_store(
db=db,
upload_file=upload_file,
conversation_id=conversation_id,
)
attachment_data.append(file_data)
message = messaging_service.send_message(
db=db,
conversation_id=conversation_id,
sender_type=ParticipantType.CUSTOMER,
sender_id=customer.id,
content=content,
attachments=attachment_data,
)
logger.info(
f"[MESSAGING_STOREFRONT] Message sent in conversation {conversation_id}",
extra={
"message_id": message.id,
"customer_id": customer.id,
"vendor_id": vendor.id,
},
)
return SendMessageResponse(
success=True,
message=MessageResponse(
id=message.id,
content=message.content,
sender_type=message.sender_type.value,
sender_id=message.sender_id,
sender_name=_get_sender_name(message),
is_system_message=message.is_system_message,
attachments=[
{
"id": att.id,
"filename": att.original_filename,
"file_size": att.file_size,
"mime_type": att.mime_type,
"is_image": att.is_image,
"download_url": f"/api/v1/storefront/messages/{conversation_id}/attachments/{att.id}",
"thumbnail_url": f"/api/v1/storefront/messages/{conversation_id}/attachments/{att.id}/thumbnail"
if att.thumbnail_path
else None,
}
for att in message.attachments
],
created_at=message.created_at,
),
)
@router.put("/messages/{conversation_id}/read", response_model=dict)
def mark_as_read(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""Mark conversation as read."""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
messaging_service.mark_conversation_read(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
)
return {"success": True}
@router.get("/messages/{conversation_id}/attachments/{attachment_id}")
async def download_attachment(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
attachment_id: int = Path(..., description="Attachment ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Download a message attachment.
Validates that customer has access to the conversation.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
attachment = message_attachment_service.get_attachment(
db=db,
attachment_id=attachment_id,
conversation_id=conversation_id,
)
if not attachment:
raise AttachmentNotFoundException(attachment_id)
return FileResponse(
path=attachment.file_path,
filename=attachment.original_filename,
media_type=attachment.mime_type,
)
@router.get("/messages/{conversation_id}/attachments/{attachment_id}/thumbnail")
async def get_attachment_thumbnail(
request: Request,
conversation_id: int = Path(..., description="Conversation ID", gt=0),
attachment_id: int = Path(..., description="Attachment ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get thumbnail for an image attachment.
Validates that customer has access to the conversation.
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
conversation = messaging_service.get_conversation(
db=db,
conversation_id=conversation_id,
participant_type=ParticipantType.CUSTOMER,
participant_id=customer.id,
vendor_id=vendor.id,
)
if not conversation:
raise ConversationNotFoundException(str(conversation_id))
attachment = message_attachment_service.get_attachment(
db=db,
attachment_id=attachment_id,
conversation_id=conversation_id,
)
if not attachment or not attachment.thumbnail_path:
raise AttachmentNotFoundException(f"{attachment_id}/thumbnail")
return FileResponse(
path=attachment.thumbnail_path,
media_type="image/jpeg",
)
# ============================================================================
# Helper Functions
# ============================================================================
def _get_other_participant_name(conversation, customer_id: int) -> str:
"""Get the name of the other participant (the vendor user)."""
for participant in conversation.participants:
if participant.participant_type == ParticipantType.VENDOR:
from models.database.user import User
user = (
User.query.filter_by(id=participant.participant_id).first()
if hasattr(User, "query")
else None
)
if user:
return f"{user.first_name} {user.last_name}"
return "Shop Support"
return "Shop Support"
def _get_sender_name(message) -> str:
"""Get sender name for a message."""
if message.sender_type == ParticipantType.CUSTOMER:
from models.database.customer import Customer
customer = (
Customer.query.filter_by(id=message.sender_id).first()
if hasattr(Customer, "query")
else None
)
if customer:
return f"{customer.first_name} {customer.last_name}"
return "Customer"
elif message.sender_type == ParticipantType.VENDOR:
from models.database.user import User
user = (
User.query.filter_by(id=message.sender_id).first()
if hasattr(User, "query")
else None
)
if user:
return f"{user.first_name} {user.last_name}"
return "Shop Support"
elif message.sender_type == ParticipantType.ADMIN:
return "Platform Support"
return "Unknown"

View File

@@ -0,0 +1,9 @@
# app/modules/orders/routes/api/__init__.py
"""Orders module API routes."""
from app.modules.orders.routes.api.storefront import router as storefront_router
# Tag for OpenAPI documentation
STOREFRONT_TAG = "Orders (Storefront)"
__all__ = ["storefront_router", "STOREFRONT_TAG"]

View File

@@ -0,0 +1,219 @@
# app/modules/orders/routes/api/storefront.py
"""
Orders Module - Storefront API Routes
Authenticated endpoints for customer order operations:
- View order history
- View order details
- Download invoices
Uses vendor from middleware context (VendorContextMiddleware).
Requires customer authentication.
"""
import logging
from pathlib import Path as FilePath
from fastapi import APIRouter, Depends, Path, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from app.api.deps import get_current_customer_api
from app.core.database import get_db
from app.exceptions import OrderNotFoundException, VendorNotFoundException
from app.exceptions.invoice import InvoicePDFNotFoundException
from app.modules.orders.services import order_service
from app.services.invoice_service import invoice_service
from models.database.customer import Customer
from models.schema.order import (
OrderDetailResponse,
OrderListResponse,
OrderResponse,
)
router = APIRouter()
logger = logging.getLogger(__name__)
@router.get("/orders", response_model=OrderListResponse) # authenticated
def get_my_orders(
request: Request,
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get order history for authenticated customer.
Vendor is automatically determined from request context.
Returns all orders placed by the authenticated customer.
Query Parameters:
- skip: Number of orders to skip (pagination)
- limit: Maximum number of orders to return
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[ORDERS_STOREFRONT] get_my_orders for customer {customer.id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
"skip": skip,
"limit": limit,
},
)
orders, total = order_service.get_customer_orders(
db=db, vendor_id=vendor.id, customer_id=customer.id, skip=skip, limit=limit
)
return OrderListResponse(
orders=[OrderResponse.model_validate(o) for o in orders],
total=total,
skip=skip,
limit=limit,
)
@router.get("/orders/{order_id}", response_model=OrderDetailResponse)
def get_order_details(
request: Request,
order_id: int = Path(..., description="Order ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get detailed order information for authenticated customer.
Vendor is automatically determined from request context.
Customer can only view their own orders.
Path Parameters:
- order_id: ID of the order to retrieve
"""
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[ORDERS_STOREFRONT] get_order_details: order {order_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
"order_id": order_id,
},
)
order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id)
# Verify order belongs to customer
if order.customer_id != customer.id:
raise OrderNotFoundException(str(order_id))
return OrderDetailResponse.model_validate(order)
@router.get("/orders/{order_id}/invoice")
def download_order_invoice(
request: Request,
order_id: int = Path(..., description="Order ID", gt=0),
customer: Customer = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Download invoice PDF for a customer's order.
Vendor is automatically determined from request context.
Customer can only download invoices for their own orders.
Invoice is auto-generated if it doesn't exist.
Path Parameters:
- order_id: ID of the order to get invoice for
"""
from app.exceptions import ValidationException
vendor = getattr(request.state, "vendor", None)
if not vendor:
raise VendorNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[ORDERS_STOREFRONT] download_order_invoice: order {order_id}",
extra={
"vendor_id": vendor.id,
"vendor_code": vendor.subdomain,
"customer_id": customer.id,
"order_id": order_id,
},
)
order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id)
# Verify order belongs to customer
if order.customer_id != customer.id:
raise OrderNotFoundException(str(order_id))
# Only allow invoice download for orders that are at least processing
allowed_statuses = [
"processing",
"partially_shipped",
"shipped",
"delivered",
"completed",
]
if order.status not in allowed_statuses:
raise ValidationException("Invoice not available for pending orders")
# Check if invoice exists for this order
invoice = invoice_service.get_invoice_by_order_id(
db=db, vendor_id=vendor.id, order_id=order_id
)
# Create invoice if it doesn't exist
if not invoice:
logger.info(f"Creating invoice for order {order_id} (customer download)")
invoice = invoice_service.create_invoice_from_order(
db=db,
vendor_id=vendor.id,
order_id=order_id,
)
db.commit()
# Get or generate PDF
pdf_path = invoice_service.get_pdf_path(
db=db,
vendor_id=vendor.id,
invoice_id=invoice.id,
)
if not pdf_path:
pdf_path = invoice_service.generate_pdf(
db=db,
vendor_id=vendor.id,
invoice_id=invoice.id,
)
# Verify file exists
if not FilePath(pdf_path).exists():
raise InvoicePDFNotFoundException(invoice.id)
filename = f"invoice-{invoice.invoice_number}.pdf"
logger.info(
f"Customer {customer.id} downloading invoice {invoice.invoice_number} for order {order.order_number}"
)
return FileResponse(
path=pdf_path,
media_type="application/pdf",
filename=filename,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)

View File

@@ -313,7 +313,7 @@ After migrated to `app/modules/cart/services/cart_service.py`.
1. **Phase 1** - Add architecture rule (enables detection) ✅ COMPLETE
2. **Phase 2** - Rename shop → storefront (terminology) ✅ COMPLETE
3. **Phase 3** - Create new modules (cart, checkout, catalog) ✅ COMPLETE
4. **Phase 4** - Move routes to modules
4. **Phase 4** - Move routes to modules ✅ COMPLETE
5. **Phase 5** - Fix direct model imports
6. **Phase 6** - Delete legacy files
7. **Phase 7** - Update documentation