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:
@@ -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.
|
Uses vendor context from middleware - no vendor_id in URLs.
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
- Products: Browse catalog, search products
|
- Products: Browse catalog, search products (catalog module)
|
||||||
- Cart: Shopping cart operations (session-based)
|
- Cart: Shopping cart operations (cart module)
|
||||||
- Orders: Order placement and history (requires auth)
|
- Orders: Order history viewing (orders module)
|
||||||
- Auth: Customer login, registration, password reset
|
- Checkout: Order placement (checkout module)
|
||||||
- Content Pages: CMS pages (about, faq, etc.)
|
- 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:
|
Authentication:
|
||||||
- Products, Cart, Content Pages: No auth required
|
- 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)
|
- Auth: Public (login, register)
|
||||||
|
|
||||||
Note: Previously named "shop", renamed to "storefront" as not all platforms
|
Note: Routes are now served from their respective modules.
|
||||||
sell items - storefront is a more accurate term for the customer-facing interface.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
# Import storefront routers
|
# Import module routers
|
||||||
from . import addresses, auth, carts, messages, orders, products, profile
|
from app.modules.cart.routes.api import storefront_router as cart_router
|
||||||
|
from app.modules.catalog.routes.api import storefront_router as catalog_router
|
||||||
# CMS module 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.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
|
# Create storefront router
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -36,28 +41,25 @@ router = APIRouter()
|
|||||||
# STOREFRONT API ROUTES (All vendor-context aware via middleware)
|
# STOREFRONT API ROUTES (All vendor-context aware via middleware)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
# Addresses (authenticated)
|
# Customer authentication and account management (customers module)
|
||||||
router.include_router(addresses.router, tags=["storefront-addresses"])
|
router.include_router(customers_router, tags=["storefront-auth", "storefront-profile", "storefront-addresses"])
|
||||||
|
|
||||||
# Authentication (public)
|
# Product catalog browsing (catalog module)
|
||||||
router.include_router(auth.router, tags=["storefront-auth"])
|
router.include_router(catalog_router, tags=["storefront-products"])
|
||||||
|
|
||||||
# Products (public)
|
# Shopping cart (cart module)
|
||||||
router.include_router(products.router, tags=["storefront-products"])
|
router.include_router(cart_router, tags=["storefront-cart"])
|
||||||
|
|
||||||
# Shopping cart (public - session based)
|
# Order placement (checkout module)
|
||||||
router.include_router(carts.router, tags=["storefront-cart"])
|
router.include_router(checkout_router, tags=["storefront-checkout"])
|
||||||
|
|
||||||
# Orders (authenticated)
|
# Order history viewing (orders module)
|
||||||
router.include_router(orders.router, tags=["storefront-orders"])
|
router.include_router(orders_router, tags=["storefront-orders"])
|
||||||
|
|
||||||
# Messages (authenticated)
|
# Customer messaging (messaging module)
|
||||||
router.include_router(messages.router, tags=["storefront-messages"])
|
router.include_router(messaging_router, tags=["storefront-messages"])
|
||||||
|
|
||||||
# Profile (authenticated)
|
# CMS content pages (cms module)
|
||||||
router.include_router(profile.router, tags=["storefront-profile"])
|
|
||||||
|
|
||||||
# CMS module router (self-contained module)
|
|
||||||
router.include_router(
|
router.include_router(
|
||||||
cms_storefront_router, prefix="/content-pages", tags=["storefront-content-pages"]
|
cms_storefront_router, prefix="/content-pages", tags=["storefront-content-pages"]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,33 +2,150 @@
|
|||||||
"""
|
"""
|
||||||
Checkout Module - Storefront API Routes
|
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).
|
Uses vendor from middleware context (VendorContextMiddleware).
|
||||||
|
Requires customer authentication for order placement.
|
||||||
Note: These endpoints are placeholders for future checkout functionality.
|
|
||||||
|
|
||||||
Vendor Context: require_vendor_context() - detects vendor from URL/subdomain/domain
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Request
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.api.deps import get_current_customer_api
|
||||||
from app.core.database import get_db
|
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 (
|
from app.modules.checkout.schemas import (
|
||||||
CheckoutRequest,
|
CheckoutRequest,
|
||||||
CheckoutResponse,
|
CheckoutResponse,
|
||||||
CheckoutSessionResponse,
|
CheckoutSessionResponse,
|
||||||
)
|
)
|
||||||
from app.modules.checkout.services import checkout_service
|
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 middleware.vendor_context import require_vendor_context
|
||||||
|
from models.database.customer import Customer
|
||||||
from models.database.vendor import Vendor
|
from models.database.vendor import Vendor
|
||||||
|
from models.schema.order import OrderCreate, OrderResponse
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
@router.post("/checkout/session", response_model=CheckoutSessionResponse)
|
||||||
def create_checkout_session(
|
def create_checkout_session(
|
||||||
checkout_data: CheckoutRequest,
|
checkout_data: CheckoutRequest,
|
||||||
@@ -41,6 +158,8 @@ def create_checkout_session(
|
|||||||
Validates the cart and prepares for checkout.
|
Validates the cart and prepares for checkout.
|
||||||
Vendor is automatically determined from request context.
|
Vendor is automatically determined from request context.
|
||||||
|
|
||||||
|
Note: This is a placeholder endpoint for future checkout session workflow.
|
||||||
|
|
||||||
Request Body:
|
Request Body:
|
||||||
- session_id: Cart session ID
|
- session_id: Cart session ID
|
||||||
- shipping_address: Shipping address details
|
- shipping_address: Shipping address details
|
||||||
@@ -78,6 +197,8 @@ def complete_checkout(
|
|||||||
Converts the cart to an order and processes payment.
|
Converts the cart to an order and processes payment.
|
||||||
Vendor is automatically determined from request context.
|
Vendor is automatically determined from request context.
|
||||||
|
|
||||||
|
Note: This is a placeholder endpoint for future checkout completion workflow.
|
||||||
|
|
||||||
Query Parameters:
|
Query Parameters:
|
||||||
- checkout_session_id: The checkout session ID from create_checkout_session
|
- checkout_session_id: The checkout session ID from create_checkout_session
|
||||||
"""
|
"""
|
||||||
|
|||||||
9
app/modules/customers/routes/api/__init__.py
Normal file
9
app/modules/customers/routes/api/__init__.py
Normal 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"]
|
||||||
730
app/modules/customers/routes/api/storefront.py
Normal file
730
app/modules/customers/routes/api/storefront.py
Normal 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)
|
||||||
9
app/modules/messaging/routes/api/__init__.py
Normal file
9
app/modules/messaging/routes/api/__init__.py
Normal 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"]
|
||||||
529
app/modules/messaging/routes/api/storefront.py
Normal file
529
app/modules/messaging/routes/api/storefront.py
Normal 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"
|
||||||
9
app/modules/orders/routes/api/__init__.py
Normal file
9
app/modules/orders/routes/api/__init__.py
Normal 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"]
|
||||||
219
app/modules/orders/routes/api/storefront.py
Normal file
219
app/modules/orders/routes/api/storefront.py
Normal 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}"'},
|
||||||
|
)
|
||||||
@@ -313,7 +313,7 @@ After migrated to `app/modules/cart/services/cart_service.py`.
|
|||||||
1. **Phase 1** - Add architecture rule (enables detection) ✅ COMPLETE
|
1. **Phase 1** - Add architecture rule (enables detection) ✅ COMPLETE
|
||||||
2. **Phase 2** - Rename shop → storefront (terminology) ✅ COMPLETE
|
2. **Phase 2** - Rename shop → storefront (terminology) ✅ COMPLETE
|
||||||
3. **Phase 3** - Create new modules (cart, checkout, catalog) ✅ 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
|
5. **Phase 5** - Fix direct model imports
|
||||||
6. **Phase 6** - Delete legacy files
|
6. **Phase 6** - Delete legacy files
|
||||||
7. **Phase 7** - Update documentation
|
7. **Phase 7** - Update documentation
|
||||||
|
|||||||
Reference in New Issue
Block a user