MukeshKapoor25's picture
updates
e032470
"""
Customer authentication router for OTP-based authentication.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from app.auth.schemas.customer_auth import (
SendOTPRequest,
VerifyOTPRequest,
CustomerAuthResponse,
SendOTPResponse,
CustomerUpdateRequest,
CustomerProfileResponse,
CustomerUpdateResponse
)
from app.auth.services.customer_auth_service import CustomerAuthService
from app.dependencies.customer_auth import get_current_customer, CustomerUser
from app.core.config import settings
from app.core.logging import get_logger
logger = get_logger(__name__)
router = APIRouter(prefix="/customer", tags=["Customer Authentication"])
@router.post("/send-otp", response_model=SendOTPResponse)
async def send_customer_otp(request: SendOTPRequest):
"""
Send OTP to customer mobile number for authentication.
- **mobile**: Customer mobile number in international format (e.g., +919999999999)
**Process:**
1. Validates mobile number format
2. Generates 6-digit OTP
3. Stores OTP with 5-minute expiration
4. Sends OTP via SMS (currently logged for testing)
**Rate Limiting:**
- Maximum 3 verification attempts per OTP
- OTP expires after 5 minutes
- New OTP request replaces previous one
Raises:
HTTPException: 400 - Invalid mobile number format
HTTPException: 500 - Failed to send OTP
"""
try:
customer_auth_service = CustomerAuthService()
success, message, expires_in = await customer_auth_service.send_otp(request.mobile)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=message
)
logger.info(f"OTP sent to customer mobile: {request.mobile}")
return SendOTPResponse(
success=True,
message=message,
expires_in=expires_in
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error sending OTP to {request.mobile}: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred while sending OTP"
)
@router.post("/verify-otp", response_model=CustomerAuthResponse)
async def verify_customer_otp(request: VerifyOTPRequest):
"""
Verify OTP and authenticate customer.
- **mobile**: Customer mobile number used for OTP
- **otp**: 6-digit OTP code received via SMS
**Process:**
1. Validates OTP against stored record
2. Checks expiration and attempt limits
3. Finds existing customer or creates new one
4. Generates JWT access token
5. Returns customer authentication data
**Customer Creation:**
- New customers are automatically created on first successful OTP verification
- Customer profile can be completed later via separate endpoints
- Initial customer record contains only mobile number
**Session Handling:**
- Returns JWT access token for API authentication
- Token includes customer_id and mobile number
- Token expires based on system configuration (default: 24 hours)
Raises:
HTTPException: 400 - Invalid OTP format or mobile number
HTTPException: 401 - Invalid, expired, or already used OTP
HTTPException: 429 - Too many attempts
HTTPException: 500 - Server error
"""
try:
customer_auth_service = CustomerAuthService()
customer_data, message = await customer_auth_service.verify_otp(
request.mobile,
request.otp
)
if not customer_data:
# Determine appropriate status code based on message
if "expired" in message.lower():
status_code = status.HTTP_401_UNAUTHORIZED
elif "too many attempts" in message.lower():
status_code = status.HTTP_429_TOO_MANY_REQUESTS
else:
status_code = status.HTTP_401_UNAUTHORIZED
raise HTTPException(
status_code=status_code,
detail=message
)
# Create JWT token for customer
access_token = customer_auth_service.create_customer_token(customer_data)
logger.info(
f"Customer authenticated successfully: {customer_data['customer_id']}",
extra={
"event": "customer_login_success",
"customer_id": customer_data["customer_id"],
"mobile": request.mobile,
"is_new_customer": customer_data["is_new_customer"]
}
)
return CustomerAuthResponse(
access_token=access_token,
customer_id=customer_data["customer_id"],
is_new_customer=customer_data["is_new_customer"],
expires_in=settings.TOKEN_EXPIRATION_HOURS * 3600
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Unexpected error verifying OTP for {request.mobile}: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred during OTP verification"
)
@router.get("/me", response_model=CustomerProfileResponse)
async def get_customer_profile(
current_customer: CustomerUser = Depends(get_current_customer)
):
"""
Get current customer profile information.
Requires customer JWT token in Authorization header (Bearer token).
**Returns:**
- **customer_id**: Unique customer identifier
- **mobile**: Customer mobile number
- **name**: Customer full name
- **email**: Customer email address
- **status**: Customer status
- **merchant_id**: Associated merchant (if any)
- **is_new_customer**: Whether customer profile is incomplete
- **created_at**: Customer registration timestamp
- **updated_at**: Last profile update timestamp
**Usage:**
- Use this endpoint to verify customer authentication
- Get complete customer information for app initialization
- Check if customer profile needs completion
Raises:
HTTPException: 401 - Invalid or expired token
HTTPException: 403 - Not a customer token
HTTPException: 404 - Customer not found
"""
try:
customer_auth_service = CustomerAuthService()
customer_profile = await customer_auth_service.get_customer_profile(current_customer.customer_id)
if not customer_profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer profile not found"
)
logger.info(f"Customer profile accessed: {current_customer.customer_id}")
return CustomerProfileResponse(
customer_id=customer_profile["customer_id"],
mobile=customer_profile["mobile"],
name=customer_profile["name"],
email=customer_profile["email"],
gender=customer_profile["gender"],
dob=customer_profile["dob"],
status=customer_profile["status"],
merchant_id=customer_profile["merchant_id"],
is_new_customer=customer_profile["is_new_customer"],
created_at=customer_profile["created_at"].isoformat() if customer_profile["created_at"] else "",
updated_at=customer_profile["updated_at"].isoformat() if customer_profile["updated_at"] else ""
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting customer profile: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to get customer profile"
)
@router.put("/profile", response_model=CustomerUpdateResponse)
async def update_customer_profile_put(
request: CustomerUpdateRequest,
current_customer: CustomerUser = Depends(get_current_customer)
):
"""
Update customer profile information (PUT - full update).
Requires customer JWT token in Authorization header (Bearer token).
**Request Body:**
- **name**: Customer full name (optional)
- **email**: Customer email address (optional, must be unique)
- **gender**: Customer gender (optional, one of: male, female, other, prefer_not_to_say)
- **dob**: Customer date of birth (optional, YYYY-MM-DD format)
**Process:**
1. Validates customer authentication
2. Validates input data (email format, name length, gender values, date format)
3. Checks email uniqueness if provided
4. Updates customer profile in database
5. Returns updated profile information
**Validation Rules:**
- Name: 1-100 characters, no empty/whitespace-only values
- Email: Valid email format, unique across all customers
- Gender: Must be one of: male, female, other, prefer_not_to_say
- DOB: Valid date, not in future, reasonable age (0-150 years)
- All fields can be set to null to remove existing values
**Usage:**
- Complete customer profile after registration
- Update customer contact information
- Remove email by setting it to null
Raises:
HTTPException: 400 - Invalid input data or email already exists
HTTPException: 401 - Invalid or expired token
HTTPException: 403 - Not a customer token
HTTPException: 404 - Customer not found
HTTPException: 500 - Server error
"""
try:
customer_auth_service = CustomerAuthService()
# Prepare update data
update_data = {}
if request.name is not None:
update_data["name"] = request.name
if hasattr(request, 'email'): # Check if email field was provided
update_data["email"] = request.email
if hasattr(request, 'gender'): # Check if gender field was provided
update_data["gender"] = request.gender
if hasattr(request, 'dob'): # Check if dob field was provided
update_data["dob"] = request.dob
# Update customer profile
success, message, updated_customer = await customer_auth_service.update_customer_profile(
current_customer.customer_id,
update_data
)
if not success:
if "not found" in message.lower():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=message
)
elif "already registered" in message.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message
)
logger.info(
f"Customer profile updated via PUT: {current_customer.customer_id}",
extra={
"event": "customer_profile_update",
"customer_id": current_customer.customer_id,
"method": "PUT",
"fields_updated": list(update_data.keys())
}
)
return CustomerUpdateResponse(
success=True,
message=message,
customer=CustomerProfileResponse(
customer_id=updated_customer["customer_id"],
mobile=updated_customer["mobile"],
name=updated_customer["name"],
email=updated_customer["email"],
gender=updated_customer["gender"],
dob=updated_customer["dob"],
status=updated_customer["status"],
merchant_id=updated_customer["merchant_id"],
is_new_customer=updated_customer["is_new_customer"],
created_at=updated_customer["created_at"].isoformat() if updated_customer["created_at"] else "",
updated_at=updated_customer["updated_at"].isoformat() if updated_customer["updated_at"] else ""
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating customer profile via PUT: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update customer profile"
)
@router.patch("/profile", response_model=CustomerUpdateResponse)
async def update_customer_profile_patch(
request: CustomerUpdateRequest,
current_customer: CustomerUser = Depends(get_current_customer)
):
"""
Update customer profile information (PATCH - partial update).
Requires customer JWT token in Authorization header (Bearer token).
**Request Body:**
- **name**: Customer full name (optional)
- **email**: Customer email address (optional, must be unique)
- **gender**: Customer gender (optional, one of: male, female, other, prefer_not_to_say)
- **dob**: Customer date of birth (optional, YYYY-MM-DD format)
**Process:**
1. Validates customer authentication
2. Validates input data (email format, name length, gender values, date format)
3. Checks email uniqueness if provided
4. Updates only provided fields in database
5. Returns updated profile information
**Validation Rules:**
- Name: 1-100 characters, no empty/whitespace-only values
- Email: Valid email format, unique across all customers
- Gender: Must be one of: male, female, other, prefer_not_to_say
- DOB: Valid date, not in future, reasonable age (0-150 years)
- All fields can be set to null to remove existing values
- Only provided fields are updated (partial update)
**Usage:**
- Update specific customer profile fields
- Partial profile updates from mobile app
- Progressive profile completion
Raises:
HTTPException: 400 - Invalid input data or email already exists
HTTPException: 401 - Invalid or expired token
HTTPException: 403 - Not a customer token
HTTPException: 404 - Customer not found
HTTPException: 500 - Server error
"""
try:
customer_auth_service = CustomerAuthService()
# Prepare update data (only include fields that were explicitly provided)
update_data = {}
# Check if name was provided in request
if request.name is not None:
update_data["name"] = request.name
# Check if email was provided in request (including None to clear email)
if hasattr(request, 'email') and request.email is not None:
update_data["email"] = request.email
elif hasattr(request, 'email') and request.email is None:
update_data["email"] = None
# Check if gender was provided in request (including None to clear gender)
if hasattr(request, 'gender') and request.gender is not None:
update_data["gender"] = request.gender
elif hasattr(request, 'gender') and request.gender is None:
update_data["gender"] = None
# Check if dob was provided in request (including None to clear dob)
if hasattr(request, 'dob') and request.dob is not None:
update_data["dob"] = request.dob
elif hasattr(request, 'dob') and request.dob is None:
update_data["dob"] = None
# If no fields to update, return current profile
if not update_data:
current_profile = await customer_auth_service.get_customer_profile(current_customer.customer_id)
if not current_profile:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Customer not found"
)
return CustomerUpdateResponse(
success=True,
message="No changes requested",
customer=CustomerProfileResponse(
customer_id=current_profile["customer_id"],
mobile=current_profile["mobile"],
name=current_profile["name"],
email=current_profile["email"],
gender=current_profile["gender"],
dob=current_profile["dob"],
status=current_profile["status"],
merchant_id=current_profile["merchant_id"],
is_new_customer=current_profile["is_new_customer"],
created_at=current_profile["created_at"].isoformat() if current_profile["created_at"] else "",
updated_at=current_profile["updated_at"].isoformat() if current_profile["updated_at"] else ""
)
)
# Update customer profile
success, message, updated_customer = await customer_auth_service.update_customer_profile(
current_customer.customer_id,
update_data
)
if not success:
if "not found" in message.lower():
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=message
)
elif "already registered" in message.lower():
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message
)
else:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=message
)
logger.info(
f"Customer profile updated via PATCH: {current_customer.customer_id}",
extra={
"event": "customer_profile_update",
"customer_id": current_customer.customer_id,
"method": "PATCH",
"fields_updated": list(update_data.keys())
}
)
return CustomerUpdateResponse(
success=True,
message=message,
customer=CustomerProfileResponse(
customer_id=updated_customer["customer_id"],
mobile=updated_customer["mobile"],
name=updated_customer["name"],
email=updated_customer["email"],
gender=updated_customer["gender"],
dob=updated_customer["dob"],
status=updated_customer["status"],
merchant_id=updated_customer["merchant_id"],
is_new_customer=updated_customer["is_new_customer"],
created_at=updated_customer["created_at"].isoformat() if updated_customer["created_at"] else "",
updated_at=updated_customer["updated_at"].isoformat() if updated_customer["updated_at"] else ""
)
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating customer profile via PATCH: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update customer profile"
)
@router.post("/logout")
async def logout_customer(
current_customer: CustomerUser = Depends(get_current_customer)
):
"""
Logout current customer.
Requires customer JWT token in Authorization header (Bearer token).
**Process:**
- Validates customer JWT token
- Records logout event for audit purposes
- Returns success confirmation
**Note:** Since we're using stateless JWT tokens, the client is responsible for:
- Removing the token from local storage
- Clearing any cached customer data
- Redirecting to login screen
**Security:**
- Logs logout event with customer information
- Provides audit trail for customer sessions
Raises:
HTTPException: 401 - Invalid or expired token
HTTPException: 403 - Not a customer token
"""
try:
logger.info(
f"Customer logged out: {current_customer.customer_id}",
extra={
"event": "customer_logout",
"customer_id": current_customer.customer_id,
"mobile": current_customer.mobile
}
)
return {
"success": True,
"message": "Customer logged out successfully"
}
except Exception as e:
logger.error(f"Error during customer logout: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="An unexpected error occurred during logout"
)