diff --git a/app/api/v1/storefront/__init__.py b/app/api/v1/storefront/__init__.py index ca145bfb..e5836d3d 100644 --- a/app/api/v1/storefront/__init__.py +++ b/app/api/v1/storefront/__init__.py @@ -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"] ) diff --git a/app/modules/checkout/routes/api/storefront.py b/app/modules/checkout/routes/api/storefront.py index 8c352d80..d73d2520 100644 --- a/app/modules/checkout/routes/api/storefront.py +++ b/app/modules/checkout/routes/api/storefront.py @@ -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 """ diff --git a/app/modules/customers/routes/api/__init__.py b/app/modules/customers/routes/api/__init__.py new file mode 100644 index 00000000..def9c0c7 --- /dev/null +++ b/app/modules/customers/routes/api/__init__.py @@ -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"] diff --git a/app/modules/customers/routes/api/storefront.py b/app/modules/customers/routes/api/storefront.py new file mode 100644 index 00000000..c0c621d0 --- /dev/null +++ b/app/modules/customers/routes/api/storefront.py @@ -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) diff --git a/app/modules/messaging/routes/api/__init__.py b/app/modules/messaging/routes/api/__init__.py new file mode 100644 index 00000000..c2cec261 --- /dev/null +++ b/app/modules/messaging/routes/api/__init__.py @@ -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"] diff --git a/app/modules/messaging/routes/api/storefront.py b/app/modules/messaging/routes/api/storefront.py new file mode 100644 index 00000000..eb3895a9 --- /dev/null +++ b/app/modules/messaging/routes/api/storefront.py @@ -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" diff --git a/app/modules/orders/routes/api/__init__.py b/app/modules/orders/routes/api/__init__.py new file mode 100644 index 00000000..d9a51eb2 --- /dev/null +++ b/app/modules/orders/routes/api/__init__.py @@ -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"] diff --git a/app/modules/orders/routes/api/storefront.py b/app/modules/orders/routes/api/storefront.py new file mode 100644 index 00000000..a5784d5c --- /dev/null +++ b/app/modules/orders/routes/api/storefront.py @@ -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}"'}, + ) diff --git a/docs/proposals/PLAN_storefront-module-restructure.md b/docs/proposals/PLAN_storefront-module-restructure.md index 2ef9a1d9..93f36a8c 100644 --- a/docs/proposals/PLAN_storefront-module-restructure.md +++ b/docs/proposals/PLAN_storefront-module-restructure.md @@ -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