Files
orion/app/modules/customers/routes/api/storefront.py
Samir Boulahtit 4aa6f76e46
Some checks failed
CI / validate (push) Has been cancelled
CI / dependency-scanning (push) Has been cancelled
CI / docs (push) Has been cancelled
CI / deploy (push) Has been cancelled
CI / pytest (push) Has been cancelled
CI / ruff (push) Successful in 10s
refactor(arch): move auth schemas to tenancy module and add cross-module service methods
Move all auth schemas (UserContext, UserLogin, LoginResponse, etc.) from
legacy models/schema/auth.py to app/modules/tenancy/schemas/auth.py per
MOD-019. Update 84 import sites across 14 modules. Legacy file now
re-exports for backwards compatibility.

Add missing tenancy service methods for cross-module consumers:
- merchant_service.get_merchant_by_owner_id()
- merchant_service.get_merchant_count_for_owner()
- admin_service.get_user_by_id() (public, was private-only)
- platform_service.get_active_store_count()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 23:57:04 +01:00

705 lines
22 KiB
Python

# 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 store from middleware context (StoreContextMiddleware).
Implements dual token storage with path restriction:
- Sets HTTP-only cookie with path=/storefront (restricted to storefront 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
from app.modules.core.services.auth_service import (
AuthService, # MOD-004 - Core auth service
)
from app.modules.customers.models import PasswordResetToken
from app.modules.customers.schemas import (
CustomerAddressCreate,
CustomerAddressListResponse,
CustomerAddressResponse,
CustomerAddressUpdate,
CustomerContext,
CustomerPasswordChange,
CustomerRegister,
CustomerResponse,
CustomerUpdate,
)
from app.modules.customers.services import (
customer_address_service,
customer_service,
)
from app.modules.messaging.services.email_service import (
EmailService, # MOD-004 - Core email service
)
from app.modules.tenancy.exceptions import StoreNotFoundException
from app.modules.tenancy.schemas.auth import (
LogoutResponse,
PasswordResetRequestResponse,
PasswordResetResponse,
UserLogin,
)
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) # public
def register_customer(
request: Request, customer_data: CustomerRegister, db: Session = Depends(get_db)
):
"""
Register a new customer for current store.
Store is automatically determined from request context.
Customer accounts are store-scoped - each store has independent customers.
Same email can be used for different stores.
Request Body:
- email: Customer email address
- password: Customer password
- first_name: Customer first name
- last_name: Customer last name
- phone: Customer phone number (optional)
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] register_customer for store {store.subdomain}",
extra={
"store_id": store.id,
"store_code": store.subdomain,
"email": customer_data.email,
},
)
customer = customer_service.register_customer(
db=db, store_id=store.id, customer_data=customer_data
)
db.commit()
logger.info(
f"New customer registered: {customer.email} for store {store.subdomain}",
extra={
"customer_id": customer.id,
"store_id": store.id,
"email": customer.email,
},
)
return CustomerResponse.model_validate(customer)
@router.post("/auth/login", response_model=CustomerLoginResponse) # public
def customer_login(
request: Request,
user_credentials: UserLogin,
response: Response,
db: Session = Depends(get_db),
):
"""
Customer login for current store.
Store is automatically determined from request context.
Authenticates customer and returns JWT token.
Customer must belong to the specified store.
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
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] customer_login for store {store.subdomain}",
extra={
"store_id": store.id,
"store_code": store.subdomain,
"email_or_username": user_credentials.email_or_username,
},
)
login_result = customer_service.login_customer(
db=db, store_id=store.id, credentials=user_credentials
)
logger.info(
f"Customer login successful: {login_result['customer'].email} for store {store.subdomain}",
extra={
"customer_id": login_result["customer"].id,
"store_id": store.id,
"email": login_result["customer"].email,
},
)
# Set cookie with path=/ so it's sent with all requests
# (platform prefix varies between dev and prod, broad path avoids mismatch)
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="/",
)
logger.debug(
f"Set customer_token cookie with {login_result['token_data']['expires_in']}s expiry "
f"(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) # public
def customer_logout(request: Request, response: Response):
"""
Customer logout for current store.
Store is automatically determined from request context.
Clears the customer_token cookie.
Client should also remove token from localStorage.
"""
store = getattr(request.state, "store", None)
logger.info(
f"Customer logout for store {store.subdomain if store else 'unknown'}",
extra={
"store_id": store.id if store else None,
"store_code": store.subdomain if store else None,
},
)
response.delete_cookie(key="customer_token", path="/")
logger.debug("Deleted customer_token cookie (path=/)")
return LogoutResponse(message="Logged out successfully")
@router.post("/auth/forgot-password", response_model=PasswordResetRequestResponse) # public
def forgot_password(request: Request, email: str, db: Session = Depends(get_db)):
"""
Request password reset for customer.
Store is automatically determined from request context.
Sends password reset email to customer if account exists.
Request Body:
- email: Customer email address
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] forgot_password for store {store.subdomain}",
extra={
"store_id": store.id,
"store_code": store.subdomain,
"email": email,
},
)
customer = customer_service.get_customer_for_password_reset(db, store.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}/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),
},
store_id=store.id,
related_type="customer",
related_id=customer.id,
)
db.commit()
logger.info(
f"Password reset email sent to {email} (store: {store.subdomain})"
)
except Exception as e:
db.rollback()
logger.error(f"Failed to send password reset email: {e}") # noqa: SEC021
else:
logger.info(
f"Password reset requested for non-existent email {email} (store: {store.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) # public
def reset_password(
request: Request, reset_token: str, new_password: str, db: Session = Depends(get_db)
):
"""
Reset customer password using reset token.
Store is automatically determined from request context.
Request Body:
- reset_token: Password reset token from email
- new_password: New password (minimum 8 characters)
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] reset_password for store {store.subdomain}",
extra={"store_id": store.id, "store_code": store.subdomain},
)
customer = customer_service.validate_and_reset_password(
db=db,
store_id=store.id,
reset_token=reset_token,
new_password=new_password,
)
db.commit()
logger.info(
f"Password reset completed for customer {customer.id} (store: {store.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: CustomerContext = 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: CustomerContext = 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 store.
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.store_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: CustomerContext = 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: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
List all addresses for authenticated customer.
Store is automatically determined from request context.
Returns all addresses sorted by default first, then by creation date.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] list_addresses for customer {customer.id}",
extra={
"store_id": store.id,
"store_code": store.subdomain,
"customer_id": customer.id,
},
)
addresses = customer_address_service.list_addresses(
db=db, store_id=store.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: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Get specific address by ID.
Store is automatically determined from request context.
Customer can only access their own addresses.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] get_address {address_id} for customer {customer.id}",
extra={
"store_id": store.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.get_address(
db=db, store_id=store.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: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Create new address for authenticated customer.
Store is automatically determined from request context.
Maximum 10 addresses per customer.
If is_default=True, clears default flag on other addresses of same type.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] create_address for customer {customer.id}",
extra={
"store_id": store.id,
"customer_id": customer.id,
"address_type": address_data.address_type,
},
)
address = customer_address_service.create_address(
db=db,
store_id=store.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: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Update existing address.
Store 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.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] update_address {address_id} for customer {customer.id}",
extra={
"store_id": store.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.update_address(
db=db,
store_id=store.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: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Delete address.
Store is automatically determined from request context.
Customer can only delete their own addresses.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] delete_address {address_id} for customer {customer.id}",
extra={
"store_id": store.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
customer_address_service.delete_address(
db=db, store_id=store.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: CustomerContext = Depends(get_current_customer_api),
db: Session = Depends(get_db),
):
"""
Set address as default for its type.
Store is automatically determined from request context.
Clears default flag on other addresses of the same type.
"""
store = getattr(request.state, "store", None)
if not store:
raise StoreNotFoundException("context", identifier_type="subdomain")
logger.debug(
f"[CUSTOMER_STOREFRONT] set_address_default {address_id} for customer {customer.id}",
extra={
"store_id": store.id,
"customer_id": customer.id,
"address_id": address_id,
},
)
address = customer_address_service.set_default(
db=db, store_id=store.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)