Spaces:
Sleeping
Sleeping
Commit ·
19a1450
1
Parent(s): 846cbb0
feat(service-professional): Add OTP-based authentication system for service professionals
Browse files- Add service professional authentication schema with phone and OTP validation models
- Implement service professional auth service with OTP generation, verification, and JWT token creation
- Create service professional router with send-otp, verify-otp, and logout endpoints
- Integrate WhatsApp OTP delivery via WATI for secure authentication
- Add comprehensive authentication guide with API documentation, setup instructions, and test data
- Register service professional routes in main application
- Support merchant context in JWT tokens for multi-tenant access control
SERVICE_PROFESSIONAL_AUTH_GUIDE.md
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Service Professional Authentication Guide
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
Complete end-to-end authentication system for service professionals (spa staff, salon staff, beauticians, therapists, etc.) using OTP-based login via WhatsApp.
|
| 6 |
+
|
| 7 |
+
## Architecture
|
| 8 |
+
|
| 9 |
+
### Collection
|
| 10 |
+
- **Database**: `cuatrolabs_db`
|
| 11 |
+
- **Collection**: `scm_service_professionals`
|
| 12 |
+
- **Purpose**: Stores service professional profiles
|
| 13 |
+
|
| 14 |
+
### Authentication Flow
|
| 15 |
+
1. Service professional requests OTP via phone number
|
| 16 |
+
2. System validates professional exists and is active
|
| 17 |
+
3. OTP generated and sent via WhatsApp (WATI)
|
| 18 |
+
4. OTP stored in Redis with 5-minute expiration
|
| 19 |
+
5. Professional verifies OTP
|
| 20 |
+
6. System generates JWT token with merchant context
|
| 21 |
+
7. Professional can access protected endpoints
|
| 22 |
+
|
| 23 |
+
## API Endpoints
|
| 24 |
+
|
| 25 |
+
### 1. Send OTP
|
| 26 |
+
```http
|
| 27 |
+
POST /service-professional/send-otp
|
| 28 |
+
Content-Type: application/json
|
| 29 |
+
|
| 30 |
+
{
|
| 31 |
+
"phone": "+919876543210"
|
| 32 |
+
}
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
**Response:**
|
| 36 |
+
```json
|
| 37 |
+
{
|
| 38 |
+
"success": true,
|
| 39 |
+
"message": "OTP sent successfully via WhatsApp",
|
| 40 |
+
"expires_in": 300
|
| 41 |
+
}
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
### 2. Verify OTP & Login
|
| 45 |
+
```http
|
| 46 |
+
POST /service-professional/verify-otp
|
| 47 |
+
Content-Type: application/json
|
| 48 |
+
|
| 49 |
+
{
|
| 50 |
+
"phone": "+919876543210",
|
| 51 |
+
"otp": "123456"
|
| 52 |
+
}
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
**Response:**
|
| 56 |
+
```json
|
| 57 |
+
{
|
| 58 |
+
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
| 59 |
+
"token_type": "bearer",
|
| 60 |
+
"expires_in": 86400,
|
| 61 |
+
"professional_info": {
|
| 62 |
+
"staff_id": "550e8400-e29b-41d4-a716-446655440001",
|
| 63 |
+
"staff_code": "SP001",
|
| 64 |
+
"name": "Priya Sharma",
|
| 65 |
+
"phone": "+919876543210",
|
| 66 |
+
"email": "priya.sharma@example.com",
|
| 67 |
+
"designation": "Senior Beautician",
|
| 68 |
+
"merchant_id": "550e8400-e29b-41d4-a716-446655440000",
|
| 69 |
+
"status": "active",
|
| 70 |
+
"user_type": "service_professional"
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
### 3. Logout
|
| 76 |
+
```http
|
| 77 |
+
POST /service-professional/logout
|
| 78 |
+
Authorization: Bearer {access_token}
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
**Response:**
|
| 82 |
+
```json
|
| 83 |
+
{
|
| 84 |
+
"success": true,
|
| 85 |
+
"message": "Service professional logged out successfully"
|
| 86 |
+
}
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
## Sample Data
|
| 90 |
+
|
| 91 |
+
### Test Professionals
|
| 92 |
+
|
| 93 |
+
#### Active Professionals (Can Login)
|
| 94 |
+
1. **Priya Sharma** - Senior Beautician
|
| 95 |
+
- Phone: `+919876543210`
|
| 96 |
+
- Skills: facial, makeup, hair_styling, manicure, pedicure
|
| 97 |
+
|
| 98 |
+
2. **Rahul Verma** - Massage Therapist
|
| 99 |
+
- Phone: `+919876543211`
|
| 100 |
+
- Skills: swedish_massage, deep_tissue, aromatherapy, hot_stone
|
| 101 |
+
|
| 102 |
+
3. **Anjali Patel** - Hair Stylist
|
| 103 |
+
- Phone: `+919876543212`
|
| 104 |
+
- Skills: hair_cut, hair_color, hair_treatment, styling, keratin
|
| 105 |
+
|
| 106 |
+
4. **Vikram Singh** - Spa Manager
|
| 107 |
+
- Phone: `+919876543213`
|
| 108 |
+
- Skills: management, customer_service, scheduling, training
|
| 109 |
+
|
| 110 |
+
5. **Meera Reddy** - Nail Technician
|
| 111 |
+
- Phone: `+919876543214`
|
| 112 |
+
- Skills: manicure, pedicure, nail_art, gel_nails, acrylic_nails
|
| 113 |
+
|
| 114 |
+
6. **Arjun Kumar** - Fitness Trainer
|
| 115 |
+
- Phone: `+919876543215`
|
| 116 |
+
- Skills: personal_training, yoga, pilates, strength_training, cardio
|
| 117 |
+
|
| 118 |
+
#### Inactive Professionals (Cannot Login)
|
| 119 |
+
7. **Sneha Gupta** - Esthetician
|
| 120 |
+
- Phone: `+919876543216`
|
| 121 |
+
- Status: `inactive`
|
| 122 |
+
- Skills: facials, waxing, threading, skin_analysis, chemical_peels
|
| 123 |
+
|
| 124 |
+
## Setup Instructions
|
| 125 |
+
|
| 126 |
+
### 1. Create Sample Data
|
| 127 |
+
```bash
|
| 128 |
+
# Run the Python script to create sample professionals
|
| 129 |
+
cd utils
|
| 130 |
+
python create_sample_service_professionals.py
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
### 2. Manual MongoDB Insert
|
| 134 |
+
```bash
|
| 135 |
+
# Import JSON data directly
|
| 136 |
+
mongoimport --db cuatrolabs_db \
|
| 137 |
+
--collection scm_service_professionals \
|
| 138 |
+
--file utils/sample_service_professionals.json \
|
| 139 |
+
--jsonArray
|
| 140 |
+
```
|
| 141 |
+
|
| 142 |
+
### 3. Verify Data
|
| 143 |
+
```javascript
|
| 144 |
+
// MongoDB shell
|
| 145 |
+
use cuatrolabs_db
|
| 146 |
+
db.scm_service_professionals.find().pretty()
|
| 147 |
+
db.scm_service_professionals.countDocuments()
|
| 148 |
+
```
|
| 149 |
+
|
| 150 |
+
## Testing
|
| 151 |
+
|
| 152 |
+
### Using cURL
|
| 153 |
+
|
| 154 |
+
#### 1. Send OTP
|
| 155 |
+
```bash
|
| 156 |
+
curl -X POST http://localhost:8002/service-professional/send-otp \
|
| 157 |
+
-H "Content-Type: application/json" \
|
| 158 |
+
-d '{"phone": "+919876543210"}'
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
+
#### 2. Verify OTP (replace with actual OTP)
|
| 162 |
+
```bash
|
| 163 |
+
curl -X POST http://localhost:8002/service-professional/verify-otp \
|
| 164 |
+
-H "Content-Type: application/json" \
|
| 165 |
+
-d '{"phone": "+919876543210", "otp": "123456"}'
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
#### 3. Logout (replace TOKEN)
|
| 169 |
+
```bash
|
| 170 |
+
curl -X POST http://localhost:8002/service-professional/logout \
|
| 171 |
+
-H "Authorization: Bearer TOKEN"
|
| 172 |
+
```
|
| 173 |
+
|
| 174 |
+
### Using Python
|
| 175 |
+
```python
|
| 176 |
+
import requests
|
| 177 |
+
|
| 178 |
+
# 1. Send OTP
|
| 179 |
+
response = requests.post(
|
| 180 |
+
"http://localhost:8002/service-professional/send-otp",
|
| 181 |
+
json={"phone": "+919876543210"}
|
| 182 |
+
)
|
| 183 |
+
print(response.json())
|
| 184 |
+
|
| 185 |
+
# 2. Verify OTP
|
| 186 |
+
response = requests.post(
|
| 187 |
+
"http://localhost:8002/service-professional/verify-otp",
|
| 188 |
+
json={"phone": "+919876543210", "otp": "123456"}
|
| 189 |
+
)
|
| 190 |
+
data = response.json()
|
| 191 |
+
token = data["access_token"]
|
| 192 |
+
print(f"Token: {token}")
|
| 193 |
+
|
| 194 |
+
# 3. Logout
|
| 195 |
+
response = requests.post(
|
| 196 |
+
"http://localhost:8002/service-professional/logout",
|
| 197 |
+
headers={"Authorization": f"Bearer {token}"}
|
| 198 |
+
)
|
| 199 |
+
print(response.json())
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
## Security Features
|
| 203 |
+
|
| 204 |
+
### OTP Security
|
| 205 |
+
- **Expiration**: 5 minutes
|
| 206 |
+
- **Attempts**: Maximum 3 attempts per OTP
|
| 207 |
+
- **One-time use**: OTP deleted after successful verification
|
| 208 |
+
- **Redis storage**: Secure temporary storage
|
| 209 |
+
|
| 210 |
+
### Status Validation
|
| 211 |
+
- Only `active` professionals can login
|
| 212 |
+
- Inactive/suspended accounts are rejected
|
| 213 |
+
- Status checked at both OTP send and verify stages
|
| 214 |
+
|
| 215 |
+
### JWT Token
|
| 216 |
+
- **Expiration**: 24 hours (configurable)
|
| 217 |
+
- **Payload**: staff_id, staff_code, merchant_id, user_type
|
| 218 |
+
- **Algorithm**: HS256
|
| 219 |
+
- **Stateless**: No server-side session storage
|
| 220 |
+
|
| 221 |
+
## Error Handling
|
| 222 |
+
|
| 223 |
+
### Common Errors
|
| 224 |
+
|
| 225 |
+
#### 404 - Professional Not Found
|
| 226 |
+
```json
|
| 227 |
+
{
|
| 228 |
+
"detail": "Service professional not found for this phone number"
|
| 229 |
+
}
|
| 230 |
+
```
|
| 231 |
+
|
| 232 |
+
#### 403 - Inactive Account
|
| 233 |
+
```json
|
| 234 |
+
{
|
| 235 |
+
"detail": "Service professional account is inactive"
|
| 236 |
+
}
|
| 237 |
+
```
|
| 238 |
+
|
| 239 |
+
#### 401 - Invalid OTP
|
| 240 |
+
```json
|
| 241 |
+
{
|
| 242 |
+
"detail": "Invalid OTP"
|
| 243 |
+
}
|
| 244 |
+
```
|
| 245 |
+
|
| 246 |
+
#### 401 - Expired OTP
|
| 247 |
+
```json
|
| 248 |
+
{
|
| 249 |
+
"detail": "OTP has expired"
|
| 250 |
+
}
|
| 251 |
+
```
|
| 252 |
+
|
| 253 |
+
#### 429 - Too Many Attempts
|
| 254 |
+
```json
|
| 255 |
+
{
|
| 256 |
+
"detail": "Too many attempts. Please request a new OTP"
|
| 257 |
+
}
|
| 258 |
+
```
|
| 259 |
+
|
| 260 |
+
## Files Created
|
| 261 |
+
|
| 262 |
+
### Schema
|
| 263 |
+
- `app/auth/schemas/service_professional_auth.py`
|
| 264 |
+
- Request/response models
|
| 265 |
+
- Phone and OTP validation
|
| 266 |
+
|
| 267 |
+
### Service
|
| 268 |
+
- `app/auth/services/service_professional_auth_service.py`
|
| 269 |
+
- Business logic
|
| 270 |
+
- OTP generation and verification
|
| 271 |
+
- JWT token creation
|
| 272 |
+
- WhatsApp integration
|
| 273 |
+
|
| 274 |
+
### Router
|
| 275 |
+
- `app/auth/controllers/service_professional_router.py`
|
| 276 |
+
- API endpoints
|
| 277 |
+
- Request handling
|
| 278 |
+
- Error responses
|
| 279 |
+
|
| 280 |
+
### Test Data
|
| 281 |
+
- `utils/create_sample_service_professionals.py` - Python script
|
| 282 |
+
- `utils/sample_service_professionals.json` - JSON data
|
| 283 |
+
|
| 284 |
+
## Configuration
|
| 285 |
+
|
| 286 |
+
### Environment Variables
|
| 287 |
+
```env
|
| 288 |
+
# WhatsApp OTP Settings
|
| 289 |
+
WATI_STAFF_OTP_TEMPLATE_NAME=staff_otp_template
|
| 290 |
+
|
| 291 |
+
# JWT Settings
|
| 292 |
+
SECRET_KEY=your-secret-key
|
| 293 |
+
ALGORITHM=HS256
|
| 294 |
+
TOKEN_EXPIRATION_HOURS=24
|
| 295 |
+
|
| 296 |
+
# Redis Settings
|
| 297 |
+
REDIS_HOST=localhost
|
| 298 |
+
REDIS_PORT=6379
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
## Integration Points
|
| 302 |
+
|
| 303 |
+
### Dependencies
|
| 304 |
+
- **MongoDB**: Professional data storage
|
| 305 |
+
- **Redis**: OTP caching
|
| 306 |
+
- **WATI**: WhatsApp OTP delivery
|
| 307 |
+
- **JWT**: Token generation
|
| 308 |
+
|
| 309 |
+
### Related Collections
|
| 310 |
+
- `scm_service_professionals` - Professional profiles
|
| 311 |
+
- `scm_merchants` - Merchant information
|
| 312 |
+
|
| 313 |
+
## Monitoring & Logging
|
| 314 |
+
|
| 315 |
+
### Log Events
|
| 316 |
+
- `service_professional_mobile_otp_login` - Successful login
|
| 317 |
+
- `service_professional_logout` - Logout event
|
| 318 |
+
- OTP send/verify attempts
|
| 319 |
+
- Failed authentication attempts
|
| 320 |
+
|
| 321 |
+
### Metrics to Track
|
| 322 |
+
- OTP delivery success rate
|
| 323 |
+
- Login success rate
|
| 324 |
+
- Average OTP verification time
|
| 325 |
+
- Failed login attempts by phone
|
| 326 |
+
|
| 327 |
+
## Future Enhancements
|
| 328 |
+
|
| 329 |
+
### Potential Features
|
| 330 |
+
1. **Refresh tokens** for extended sessions
|
| 331 |
+
2. **Biometric authentication** for mobile apps
|
| 332 |
+
3. **Multi-factor authentication** for sensitive operations
|
| 333 |
+
4. **Session management** with Redis
|
| 334 |
+
5. **Rate limiting** per phone number
|
| 335 |
+
6. **Geolocation validation** for security
|
| 336 |
+
7. **Device fingerprinting** for fraud detection
|
| 337 |
+
|
| 338 |
+
## Support
|
| 339 |
+
|
| 340 |
+
For issues or questions:
|
| 341 |
+
- Check logs in `cuatrolabs-auth-ms/logs/`
|
| 342 |
+
- Review Redis cache: `redis-cli KEYS "service_professional_otp:*"`
|
| 343 |
+
- Verify MongoDB data: `db.scm_service_professionals.find()`
|
app/auth/controllers/service_professional_router.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Professional authentication router for mobile OTP login.
|
| 3 |
+
Handles authentication for spa staff, salon staff, beauticians, therapists, etc.
|
| 4 |
+
"""
|
| 5 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
| 6 |
+
from datetime import timedelta
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from app.auth.services.service_professional_auth_service import ServiceProfessionalAuthService
|
| 10 |
+
from app.auth.schemas.service_professional_auth import (
|
| 11 |
+
ServiceProfessionalSendOTPRequest,
|
| 12 |
+
ServiceProfessionalSendOTPResponse,
|
| 13 |
+
ServiceProfessionalVerifyOTPRequest,
|
| 14 |
+
ServiceProfessionalAuthResponse
|
| 15 |
+
)
|
| 16 |
+
from app.core.config import settings
|
| 17 |
+
from app.core.logging import get_logger
|
| 18 |
+
|
| 19 |
+
logger = get_logger(__name__)
|
| 20 |
+
|
| 21 |
+
router = APIRouter(prefix="/service-professional", tags=["Service Professional Authentication"])
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.post("/send-otp", response_model=ServiceProfessionalSendOTPResponse)
|
| 25 |
+
async def send_service_professional_otp(request: ServiceProfessionalSendOTPRequest):
|
| 26 |
+
"""
|
| 27 |
+
Send OTP to service professional mobile number for authentication via WhatsApp.
|
| 28 |
+
|
| 29 |
+
**Process:**
|
| 30 |
+
1. Validates phone number format
|
| 31 |
+
2. Verifies service professional exists with this phone in scm_service_professionals collection
|
| 32 |
+
3. Validates professional is active
|
| 33 |
+
4. Generates random 6-digit OTP
|
| 34 |
+
5. Sends OTP via WATI WhatsApp API
|
| 35 |
+
6. Stores OTP in Redis with 5-minute expiration
|
| 36 |
+
|
| 37 |
+
**Security:**
|
| 38 |
+
- Only allows active service professionals
|
| 39 |
+
- OTP expires in 5 minutes
|
| 40 |
+
- Maximum 3 verification attempts
|
| 41 |
+
- One-time use only
|
| 42 |
+
|
| 43 |
+
**Request Body:**
|
| 44 |
+
- **phone**: Service professional mobile number (e.g., +919999999999)
|
| 45 |
+
|
| 46 |
+
**Response:**
|
| 47 |
+
- **success**: Whether OTP was sent successfully
|
| 48 |
+
- **message**: Response message
|
| 49 |
+
- **expires_in**: OTP expiration time in seconds (300 = 5 minutes)
|
| 50 |
+
|
| 51 |
+
Raises:
|
| 52 |
+
HTTPException: 400 - Invalid phone format
|
| 53 |
+
HTTPException: 404 - Service professional not found
|
| 54 |
+
HTTPException: 403 - Inactive account
|
| 55 |
+
HTTPException: 500 - Failed to send OTP
|
| 56 |
+
"""
|
| 57 |
+
try:
|
| 58 |
+
service = ServiceProfessionalAuthService()
|
| 59 |
+
|
| 60 |
+
success, message, expires_in = await service.send_otp(request.phone)
|
| 61 |
+
|
| 62 |
+
if not success:
|
| 63 |
+
# Determine appropriate status code based on message
|
| 64 |
+
if "not found" in message.lower():
|
| 65 |
+
status_code = status.HTTP_404_NOT_FOUND
|
| 66 |
+
elif "inactive" in message.lower() or "suspended" in message.lower():
|
| 67 |
+
status_code = status.HTTP_403_FORBIDDEN
|
| 68 |
+
else:
|
| 69 |
+
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
| 70 |
+
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=status_code,
|
| 73 |
+
detail=message
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
return ServiceProfessionalSendOTPResponse(
|
| 77 |
+
success=True,
|
| 78 |
+
message=message,
|
| 79 |
+
expires_in=expires_in
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
except HTTPException:
|
| 83 |
+
raise
|
| 84 |
+
except Exception as e:
|
| 85 |
+
logger.error(f"Unexpected error sending service professional OTP: {str(e)}", exc_info=True)
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 88 |
+
detail="An unexpected error occurred while sending OTP"
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
@router.post("/verify-otp", response_model=ServiceProfessionalAuthResponse)
|
| 93 |
+
async def verify_service_professional_otp(
|
| 94 |
+
request: Request,
|
| 95 |
+
verify_request: ServiceProfessionalVerifyOTPRequest
|
| 96 |
+
):
|
| 97 |
+
"""
|
| 98 |
+
Verify OTP and authenticate service professional.
|
| 99 |
+
|
| 100 |
+
**Process:**
|
| 101 |
+
1. Validates phone number and OTP
|
| 102 |
+
2. Verifies OTP from Redis cache
|
| 103 |
+
3. Validates professional status
|
| 104 |
+
4. Generates JWT access token
|
| 105 |
+
5. Returns authentication response
|
| 106 |
+
|
| 107 |
+
**Security:**
|
| 108 |
+
- Only allows active service professionals
|
| 109 |
+
- OTP validation via Redis cache
|
| 110 |
+
- OTP expires in 5 minutes
|
| 111 |
+
- Maximum 3 verification attempts
|
| 112 |
+
- One-time use only
|
| 113 |
+
- JWT token with merchant context
|
| 114 |
+
|
| 115 |
+
**Request Body:**
|
| 116 |
+
- **phone**: Service professional mobile number (e.g., +919999999999)
|
| 117 |
+
- **otp**: 6-digit OTP code received via WhatsApp
|
| 118 |
+
|
| 119 |
+
**Response:**
|
| 120 |
+
- **access_token**: JWT token for authentication
|
| 121 |
+
- **token_type**: "bearer"
|
| 122 |
+
- **expires_in**: Token expiration time in seconds
|
| 123 |
+
- **professional_info**: Service professional information
|
| 124 |
+
|
| 125 |
+
Raises:
|
| 126 |
+
HTTPException: 400 - Missing phone or OTP
|
| 127 |
+
HTTPException: 401 - Invalid OTP or professional not found
|
| 128 |
+
HTTPException: 403 - Inactive account
|
| 129 |
+
HTTPException: 429 - Too many attempts
|
| 130 |
+
HTTPException: 500 - Server error
|
| 131 |
+
"""
|
| 132 |
+
try:
|
| 133 |
+
# Validate input
|
| 134 |
+
if not verify_request.phone or not verify_request.otp:
|
| 135 |
+
raise HTTPException(
|
| 136 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 137 |
+
detail="Phone and OTP are required"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
# Verify OTP using ServiceProfessionalAuthService
|
| 141 |
+
service = ServiceProfessionalAuthService()
|
| 142 |
+
professional_data, verify_message = await service.verify_otp(
|
| 143 |
+
verify_request.phone,
|
| 144 |
+
verify_request.otp
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
if not professional_data:
|
| 148 |
+
# Determine appropriate status code based on message
|
| 149 |
+
if "expired" in verify_message.lower():
|
| 150 |
+
status_code = status.HTTP_401_UNAUTHORIZED
|
| 151 |
+
elif "too many attempts" in verify_message.lower():
|
| 152 |
+
status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
| 153 |
+
else:
|
| 154 |
+
status_code = status.HTTP_401_UNAUTHORIZED
|
| 155 |
+
|
| 156 |
+
logger.warning(f"Service professional OTP verification failed for phone: {verify_request.phone}")
|
| 157 |
+
raise HTTPException(
|
| 158 |
+
status_code=status_code,
|
| 159 |
+
detail=verify_message
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
# Create access token for service professional
|
| 163 |
+
try:
|
| 164 |
+
access_token = service.create_professional_token(professional_data)
|
| 165 |
+
access_token_expires = timedelta(hours=settings.TOKEN_EXPIRATION_HOURS)
|
| 166 |
+
except Exception as token_error:
|
| 167 |
+
logger.error(
|
| 168 |
+
f"Error creating access token for service professional {professional_data['staff_id']}: {token_error}",
|
| 169 |
+
exc_info=True
|
| 170 |
+
)
|
| 171 |
+
raise HTTPException(
|
| 172 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 173 |
+
detail="Failed to generate authentication token"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Prepare professional info response
|
| 177 |
+
professional_info = {
|
| 178 |
+
"staff_id": professional_data["staff_id"],
|
| 179 |
+
"staff_code": professional_data["staff_code"],
|
| 180 |
+
"name": professional_data["name"],
|
| 181 |
+
"phone": professional_data["phone"],
|
| 182 |
+
"email": professional_data.get("email"),
|
| 183 |
+
"designation": professional_data.get("designation"),
|
| 184 |
+
"status": professional_data["status"],
|
| 185 |
+
"user_type": "service_professional"
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
logger.info(
|
| 189 |
+
f"Service professional logged in via mobile OTP: {professional_data['staff_code']}",
|
| 190 |
+
extra={
|
| 191 |
+
"event": "service_professional_mobile_otp_login",
|
| 192 |
+
"staff_id": professional_data["staff_id"],
|
| 193 |
+
"staff_code": professional_data["staff_code"],
|
| 194 |
+
"phone": verify_request.phone
|
| 195 |
+
}
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
return ServiceProfessionalAuthResponse(
|
| 199 |
+
access_token=access_token,
|
| 200 |
+
token_type="bearer",
|
| 201 |
+
expires_in=int(access_token_expires.total_seconds()),
|
| 202 |
+
professional_info=professional_info
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
except HTTPException:
|
| 206 |
+
raise
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.error(f"Unexpected error in service professional OTP verification: {str(e)}", exc_info=True)
|
| 209 |
+
raise HTTPException(
|
| 210 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 211 |
+
detail="An unexpected error occurred during authentication"
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@router.post("/logout")
|
| 216 |
+
async def logout_service_professional(request: Request):
|
| 217 |
+
"""
|
| 218 |
+
Logout current service professional.
|
| 219 |
+
|
| 220 |
+
**Process:**
|
| 221 |
+
- Records logout event for audit purposes
|
| 222 |
+
- Returns success confirmation
|
| 223 |
+
|
| 224 |
+
**Note:** Since we're using stateless JWT tokens, the client is responsible for:
|
| 225 |
+
- Removing the token from local storage
|
| 226 |
+
- Clearing any cached professional data
|
| 227 |
+
- Redirecting to login screen
|
| 228 |
+
|
| 229 |
+
**Security:**
|
| 230 |
+
- Logs logout event with professional information
|
| 231 |
+
- Provides audit trail for professional sessions
|
| 232 |
+
|
| 233 |
+
Returns:
|
| 234 |
+
Success message
|
| 235 |
+
"""
|
| 236 |
+
try:
|
| 237 |
+
# Get client information for audit logging
|
| 238 |
+
client_ip = request.client.host if request.client else None
|
| 239 |
+
user_agent = request.headers.get("User-Agent")
|
| 240 |
+
|
| 241 |
+
logger.info(
|
| 242 |
+
"Service professional logged out",
|
| 243 |
+
extra={
|
| 244 |
+
"event": "service_professional_logout",
|
| 245 |
+
"ip_address": client_ip,
|
| 246 |
+
"user_agent": user_agent
|
| 247 |
+
}
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
return {
|
| 251 |
+
"success": True,
|
| 252 |
+
"message": "Service professional logged out successfully"
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
except Exception as e:
|
| 256 |
+
logger.error(f"Error during service professional logout: {str(e)}", exc_info=True)
|
| 257 |
+
raise HTTPException(
|
| 258 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 259 |
+
detail="An unexpected error occurred during logout"
|
| 260 |
+
)
|
app/auth/schemas/service_professional_auth.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Professional authentication schemas for OTP-based login.
|
| 3 |
+
Service professionals include spa staff, salon staff, beauticians, therapists, etc.
|
| 4 |
+
"""
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from pydantic import BaseModel, Field, field_validator
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
PHONE_REGEX = re.compile(r"^\+?[0-9\-\s]{8,20}$")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ServiceProfessionalSendOTPRequest(BaseModel):
|
| 13 |
+
"""Request schema for sending OTP to service professional."""
|
| 14 |
+
phone: str = Field(..., min_length=8, max_length=20, description="Service professional mobile number with country code")
|
| 15 |
+
|
| 16 |
+
@field_validator("phone")
|
| 17 |
+
@classmethod
|
| 18 |
+
def validate_phone(cls, v: str) -> str:
|
| 19 |
+
if not PHONE_REGEX.match(v):
|
| 20 |
+
raise ValueError("Invalid phone number format; use international format like +919999999999")
|
| 21 |
+
return v
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ServiceProfessionalVerifyOTPRequest(BaseModel):
|
| 25 |
+
"""Request schema for verifying service professional OTP."""
|
| 26 |
+
phone: str = Field(..., min_length=8, max_length=20, description="Service professional mobile number with country code")
|
| 27 |
+
otp: str = Field(..., min_length=4, max_length=6, description="OTP code")
|
| 28 |
+
|
| 29 |
+
@field_validator("phone")
|
| 30 |
+
@classmethod
|
| 31 |
+
def validate_phone(cls, v: str) -> str:
|
| 32 |
+
if not PHONE_REGEX.match(v):
|
| 33 |
+
raise ValueError("Invalid phone number format; use international format like +919999999999")
|
| 34 |
+
return v
|
| 35 |
+
|
| 36 |
+
@field_validator("otp")
|
| 37 |
+
@classmethod
|
| 38 |
+
def validate_otp(cls, v: str) -> str:
|
| 39 |
+
if not v.isdigit():
|
| 40 |
+
raise ValueError("OTP must contain only digits")
|
| 41 |
+
return v
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class ServiceProfessionalSendOTPResponse(BaseModel):
|
| 45 |
+
"""Response schema for service professional OTP send request."""
|
| 46 |
+
success: bool = Field(..., description="Whether OTP was sent successfully")
|
| 47 |
+
message: str = Field(..., description="Response message")
|
| 48 |
+
expires_in: int = Field(..., description="OTP expiration time in seconds")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ServiceProfessionalAuthResponse(BaseModel):
|
| 52 |
+
"""Response schema for successful service professional authentication."""
|
| 53 |
+
access_token: str = Field(..., description="JWT access token")
|
| 54 |
+
token_type: str = Field(default="bearer", description="Token type")
|
| 55 |
+
expires_in: int = Field(..., description="Token expiration time in seconds")
|
| 56 |
+
professional_info: dict = Field(..., description="Service professional information")
|
app/auth/services/service_professional_auth_service.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Service Professional authentication service for OTP-based login via WhatsApp.
|
| 3 |
+
Handles authentication for spa staff, salon staff, beauticians, therapists, etc.
|
| 4 |
+
"""
|
| 5 |
+
import secrets
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from typing import Optional, Tuple, Dict, Any
|
| 8 |
+
from motor.motor_asyncio import AsyncIOMotorDatabase
|
| 9 |
+
|
| 10 |
+
from app.core.config import settings
|
| 11 |
+
from app.core.logging import get_logger
|
| 12 |
+
from app.nosql import get_database
|
| 13 |
+
from app.cache import cache_service
|
| 14 |
+
from app.auth.services.wati_service import WatiService
|
| 15 |
+
|
| 16 |
+
logger = get_logger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class ServiceProfessionalAuthService:
|
| 20 |
+
"""Service for service professional OTP authentication via WhatsApp."""
|
| 21 |
+
|
| 22 |
+
def __init__(self):
|
| 23 |
+
self.db: AsyncIOMotorDatabase = get_database()
|
| 24 |
+
self.wati_service = WatiService()
|
| 25 |
+
self.cache = cache_service
|
| 26 |
+
# Collection for service professionals
|
| 27 |
+
self.staff_collection = self.db["scm_service_professionals"]
|
| 28 |
+
|
| 29 |
+
def _normalize_mobile(self, mobile: str) -> str:
|
| 30 |
+
"""
|
| 31 |
+
Normalize mobile number to consistent format.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
mobile: Raw mobile number (with or without country code)
|
| 35 |
+
|
| 36 |
+
Returns:
|
| 37 |
+
Normalized mobile number (with country code)
|
| 38 |
+
"""
|
| 39 |
+
# Remove all spaces and dashes
|
| 40 |
+
clean_mobile = mobile.replace(" ", "").replace("-", "")
|
| 41 |
+
|
| 42 |
+
# If it doesn't start with +, add +91 (India country code)
|
| 43 |
+
if not clean_mobile.startswith("+"):
|
| 44 |
+
if clean_mobile.startswith("91") and len(clean_mobile) == 12:
|
| 45 |
+
# Already has country code but no +
|
| 46 |
+
clean_mobile = "+" + clean_mobile
|
| 47 |
+
elif len(clean_mobile) == 10:
|
| 48 |
+
# Indian mobile number without country code
|
| 49 |
+
clean_mobile = "+91" + clean_mobile
|
| 50 |
+
else:
|
| 51 |
+
# Assume it needs +91 prefix
|
| 52 |
+
clean_mobile = "+91" + clean_mobile
|
| 53 |
+
|
| 54 |
+
return clean_mobile
|
| 55 |
+
|
| 56 |
+
async def get_professional_by_phone(self, phone: str) -> Optional[Dict[str, Any]]:
|
| 57 |
+
"""
|
| 58 |
+
Get service professional by phone number from scm_service_professionals collection.
|
| 59 |
+
|
| 60 |
+
Args:
|
| 61 |
+
phone: Normalized phone number
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
Service professional document or None
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
professional = await self.staff_collection.find_one({"phone": phone})
|
| 68 |
+
return professional
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Error fetching service professional by phone {phone}: {str(e)}", exc_info=True)
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
async def send_otp(self, phone: str) -> Tuple[bool, str, int]:
|
| 74 |
+
"""
|
| 75 |
+
Send OTP to service professional mobile number via WATI WhatsApp API.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
phone: Service professional mobile number
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
Tuple of (success, message, expires_in_seconds)
|
| 82 |
+
"""
|
| 83 |
+
try:
|
| 84 |
+
# Normalize mobile number
|
| 85 |
+
normalized_phone = self._normalize_mobile(phone)
|
| 86 |
+
|
| 87 |
+
# Verify service professional exists with this phone number
|
| 88 |
+
professional = await self.get_professional_by_phone(normalized_phone)
|
| 89 |
+
|
| 90 |
+
if not professional:
|
| 91 |
+
logger.warning(f"Service professional OTP request for non-existent phone: {normalized_phone}")
|
| 92 |
+
return False, "Service professional not found for this phone number", 0
|
| 93 |
+
|
| 94 |
+
# Check professional status
|
| 95 |
+
professional_status = professional.get("status", "").lower()
|
| 96 |
+
if professional_status != "active":
|
| 97 |
+
logger.warning(
|
| 98 |
+
f"Inactive service professional attempted OTP: {professional.get('staff_code')}, "
|
| 99 |
+
f"status: {professional_status}"
|
| 100 |
+
)
|
| 101 |
+
return False, f"Service professional account is {professional_status}", 0
|
| 102 |
+
|
| 103 |
+
# Generate 6-digit OTP
|
| 104 |
+
otp = str(secrets.randbelow(900000) + 100000)
|
| 105 |
+
|
| 106 |
+
# Set expiration (5 minutes)
|
| 107 |
+
expiry_minutes = 5
|
| 108 |
+
expires_at = datetime.utcnow() + timedelta(minutes=expiry_minutes)
|
| 109 |
+
expires_in = expiry_minutes * 60 # Convert to seconds
|
| 110 |
+
|
| 111 |
+
# Store OTP in Redis FIRST (before attempting to send)
|
| 112 |
+
otp_data = {
|
| 113 |
+
"phone": normalized_phone,
|
| 114 |
+
"staff_id": professional.get("staff_id"),
|
| 115 |
+
"staff_code": professional.get("staff_code"),
|
| 116 |
+
"otp": otp,
|
| 117 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 118 |
+
"expires_at": expires_at.isoformat(),
|
| 119 |
+
"attempts": 0,
|
| 120 |
+
"verified": False,
|
| 121 |
+
"wati_message_id": None,
|
| 122 |
+
"delivery_status": "pending"
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
# Store in Redis with TTL
|
| 126 |
+
redis_key = f"service_professional_otp:{normalized_phone}"
|
| 127 |
+
await self.cache.set(redis_key, otp_data, ttl=expires_in)
|
| 128 |
+
|
| 129 |
+
logger.info(
|
| 130 |
+
f"Service professional OTP generated and stored in Redis for {normalized_phone} "
|
| 131 |
+
f"(staff: {professional.get('staff_code')}), expires in {expires_in}s"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
# Attempt to send OTP via WATI WhatsApp API
|
| 135 |
+
wati_success, wati_message, message_id = await self.wati_service.send_otp_message(
|
| 136 |
+
mobile=normalized_phone,
|
| 137 |
+
otp=otp,
|
| 138 |
+
expiry_minutes=expiry_minutes,
|
| 139 |
+
template_name=settings.WATI_STAFF_OTP_TEMPLATE_NAME
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
if wati_success:
|
| 143 |
+
# Update OTP data with WATI message ID and delivery status
|
| 144 |
+
otp_data["wati_message_id"] = message_id
|
| 145 |
+
otp_data["delivery_status"] = "sent"
|
| 146 |
+
await self.cache.set(redis_key, otp_data, ttl=expires_in)
|
| 147 |
+
|
| 148 |
+
logger.info(
|
| 149 |
+
f"Service professional OTP sent successfully via WATI to {normalized_phone} "
|
| 150 |
+
f"for staff {professional.get('staff_code')}. Message ID: {message_id}"
|
| 151 |
+
)
|
| 152 |
+
return True, "OTP sent successfully via WhatsApp", expires_in
|
| 153 |
+
else:
|
| 154 |
+
# WhatsApp sending failed, but OTP is still in Redis for verification
|
| 155 |
+
otp_data["delivery_status"] = "failed"
|
| 156 |
+
otp_data["delivery_error"] = wati_message
|
| 157 |
+
await self.cache.set(redis_key, otp_data, ttl=expires_in)
|
| 158 |
+
|
| 159 |
+
logger.warning(
|
| 160 |
+
f"WhatsApp delivery failed for service professional {normalized_phone} "
|
| 161 |
+
f"(staff: {professional.get('staff_code')}), but OTP is cached in Redis. Error: {wati_message}"
|
| 162 |
+
)
|
| 163 |
+
return True, "OTP generated (WhatsApp delivery pending). Please use the OTP if received.", expires_in
|
| 164 |
+
|
| 165 |
+
except Exception as e:
|
| 166 |
+
logger.error(f"Error sending service professional OTP to {phone}: {str(e)}", exc_info=True)
|
| 167 |
+
return False, "Failed to send OTP", 0
|
| 168 |
+
|
| 169 |
+
async def verify_otp(self, phone: str, otp: str) -> Tuple[Optional[Dict[str, Any]], str]:
|
| 170 |
+
"""
|
| 171 |
+
Verify OTP and authenticate service professional.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
phone: Service professional mobile number
|
| 175 |
+
otp: OTP code to verify
|
| 176 |
+
|
| 177 |
+
Returns:
|
| 178 |
+
Tuple of (professional_data, message)
|
| 179 |
+
"""
|
| 180 |
+
try:
|
| 181 |
+
# Normalize mobile number
|
| 182 |
+
normalized_phone = self._normalize_mobile(phone)
|
| 183 |
+
|
| 184 |
+
# Get OTP from Redis
|
| 185 |
+
redis_key = f"service_professional_otp:{normalized_phone}"
|
| 186 |
+
otp_data = await self.cache.get(redis_key)
|
| 187 |
+
|
| 188 |
+
if not otp_data:
|
| 189 |
+
logger.warning(f"Service professional OTP verification failed - no OTP found in Redis for {normalized_phone}")
|
| 190 |
+
return None, "Invalid OTP or OTP has expired"
|
| 191 |
+
|
| 192 |
+
# Check if OTP is expired
|
| 193 |
+
expires_at = datetime.fromisoformat(otp_data["expires_at"])
|
| 194 |
+
if datetime.utcnow() > expires_at:
|
| 195 |
+
logger.warning(f"Service professional OTP verification failed - expired OTP for {normalized_phone}")
|
| 196 |
+
await self.cache.delete(redis_key)
|
| 197 |
+
return None, "OTP has expired"
|
| 198 |
+
|
| 199 |
+
# Check if already verified
|
| 200 |
+
if otp_data.get("verified", False):
|
| 201 |
+
logger.warning(f"Service professional OTP verification failed - already used OTP for {normalized_phone}")
|
| 202 |
+
return None, "OTP has already been used"
|
| 203 |
+
|
| 204 |
+
# Increment attempts
|
| 205 |
+
attempts = otp_data.get("attempts", 0) + 1
|
| 206 |
+
|
| 207 |
+
# Check max attempts (3 attempts allowed)
|
| 208 |
+
if attempts > 3:
|
| 209 |
+
logger.warning(f"Service professional OTP verification failed - too many attempts for {normalized_phone}")
|
| 210 |
+
await self.cache.delete(redis_key)
|
| 211 |
+
return None, "Too many attempts. Please request a new OTP"
|
| 212 |
+
|
| 213 |
+
# Update attempts in Redis
|
| 214 |
+
otp_data["attempts"] = attempts
|
| 215 |
+
remaining_ttl = int((expires_at - datetime.utcnow()).total_seconds())
|
| 216 |
+
if remaining_ttl > 0:
|
| 217 |
+
await self.cache.set(redis_key, otp_data, ttl=remaining_ttl)
|
| 218 |
+
|
| 219 |
+
# Verify OTP
|
| 220 |
+
if otp_data["otp"] != otp:
|
| 221 |
+
logger.warning(f"Service professional OTP verification failed - incorrect OTP for {normalized_phone}")
|
| 222 |
+
return None, "Invalid OTP"
|
| 223 |
+
|
| 224 |
+
# Delete OTP from Redis (one-time use)
|
| 225 |
+
await self.cache.delete(redis_key)
|
| 226 |
+
|
| 227 |
+
# Get service professional
|
| 228 |
+
professional = await self.get_professional_by_phone(normalized_phone)
|
| 229 |
+
|
| 230 |
+
if not professional:
|
| 231 |
+
logger.error(f"Service professional not found after OTP verification: {normalized_phone}")
|
| 232 |
+
return None, "Service professional not found"
|
| 233 |
+
|
| 234 |
+
# Verify professional is still active
|
| 235 |
+
professional_status = professional.get("status", "").lower()
|
| 236 |
+
if professional_status != "active":
|
| 237 |
+
logger.warning(f"Inactive service professional verified OTP: {professional.get('staff_code')}")
|
| 238 |
+
return None, f"Service professional account is {professional_status}"
|
| 239 |
+
|
| 240 |
+
# Prepare professional data for token generation
|
| 241 |
+
professional_data = {
|
| 242 |
+
"staff_id": professional.get("staff_id"),
|
| 243 |
+
"staff_code": professional.get("staff_code"),
|
| 244 |
+
"name": professional.get("name"),
|
| 245 |
+
"phone": professional.get("phone"),
|
| 246 |
+
"email": professional.get("email"),
|
| 247 |
+
"designation": professional.get("designation"),
|
| 248 |
+
"status": professional.get("status"),
|
| 249 |
+
"user_type": "service_professional"
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
logger.info(
|
| 253 |
+
f"Service professional OTP verified successfully for {normalized_phone}, "
|
| 254 |
+
f"staff: {professional.get('staff_code')}"
|
| 255 |
+
)
|
| 256 |
+
return professional_data, "OTP verified successfully"
|
| 257 |
+
|
| 258 |
+
except Exception as e:
|
| 259 |
+
logger.error(f"Error verifying service professional OTP for {phone}: {str(e)}", exc_info=True)
|
| 260 |
+
return None, "Failed to verify OTP"
|
| 261 |
+
|
| 262 |
+
def create_professional_token(self, professional_data: Dict[str, Any]) -> str:
|
| 263 |
+
"""
|
| 264 |
+
Create JWT token for service professional.
|
| 265 |
+
|
| 266 |
+
Args:
|
| 267 |
+
professional_data: Service professional information
|
| 268 |
+
|
| 269 |
+
Returns:
|
| 270 |
+
JWT access token
|
| 271 |
+
"""
|
| 272 |
+
try:
|
| 273 |
+
from jose import jwt
|
| 274 |
+
from datetime import datetime, timedelta
|
| 275 |
+
|
| 276 |
+
# Token expiration
|
| 277 |
+
expires_delta = timedelta(hours=settings.TOKEN_EXPIRATION_HOURS)
|
| 278 |
+
expire = datetime.utcnow() + expires_delta
|
| 279 |
+
|
| 280 |
+
# Token payload
|
| 281 |
+
token_data = {
|
| 282 |
+
"sub": professional_data["staff_id"],
|
| 283 |
+
"staff_code": professional_data["staff_code"],
|
| 284 |
+
"user_type": "service_professional",
|
| 285 |
+
"exp": expire,
|
| 286 |
+
"iat": datetime.utcnow()
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
# Create JWT token
|
| 290 |
+
encoded_jwt = jwt.encode(
|
| 291 |
+
token_data,
|
| 292 |
+
settings.SECRET_KEY,
|
| 293 |
+
algorithm=settings.ALGORITHM
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
return encoded_jwt
|
| 297 |
+
|
| 298 |
+
except Exception as e:
|
| 299 |
+
logger.error(f"Error creating service professional token: {str(e)}", exc_info=True)
|
| 300 |
+
raise
|
app/main.py
CHANGED
|
@@ -19,6 +19,7 @@ from app.system_users.controllers.router import router as system_user_router
|
|
| 19 |
from app.auth.controllers.router import router as auth_router
|
| 20 |
from app.auth.controllers.staff_router import router as staff_router
|
| 21 |
from app.auth.controllers.customer_router import router as customer_router
|
|
|
|
| 22 |
from app.internal.router import router as internal_router
|
| 23 |
|
| 24 |
# Setup logging
|
|
@@ -349,11 +350,12 @@ async def check_db_status():
|
|
| 349 |
|
| 350 |
|
| 351 |
# Include routers with new organization
|
| 352 |
-
app.include_router(auth_router)
|
| 353 |
-
app.include_router(staff_router)
|
| 354 |
-
app.include_router(customer_router)
|
| 355 |
-
app.include_router(
|
| 356 |
-
app.include_router(
|
|
|
|
| 357 |
|
| 358 |
|
| 359 |
if __name__ == "__main__":
|
|
|
|
| 19 |
from app.auth.controllers.router import router as auth_router
|
| 20 |
from app.auth.controllers.staff_router import router as staff_router
|
| 21 |
from app.auth.controllers.customer_router import router as customer_router
|
| 22 |
+
from app.auth.controllers.service_professional_router import router as service_professional_router
|
| 23 |
from app.internal.router import router as internal_router
|
| 24 |
|
| 25 |
# Setup logging
|
|
|
|
| 350 |
|
| 351 |
|
| 352 |
# Include routers with new organization
|
| 353 |
+
app.include_router(auth_router) # /auth/* - Core authentication endpoints
|
| 354 |
+
app.include_router(staff_router) # /staff/* - Staff authentication (mobile OTP)
|
| 355 |
+
app.include_router(customer_router) # /customer/* - Customer authentication (OTP)
|
| 356 |
+
app.include_router(service_professional_router) # /service-professional/* - Service professional authentication (OTP)
|
| 357 |
+
app.include_router(system_user_router) # /users/* - User management endpoints
|
| 358 |
+
app.include_router(internal_router) # /internal/* - Internal API endpoints
|
| 359 |
|
| 360 |
|
| 361 |
if __name__ == "__main__":
|