Spaces:
Running
Running
| """ | |
| 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"]) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) | |
| 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" | |
| ) |