MukeshKapoor25 commited on
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) # /auth/* - Core authentication endpoints
353
- app.include_router(staff_router) # /staff/* - Staff authentication (mobile OTP)
354
- app.include_router(customer_router) # /customer/* - Customer authentication (OTP)
355
- app.include_router(system_user_router) # /users/* - User management endpoints
356
- app.include_router(internal_router) # /internal/* - Internal API endpoints
 
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__":