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.
|
||||
|
||||
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"]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user