""" 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" )