MukeshKapoor25 commited on
Commit
4367adf
·
1 Parent(s): 215b2aa

feat: add core functionality for user management and services

Browse files

- Implement user authentication with JWT tokens
- Add OTP service for email and SMS verification
- Create rate limiting middleware for API protection
- Set up MongoDB and Redis clients for data storage
- Add utility modules for common operations (logging, validation, etc.)
- Implement profile, wallet, favorite, and review services
- Add schemas and models for data validation and storage

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. app/app.py +46 -85
  2. app/core/cache_client.py +79 -0
  3. app/core/config.py +70 -0
  4. app/core/nosql_client.py +10 -0
  5. app/middleware/rate_limiter.py +27 -0
  6. app/models/__init__.py +0 -0
  7. app/models/address_model.py +243 -0
  8. app/models/favorite_model.py +209 -0
  9. app/models/guest_model.py +290 -0
  10. app/models/otp_model.py +230 -0
  11. app/models/pet_model.py +270 -0
  12. app/models/refresh_token_model.py +282 -0
  13. app/models/review_model.py +45 -0
  14. app/models/social_account_model.py +257 -0
  15. app/models/social_security_model.py +188 -0
  16. app/models/user_model.py +159 -0
  17. app/models/wallet_model.py +158 -0
  18. app/routers/__init__.py +11 -2
  19. app/routers/account_router.py +218 -0
  20. app/routers/address_router.py +327 -0
  21. app/routers/favorite_router.py +205 -0
  22. app/routers/guest_router.py +309 -0
  23. app/routers/pet_router.py +305 -0
  24. app/routers/profile_router.py +200 -0
  25. app/routers/review_router.py +48 -0
  26. app/routers/user_router.py +556 -0
  27. app/routers/wallet_router.py +223 -0
  28. app/schemas/__init__.py +0 -0
  29. app/schemas/address_schema.py +74 -0
  30. app/schemas/favorite_schema.py +45 -0
  31. app/schemas/guest_schema.py +231 -0
  32. app/schemas/pet_schema.py +114 -0
  33. app/schemas/profile_schema.py +91 -0
  34. app/schemas/review_schema.py +24 -0
  35. app/schemas/user_schema.py +198 -0
  36. app/schemas/wallet_schema.py +71 -0
  37. app/services/__init__.py +0 -0
  38. app/services/account_service.py +396 -0
  39. app/services/favorite_service.py +158 -0
  40. app/services/otp_service.py +36 -0
  41. app/services/profile_service.py +72 -0
  42. app/services/user_service.py +350 -0
  43. app/services/wallet_service.py +212 -0
  44. app/utils/__init__.py +0 -0
  45. app/utils/common_utils.py +31 -0
  46. app/utils/db.py +26 -0
  47. app/utils/email_utils.py +16 -0
  48. app/utils/jwt.py +137 -0
  49. app/utils/logger.py +32 -0
  50. app/utils/sms_utils.py +15 -0
app/app.py CHANGED
@@ -4,13 +4,26 @@ from fastapi.middleware.cors import CORSMiddleware
4
  from fastapi.responses import JSONResponse
5
  from insightfy_utils.logging import setup_logging, get_logger
6
  from insightfy_utils.config import load_env
7
- from app.routers.user import router as user_router
8
  from app.middleware.security_middleware import create_security_middleware
 
9
  from app.config.validation import validate_environment_variables, validate_database_isolation
10
  import os
11
  import time
12
  import asyncio
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  load_env()
15
  _env = os.getenv("ENVIRONMENT", "development")
16
  _log_level = os.getenv("LOG_LEVEL") or ("WARNING" if _env == "development" else "INFO")
@@ -51,6 +64,9 @@ app.add_middleware(
51
  enable_security_headers=True
52
  )
53
 
 
 
 
54
  allowed_origins = [
55
  "http://localhost:3000",
56
  "http://localhost:3001",
@@ -94,91 +110,36 @@ async def add_service_headers(request: Request, call_next):
94
  response.headers["X-Powered-By"] = "Insightfy"
95
  return response
96
 
97
- app.include_router(
98
- user_router,
99
- prefix="/ums/v1/users",
100
- tags=["Users"],
101
- responses={404: {"description": "Not found"}, 500: {"description": "Internal error"}},
102
- )
103
 
104
- @app.get("/", tags=["Health"])
105
- async def root():
106
- return {
107
- "service": "Insightfy BMS - User Management Service",
108
- "version": "1.0.0",
109
- "status": "running",
110
- "description": "User Management Service API for Insightfy BMS platform",
111
- "docs_url": "/docs",
112
- "health_check": "/ums/health",
113
- }
114
-
115
- @app.get("/ums/health", tags=["Health"])
116
- async def health_check():
117
- return {"status": "healthy", "service": "insightfy-bms-ms-ums", "version": "1.0.0", "timestamp": time.time()}
118
-
119
- @app.get("/ums/health/detailed", tags=["Health"])
120
- async def detailed_health_check():
121
- try:
122
- health_status = await get_comprehensive_health_status()
123
- return health_status
124
- except Exception as e:
125
- logger.error("detailed_health_failed", extra={"error": str(e), "service": "insightfy-bms-ms-ums"}, exc_info=True)
126
- return {"status": "unhealthy", "service": "insightfy-bms-ms-ums", "version": "1.0.0", "error": "Health check failed", "timestamp": time.time()}
127
 
128
- @app.get("/ums/health/metrics", tags=["Health"])
129
- async def health_metrics():
130
- try:
131
- from app.nosql import get_connection_metrics
132
- connection_metrics = await get_connection_metrics()
133
- return {"service": "insightfy-bms-ms-ums", "version": "1.0.0", "connection_metrics": connection_metrics, "status": "healthy", "timestamp": time.time(), "environment": os.getenv("ENVIRONMENT", "development")}
134
- except Exception as e:
135
- logger.error("metrics_failed", extra={"error": str(e), "service": "insightfy-bms-ms-ums"}, exc_info=True)
136
- return {"service": "insightfy-bms-ms-ums", "version": "1.0.0", "error": "Failed to retrieve metrics", "status": "unhealthy", "timestamp": time.time()}
137
 
138
- @app.get("/ums/health/readiness", tags=["Health"])
139
- async def readiness_check():
140
- try:
141
- from app.nosql import ping_mongo, ping_redis
142
- mongo_ready = await ping_mongo()
143
- redis_ready = await ping_redis()
144
- if mongo_ready and redis_ready:
145
- return {"status": "ready", "service": "insightfy-bms-ms-ums", "version": "1.0.0", "timestamp": time.time()}
146
- else:
147
- return JSONResponse(status_code=503, content={"status": "not_ready", "service": "insightfy-bms-ms-ums", "version": "1.0.0", "components": {"mongodb": "ready" if mongo_ready else "not_ready", "redis": "ready" if redis_ready else "not_ready"}, "timestamp": time.time()})
148
- except Exception as e:
149
- logger.error("readiness_failed", extra={"error": str(e), "service": "insightfy-bms-ms-ums"}, exc_info=True)
150
- return JSONResponse(status_code=503, content={"status": "not_ready", "service": "insightfy-bms-ms-ums", "error": "Readiness check failed", "timestamp": time.time()})
151
-
152
- @app.get("/ums/health/liveness", tags=["Health"])
153
- async def liveness_check():
154
- current_time = time.time()
155
- return {"status": "alive", "service": "insightfy-bms-ms-ums", "version": "1.0.0", "timestamp": current_time, "uptime_seconds": round(current_time - startup_time, 2)}
156
-
157
- async def get_comprehensive_health_status():
158
- from app.nosql import get_database_status
159
- from app.config.validation import validate_environment_variables, validate_database_isolation
160
- db_status = await get_database_status()
161
- try:
162
- config_status = validate_environment_variables()
163
- config_valid = True
164
- except Exception as e:
165
- config_status = {"error": str(e)}
166
- config_valid = False
167
- isolation_valid = validate_database_isolation()
168
- overall_healthy = (db_status["overall_status"] == "healthy" and config_valid and isolation_valid)
169
- return {
170
- "status": "healthy" if overall_healthy else "unhealthy",
171
- "service": "insightfy-bms-ms-ums",
172
- "version": "1.0.0",
173
- "components": {
174
- "mongodb": "healthy" if db_status["mongodb"]["connected"] else "unhealthy",
175
- "redis": "healthy" if db_status["redis"]["connected"] else "unhealthy",
176
- "configuration": "healthy" if config_valid else "unhealthy",
177
- "database_isolation": "healthy" if isolation_valid else "unhealthy",
178
- },
179
- "database": {"name": db_status["mongodb"]["database"], "connected": db_status["mongodb"]["connected"], "isolation_valid": isolation_valid},
180
- "configuration": config_status,
181
- "metrics": {"check_duration_ms": db_status["check_duration_ms"], "timestamp": db_status["timestamp"]},
182
- "environment": os.getenv("ENVIRONMENT", "development"),
183
- }
184
 
 
 
 
 
4
  from fastapi.responses import JSONResponse
5
  from insightfy_utils.logging import setup_logging, get_logger
6
  from insightfy_utils.config import load_env
 
7
  from app.middleware.security_middleware import create_security_middleware
8
+ from app.middleware.rate_limiter import RateLimitMiddleware
9
  from app.config.validation import validate_environment_variables, validate_database_isolation
10
  import os
11
  import time
12
  import asyncio
13
 
14
+ # Import migrated routers
15
+ from app.routers import (
16
+ user_router,
17
+ profile_router,
18
+ account_router,
19
+ wallet_router,
20
+ address_router,
21
+ pet_router,
22
+ guest_router,
23
+ favorite_router,
24
+ review_router
25
+ )
26
+
27
  load_env()
28
  _env = os.getenv("ENVIRONMENT", "development")
29
  _log_level = os.getenv("LOG_LEVEL") or ("WARNING" if _env == "development" else "INFO")
 
64
  enable_security_headers=True
65
  )
66
 
67
+ # Add rate limiting middleware
68
+ app.add_middleware(RateLimitMiddleware, calls=100, period=60)
69
+
70
  allowed_origins = [
71
  "http://localhost:3000",
72
  "http://localhost:3001",
 
110
  response.headers["X-Powered-By"] = "Insightfy"
111
  return response
112
 
113
+ # Include Routers
114
+ # Mapping source paths to /ums/v1/... convention
 
 
 
 
115
 
116
+ # Auth / User
117
+ app.include_router(user_router.router, prefix="/ums/v1/auth", tags=["User Auth"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
+ # Profile
120
+ app.include_router(profile_router.router, prefix="/ums/v1/profile", tags=["Profile"])
 
 
 
 
 
 
 
121
 
122
+ # Account
123
+ app.include_router(account_router.router, prefix="/ums/v1/account", tags=["Account Management"])
124
+
125
+ # Wallet
126
+ app.include_router(wallet_router.router, prefix="/ums/v1/wallet", tags=["Wallet Management"])
127
+
128
+ # Address
129
+ app.include_router(address_router.router, prefix="/ums/v1/addresses", tags=["Address Management"])
130
+
131
+ # Others (Pet, Guest, Favorite were under /api/v1/users in source)
132
+ app.include_router(pet_router.router, prefix="/ums/v1/users", tags=["Pet Management"])
133
+ app.include_router(guest_router.router, prefix="/ums/v1/users", tags=["Guest Management"])
134
+ app.include_router(favorite_router.router, prefix="/ums/v1/users", tags=["Favorites"])
135
+
136
+ # Reviews
137
+ app.include_router(review_router.router, prefix="/ums/v1/reviews", tags=["Reviews"])
138
+
139
+ @app.get("/")
140
+ def root():
141
+ return {"message": "Insightfy BMS User Management Service is running"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
142
 
143
+ @app.get("/health")
144
+ def health():
145
+ return {"status": "healthy", "service": "insightfy-bms-ms-ums", "timestamp": time.time()}
app/core/cache_client.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from redis.asyncio import Redis
3
+ from redis.exceptions import RedisError, ConnectionError, AuthenticationError
4
+ from app.core.config import settings
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ # Parse host and port
9
+ CACHE_HOST, CACHE_PORT = settings.CACHE_URI.split(":")
10
+ CACHE_PORT = int(CACHE_PORT)
11
+
12
+ async def create_redis_client():
13
+ """Create Redis client with proper error handling and fallback"""
14
+ try:
15
+ # First try with authentication if password is provided
16
+ if settings.CACHE_K and settings.CACHE_K.strip():
17
+ redis_client = Redis(
18
+ host=CACHE_HOST,
19
+ port=CACHE_PORT,
20
+ username="default",
21
+ password=settings.CACHE_K,
22
+ decode_responses=True,
23
+ socket_connect_timeout=5,
24
+ socket_timeout=5,
25
+ retry_on_timeout=True
26
+ )
27
+ # Test the connection
28
+ await redis_client.ping()
29
+ logger.info(f"Connected to Redis at {CACHE_HOST}:{CACHE_PORT} with authentication")
30
+ return redis_client
31
+ else:
32
+ # Try without authentication for local Redis
33
+ redis_client = Redis(
34
+ host=CACHE_HOST,
35
+ port=CACHE_PORT,
36
+ decode_responses=True,
37
+ socket_connect_timeout=5,
38
+ socket_timeout=5,
39
+ retry_on_timeout=True
40
+ )
41
+ # Test the connection
42
+ await redis_client.ping()
43
+ logger.info(f"Connected to Redis at {CACHE_HOST}:{CACHE_PORT} without authentication")
44
+ return redis_client
45
+
46
+ except AuthenticationError as e:
47
+ logger.warning(f"Authentication failed for Redis: {e}")
48
+ # Try without authentication as fallback
49
+ try:
50
+ redis_client = Redis(
51
+ host=CACHE_HOST,
52
+ port=CACHE_PORT,
53
+ decode_responses=True,
54
+ socket_connect_timeout=5,
55
+ socket_timeout=5,
56
+ retry_on_timeout=True
57
+ )
58
+ await redis_client.ping()
59
+ logger.info(f"Connected to Redis at {CACHE_HOST}:{CACHE_PORT} without authentication (fallback)")
60
+ return redis_client
61
+ except Exception as fallback_error:
62
+ logger.error(f"Redis fallback connection also failed: {fallback_error}")
63
+ raise
64
+
65
+ except ConnectionError as e:
66
+ logger.error(f"Failed to connect to Redis at {CACHE_HOST}:{CACHE_PORT}: {e}")
67
+ raise
68
+ except Exception as e:
69
+ logger.error(f"Unexpected error connecting to Redis: {e}")
70
+ raise
71
+
72
+ # Initialize Redis client
73
+ redis_client = None
74
+
75
+ async def get_redis() -> Redis:
76
+ global redis_client
77
+ if redis_client is None:
78
+ redis_client = await create_redis_client()
79
+ return redis_client
app/core/config.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dotenv import load_dotenv
2
+ import os
3
+
4
+ load_dotenv()
5
+
6
+ class Settings:
7
+ # MongoDB
8
+ MONGO_URI: str = os.getenv("MONGO_URI")
9
+ DB_NAME: str = os.getenv("DB_NAME")
10
+
11
+ # Redis
12
+ CACHE_URI: str = os.getenv("CACHE_URI")
13
+ CACHE_K: str = os.getenv("CACHE_K")
14
+
15
+ # JWT (Unified across services)
16
+ # Prefer JWT_* envs; fall back to legacy names to ensure compatibility
17
+ JWT_SECRET_KEY: str = os.getenv("JWT_SECRET_KEY") or os.getenv("SECRET_KEY", "B00Kmyservice@7")
18
+ JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM") or os.getenv("ALGORITHM", "HS256")
19
+ JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = int(
20
+ os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "43200"))
21
+ )
22
+ JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = int(
23
+ os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7"))
24
+ )
25
+ JWT_TEMP_TOKEN_EXPIRE_MINUTES: int = int(
26
+ os.getenv("JWT_TEMP_TOKEN_EXPIRE_MINUTES", os.getenv("TEMP_TOKEN_EXPIRE_MINUTES", "10"))
27
+ )
28
+ JWT_REMEMBER_ME_EXPIRE_DAYS: int = int(
29
+ os.getenv("JWT_REMEMBER_ME_EXPIRE_DAYS", "30") # 30 days for remember me
30
+ )
31
+
32
+ # Backward compatibility: keep legacy attributes pointing to unified values
33
+ SECRET_KEY: str = JWT_SECRET_KEY
34
+ ALGORITHM: str = JWT_ALGORITHM
35
+
36
+ # Twilio SMS
37
+ TWILIO_ACCOUNT_SID: str = os.getenv("TWILIO_ACCOUNT_SID")
38
+ TWILIO_AUTH_TOKEN: str = os.getenv("TWILIO_AUTH_TOKEN")
39
+ TWILIO_SMS_FROM: str = os.getenv("TWILIO_SMS_FROM")
40
+
41
+ # SMTP Email
42
+ SMTP_HOST: str = os.getenv("SMTP_HOST")
43
+ SMTP_PORT: int = int(os.getenv("SMTP_PORT", "587"))
44
+ SMTP_USER: str = os.getenv("SMTP_USER")
45
+ SMTP_PASS: str = os.getenv("SMTP_PASS")
46
+ SMTP_FROM: str = os.getenv("SMTP_FROM")
47
+
48
+ # OAuth Providers
49
+ GOOGLE_CLIENT_ID: str = os.getenv("GOOGLE_CLIENT_ID")
50
+ APPLE_AUDIENCE: str = os.getenv("APPLE_AUDIENCE")
51
+ FACEBOOK_APP_ID: str = os.getenv("FACEBOOK_APP_ID")
52
+ FACEBOOK_APP_SECRET: str = os.getenv("FACEBOOK_APP_SECRET")
53
+
54
+ # Local testing: bypass external OAuth verification when enabled
55
+ OAUTH_TEST_MODE: bool = os.getenv("OAUTH_TEST_MODE", "false").lower() == "true"
56
+
57
+ # Security Settings
58
+ MAX_LOGIN_ATTEMPTS: int = int(os.getenv("MAX_LOGIN_ATTEMPTS", "5"))
59
+ ACCOUNT_LOCK_DURATION: int = int(os.getenv("ACCOUNT_LOCK_DURATION", "900")) # 15 minutes
60
+ OTP_VALIDITY_MINUTES: int = int(os.getenv("OTP_VALIDITY_MINUTES", "5"))
61
+ IP_RATE_LIMIT_MAX: int = int(os.getenv("IP_RATE_LIMIT_MAX", "10"))
62
+ IP_RATE_LIMIT_WINDOW: int = int(os.getenv("IP_RATE_LIMIT_WINDOW", "3600")) # 1 hour
63
+
64
+ def __post_init__(self):
65
+ if not self.MONGO_URI or not self.DB_NAME:
66
+ raise ValueError("MongoDB URI or DB_NAME not configured.")
67
+ if not self.CACHE_URI or not self.CACHE_K:
68
+ raise ValueError("Redis URI or password (CACHE_K) not configured.")
69
+
70
+ settings = Settings()
app/core/nosql_client.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from app.nosql import mongo_db, mongo_client
3
+
4
+ logger = logging.getLogger(__name__)
5
+
6
+ # Alias for backward compatibility with migrated code
7
+ db = mongo_db
8
+
9
+ async def get_mongo_client():
10
+ return mongo_client
app/middleware/rate_limiter.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import Request, HTTPException
2
+ from starlette.middleware.base import BaseHTTPMiddleware
3
+ import time
4
+ from collections import defaultdict, deque
5
+
6
+ class RateLimitMiddleware(BaseHTTPMiddleware):
7
+ def __init__(self, app, calls: int = 100, period: int = 60):
8
+ super().__init__(app)
9
+ self.calls = calls
10
+ self.period = period
11
+ self.clients = defaultdict(deque)
12
+
13
+ async def dispatch(self, request: Request, call_next):
14
+ client_ip = request.client.host
15
+ now = time.time()
16
+
17
+ # Clean old requests
18
+ while self.clients[client_ip] and self.clients[client_ip][0] <= now - self.period:
19
+ self.clients[client_ip].popleft()
20
+
21
+ # Check rate limit
22
+ if len(self.clients[client_ip]) >= self.calls:
23
+ raise HTTPException(status_code=429, detail="Rate limit exceeded")
24
+
25
+ self.clients[client_ip].append(now)
26
+ response = await call_next(request)
27
+ return response
app/models/__init__.py ADDED
File without changes
app/models/address_model.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, List, Dict, Any
3
+ from bson import ObjectId
4
+ import logging
5
+ import uuid
6
+
7
+ from app.core.nosql_client import db
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class AddressModel:
12
+ """Model for managing user delivery addresses embedded under customer documents"""
13
+
14
+ @staticmethod
15
+ async def create_address(customer_id: str, address_data: Dict[str, Any]) -> Optional[str]:
16
+ """Create a new embedded address for a user inside customers collection"""
17
+ try:
18
+ from app.models.user_model import BookMyServiceUserModel
19
+
20
+ address_id = str(uuid.uuid4())
21
+ current_time = datetime.utcnow()
22
+
23
+ address_doc = {
24
+ "address_id": address_id, # New field for address identification
25
+ "address_line_1": address_data.get("address_line_1"),
26
+ "address_line_2": address_data.get("address_line_2", ""),
27
+ "city": address_data.get("city"),
28
+ "state": address_data.get("state"),
29
+ "postal_code": address_data.get("postal_code"),
30
+ "country": address_data.get("country", "India"),
31
+ "address_type": address_data.get("address_type", "home"), # home, work, other
32
+ "is_default": address_data.get("is_default", False),
33
+ "landmark": address_data.get("landmark", ""),
34
+ "created_at": current_time,
35
+ "updated_at": current_time,
36
+ }
37
+
38
+ # Fetch user doc
39
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
40
+ if not user:
41
+ logger.error(f"User not found for customer_id {customer_id}")
42
+ return None
43
+
44
+ addresses = user.get("addresses", [])
45
+
46
+ # If setting default, unset any existing defaults
47
+ if address_doc.get("is_default"):
48
+ for a in addresses:
49
+ if a.get("is_default"):
50
+ a["is_default"] = False
51
+ a["updated_at"] = datetime.utcnow()
52
+ else:
53
+ # If this is the first address, set default
54
+ if len(addresses) == 0:
55
+ address_doc["is_default"] = True
56
+
57
+ addresses.append(address_doc)
58
+
59
+ update_result = await BookMyServiceUserModel.collection.update_one(
60
+ {"customer_id": customer_id},
61
+ {"$set": {"addresses": addresses}}
62
+ )
63
+
64
+ if update_result.modified_count == 0:
65
+ logger.error(f"Failed to insert embedded address for user {customer_id}")
66
+ return None
67
+
68
+ logger.info(f"Created embedded address for user {customer_id}")
69
+ return address_doc["address_id"] # Return the address_id field instead of _id
70
+
71
+ except Exception as e:
72
+ logger.error(f"Error creating embedded address for user {customer_id}: {str(e)}")
73
+ return None
74
+
75
+ @staticmethod
76
+ async def get_user_addresses(customer_id: str) -> List[Dict[str, Any]]:
77
+ """Get all embedded addresses for a user from customers collection"""
78
+ try:
79
+ from app.models.user_model import BookMyServiceUserModel
80
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
81
+ if not user:
82
+ return []
83
+
84
+ addresses = user.get("addresses", [])
85
+ # Sort by created_at desc and return as-is (no _id field)
86
+ addresses.sort(key=lambda x: x.get("created_at", datetime.utcnow()), reverse=True)
87
+ return addresses
88
+
89
+ except Exception as e:
90
+ logger.error(f"Error getting embedded addresses for user {customer_id}: {str(e)}")
91
+ return []
92
+
93
+ @staticmethod
94
+ async def get_address_by_id(customer_id: str, address_id: str) -> Optional[Dict[str, Any]]:
95
+ """Get a specific embedded address by ID for a user"""
96
+ try:
97
+ from app.models.user_model import BookMyServiceUserModel
98
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
99
+ if not user:
100
+ return None
101
+
102
+ addresses = user.get("addresses", [])
103
+
104
+ for a in addresses:
105
+ if a.get("address_id") == address_id:
106
+ a_copy = dict(a)
107
+ # Inject customer_id for backward-compat where used in router
108
+ a_copy["customer_id"] = customer_id
109
+ return a_copy
110
+ return None
111
+
112
+ except Exception as e:
113
+ logger.error(f"Error getting embedded address {address_id} for user {customer_id}: {str(e)}")
114
+ return None
115
+
116
+ @staticmethod
117
+ async def update_address(customer_id: str, address_id: str, update_data: Dict[str, Any]) -> bool:
118
+ """Update an existing embedded address"""
119
+ try:
120
+ from app.models.user_model import BookMyServiceUserModel
121
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
122
+ if not user:
123
+ return False
124
+
125
+ addresses = user.get("addresses", [])
126
+
127
+ updated = False
128
+ for a in addresses:
129
+ if a.get("address_id") == address_id:
130
+ allowed_fields = [
131
+ "address_line_1", "address_line_2", "city", "state", "postal_code",
132
+ "country", "address_type", "is_default", "landmark"
133
+ ]
134
+ for field in allowed_fields:
135
+ if field in update_data:
136
+ a[field] = update_data[field]
137
+ a["updated_at"] = datetime.utcnow()
138
+ updated = True
139
+ break
140
+
141
+ if not updated:
142
+ return False
143
+
144
+ # If setting as default, unset other defaults
145
+ if update_data.get("is_default"):
146
+ for a in addresses:
147
+ if a.get("address_id") != address_id and a.get("is_default"):
148
+ a["is_default"] = False
149
+ a["updated_at"] = datetime.utcnow()
150
+
151
+ result = await BookMyServiceUserModel.collection.update_one(
152
+ {"customer_id": customer_id},
153
+ {"$set": {"addresses": addresses}}
154
+ )
155
+ return result.modified_count > 0
156
+
157
+ except Exception as e:
158
+ logger.error(f"Error updating embedded address {address_id} for user {customer_id}: {str(e)}")
159
+ return False
160
+
161
+ @staticmethod
162
+ async def delete_address(customer_id: str, address_id: str) -> bool:
163
+ """Delete an embedded address"""
164
+ try:
165
+ from app.models.user_model import BookMyServiceUserModel
166
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
167
+ if not user:
168
+ return False
169
+
170
+ addresses = user.get("addresses", [])
171
+ # Filter out by new domain id field 'address_id'
172
+ new_addresses = [a for a in addresses if a.get("address_id") != address_id]
173
+
174
+ result = await BookMyServiceUserModel.collection.update_one(
175
+ {"customer_id": customer_id},
176
+ {"$set": {"addresses": new_addresses}}
177
+ )
178
+
179
+ logger.info(f"Deleted embedded address {address_id} for user {customer_id}")
180
+ return result.modified_count > 0
181
+
182
+ except Exception as e:
183
+ logger.error(f"Error deleting embedded address {address_id} for user {customer_id}: {str(e)}")
184
+ return False
185
+
186
+ @staticmethod
187
+ async def get_default_address(customer_id: str) -> Optional[Dict[str, Any]]:
188
+ """Get the default embedded address for a user"""
189
+ try:
190
+ from app.models.user_model import BookMyServiceUserModel
191
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
192
+ if not user:
193
+ return None
194
+
195
+ addresses = user.get("addresses", [])
196
+ for a in addresses:
197
+ if a.get("is_default"):
198
+ a_copy = dict(a)
199
+ a_copy["customer_id"] = customer_id
200
+ return a_copy
201
+ return None
202
+
203
+ except Exception as e:
204
+ logger.error(f"Error getting default embedded address for user {customer_id}: {str(e)}")
205
+ return None
206
+
207
+ @staticmethod
208
+ async def set_default_address(customer_id: str, address_id: str) -> bool:
209
+ """Set an embedded address as default and unset others"""
210
+ try:
211
+ from app.models.user_model import BookMyServiceUserModel
212
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
213
+ if not user:
214
+ return False
215
+
216
+ addresses = user.get("addresses", [])
217
+
218
+ changed = False
219
+ for a in addresses:
220
+ if a.get("address_id") == address_id:
221
+ if not a.get("is_default"):
222
+ a["is_default"] = True
223
+ a["updated_at"] = datetime.utcnow()
224
+ changed = True
225
+ else:
226
+ if a.get("is_default"):
227
+ a["is_default"] = False
228
+ a["updated_at"] = datetime.utcnow()
229
+ changed = True
230
+
231
+ if not changed:
232
+ # Even if nothing changed, ensure persistence
233
+ pass
234
+
235
+ result = await BookMyServiceUserModel.collection.update_one(
236
+ {"customer_id": customer_id},
237
+ {"$set": {"addresses": addresses}}
238
+ )
239
+ return result.modified_count >= 0
240
+
241
+ except Exception as e:
242
+ logger.error(f"Error setting default embedded address {address_id} for user {customer_id}: {str(e)}")
243
+ return False
app/models/favorite_model.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException
2
+ from app.core.nosql_client import db
3
+ import logging
4
+ from datetime import datetime
5
+ from typing import List, Optional, Dict, Any
6
+ from app.utils.db import prepare_for_db
7
+ import uuid
8
+ from app.models.user_model import BookMyServiceUserModel
9
+
10
+ logger = logging.getLogger("favorite_model")
11
+
12
+ class BookMyServiceFavoriteModel:
13
+
14
+ @staticmethod
15
+ async def create_favorite(
16
+ customer_id: str,
17
+ favorite_data:dict
18
+ ):
19
+ """Create a new favorite merchant entry"""
20
+ logger.info(f"Creating favorite for customer {customer_id}, merchant {favorite_data['merchant_id']}")
21
+
22
+ try:
23
+ # Check if favorite already exists
24
+ user = await BookMyServiceUserModel.collection.find_one({
25
+ "customer_id": customer_id
26
+ })
27
+
28
+ if not user:
29
+ logger.error(f"User not found for customer_id {customer_id}")
30
+ return None
31
+
32
+ favorites = user.get("favorites", [])
33
+
34
+ # Create favorite document
35
+ favorite_doc = {
36
+ "merchant_id":favorite_data["merchant_id"],
37
+ "merchant_category": favorite_data["merchant_category"],
38
+ "merchant_name":favorite_data["merchant_name"],
39
+ "source": favorite_data[ "source"],
40
+ "added_at": datetime.utcnow(),
41
+ "notes": favorite_data.get("notes")
42
+ }
43
+ favorites.append(favorite_doc)
44
+ sanitized_favorite = prepare_for_db(favorites)
45
+
46
+ update_res = await BookMyServiceUserModel.collection.update_one(
47
+ {"customer_id": customer_id},
48
+ {"$set": {"favorites": sanitized_favorite}}
49
+ )
50
+ if update_res.modified_count > 0:
51
+ logger.info(f"favourites created successfully: {favorite_data['merchant_id']} for user: {customer_id}")
52
+ else:
53
+ logger.info(f"favourites creation attempted with no modified_count for user: {customer_id}")
54
+
55
+ return favorite_data["merchant_id"]
56
+
57
+ except HTTPException:
58
+ raise
59
+ except Exception as e:
60
+ logger.error(f"Error creating favorite: {str(e)}", exc_info=True)
61
+ raise HTTPException(status_code=500, detail="Failed to create favorite")
62
+
63
+ @staticmethod
64
+ async def delete_favorite(customer_id: str, merchant_id: str):
65
+ """Remove a merchant from favorites"""
66
+ logger.info(f"Deleting favorite for customer {customer_id}, merchant {merchant_id}")
67
+
68
+ try:
69
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
70
+ if not user:
71
+ return False
72
+
73
+ favorites = user.get("favorites", [])
74
+ filterd_favorites = [favorite for favorite in favorites if favorite.get("merchant_id") != merchant_id]
75
+ if len(filterd_favorites) == len(favorites):
76
+ logger.warning(f"Embedded favorite not found for deletion: {merchant_id} (user {customer_id})")
77
+ return False
78
+
79
+ # Sanitize for MongoDB before write
80
+ sanitized_filterd_favorites = prepare_for_db(filterd_favorites)
81
+ result = await BookMyServiceUserModel.collection.update_one(
82
+ {"customer_id": customer_id},
83
+ {"$set": {"favorites": sanitized_filterd_favorites}}
84
+ )
85
+
86
+ if result.modified_count > 0:
87
+ logger.info(f"Embedded favorite deleted successfully: {merchant_id} (user {customer_id})")
88
+ return True
89
+ else:
90
+ logger.info(f"Embedded favorite deletion applied with no modified_count: {merchant_id} (user {customer_id})")
91
+ return True
92
+
93
+ except HTTPException:
94
+ raise
95
+ except Exception as e:
96
+ logger.error(f"Error deleting favorite: {str(e)}", exc_info=True)
97
+ raise HTTPException(status_code=500, detail="Failed to delete favorite")
98
+
99
+ @staticmethod
100
+ async def get_favorites( customer_id: str, limit:int=50 )-> List[Dict[str, Any]]:
101
+ """Get user's favorite merchants, optionally filtered by category"""
102
+ logger.info(f"Getting favorite merchant for customer {customer_id}")
103
+
104
+ try:
105
+ # Build query
106
+
107
+ # Check if favorite already exists
108
+ user = await BookMyServiceUserModel.collection.find_one({
109
+ "customer_id": customer_id
110
+ })
111
+
112
+ if not user:
113
+ return []
114
+
115
+
116
+ favorites = user.get("favorites", [])
117
+ favorites.sort(key=lambda x: x["added_at"], reverse=True)
118
+ favorite_data = favorites[:limit]
119
+
120
+ # Get total count for pagination
121
+
122
+ logger.info(f"Found {len(favorites)} favorites for customer {customer_id}")
123
+
124
+ return {
125
+ "favorites": favorite_data,
126
+ "total_count": len(favorites),
127
+ "limit": limit
128
+ }
129
+
130
+ except Exception as e:
131
+ logger.error(f"Error getting favorites: {str(e)}", exc_info=True)
132
+ raise HTTPException(status_code=500, detail="Failed to get favorites")
133
+
134
+ @staticmethod
135
+ async def get_favorite(customer_id: str, merchant_id: str):
136
+ """Get a specific favorite entry"""
137
+ logger.info(f"Getting favorite for customer {customer_id}, merchant {merchant_id}")
138
+
139
+ try:
140
+
141
+ user = await BookMyServiceUserModel.collection.find_one({
142
+ "customer_id": customer_id
143
+ })
144
+
145
+ if not user:
146
+ return []
147
+
148
+ favorites = user.get("favorites", [])
149
+
150
+
151
+ for favorite in favorites:
152
+ if favorite.get("merchant_id") == merchant_id:
153
+ return favorite
154
+ logger.warning(f"Embedded favorite merchant not found: {merchant_id} for user: {customer_id}")
155
+ return None
156
+
157
+ except HTTPException:
158
+ raise
159
+ except Exception as e:
160
+ logger.error(f"Error getting favorite: {str(e)}", exc_info=True)
161
+ raise HTTPException(status_code=500, detail="Failed to get favorite")
162
+
163
+ @staticmethod
164
+ async def update_favorite_notes(customer_id: str, merchant_id: str, notes: str):
165
+ """Update notes for a favorite merchant"""
166
+ logger.info(f"Updating notes for customer {customer_id}, merchant {merchant_id}")
167
+
168
+ try:
169
+ user = await BookMyServiceUserModel.collection.find_one({
170
+ "customer_id": customer_id
171
+ })
172
+
173
+ if not user:
174
+ return False
175
+
176
+
177
+ result = await BookMyServiceUserModel.collection.update_one({"customer_id": customer_id, "favorites.merchant_id": merchant_id},
178
+ {"$set": {"favorites.$.notes": notes}})
179
+
180
+ print("result.matched_count",result.matched_count)
181
+ if result.matched_count == 0 :
182
+ logger.warning(f"Favorite not found for customer {customer_id}, merchant {merchant_id}")
183
+ raise HTTPException(status_code=404, detail="Favorite not found")
184
+
185
+ logger.info(f"Favorite merchant notes updated successfully")
186
+ return True
187
+
188
+ except HTTPException:
189
+ raise
190
+ except Exception as e:
191
+ logger.error(f"Error updating favorite merchant notes: {str(e)}", exc_info=True)
192
+ raise HTTPException(status_code=500, detail="Failed to update favorite notes")
193
+
194
+ @staticmethod
195
+ async def is_favorite(customer_id: str, merchant_id: str) -> bool:
196
+ """Check if a merchant is in user's favorites"""
197
+ logger.info(f"Checking if merchant {merchant_id} is favorite for customer {customer_id}")
198
+
199
+ try:
200
+ favorite = await BookMyServiceFavoriteModel.collection.find_one({
201
+ "customer_id": customer_id,
202
+ "merchant_id": merchant_id
203
+ })
204
+
205
+ return favorite is not None
206
+
207
+ except Exception as e:
208
+ logger.error(f"Error checking favorite status: {str(e)}", exc_info=True)
209
+ return False
app/models/guest_model.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.nosql_client import db
2
+ from datetime import datetime
3
+ from typing import List, Optional, Dict, Any
4
+ import uuid
5
+ import logging
6
+
7
+ from app.utils.db import prepare_for_db
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class GuestModel:
12
+ """Model for managing guest profiles embedded under customer documents"""
13
+
14
+ @staticmethod
15
+ async def create_guest(customer_id: str, guest_data: dict) -> Optional[str]:
16
+ """Create a new embedded guest profile under a user in customers collection"""
17
+ try:
18
+ from app.models.user_model import BookMyServiceUserModel
19
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
20
+ if not user:
21
+ logger.error(f"User not found for customer_id {customer_id}")
22
+ return None
23
+
24
+ guest_id = str(uuid.uuid4())
25
+ current_time = datetime.utcnow()
26
+
27
+ guest_doc = {
28
+ "guest_id": guest_id,
29
+ "first_name": guest_data.get("first_name"),
30
+ "last_name": guest_data.get("last_name"),
31
+ "email": guest_data.get("email"),
32
+ "phone_number": guest_data.get("phone_number"),
33
+ "gender": getattr(guest_data.get("gender"), "value", guest_data.get("gender")),
34
+ "date_of_birth": guest_data.get("date_of_birth"),
35
+ "relationship": getattr(guest_data.get("relationship"), "value", guest_data.get("relationship")),
36
+ "notes": guest_data.get("notes"),
37
+ "is_default": guest_data.get("is_default", False),
38
+ "created_at": current_time,
39
+ "updated_at": current_time,
40
+ }
41
+
42
+ guests = user.get("guests", [])
43
+ # Handle default semantics: if setting this guest as default, unset others.
44
+ if guest_doc.get("is_default"):
45
+ for existing in guests:
46
+ if existing.get("is_default"):
47
+ existing["is_default"] = False
48
+ existing["updated_at"] = current_time
49
+ else:
50
+ # If this is the first guest, make it default by default
51
+ if len(guests) == 0:
52
+ guest_doc["is_default"] = True
53
+ guests.append(guest_doc)
54
+
55
+ # Sanitize for MongoDB (convert date to datetime, strip tzinfo, etc.)
56
+ sanitized_guests = prepare_for_db(guests)
57
+ update_res = await BookMyServiceUserModel.collection.update_one(
58
+ {"customer_id": customer_id},
59
+ {"$set": {"guests": sanitized_guests}}
60
+ )
61
+
62
+ if update_res.modified_count > 0:
63
+ logger.info(f"Guest created successfully: {guest_id} for user: {customer_id}")
64
+ return guest_id
65
+ else:
66
+ logger.info(f"Guest creation attempted with no modified_count for user: {customer_id}")
67
+ return guest_id
68
+
69
+ except Exception as e:
70
+ logger.error(f"Error creating embedded guest for user {customer_id}: {str(e)}")
71
+ return None
72
+
73
+ @staticmethod
74
+ async def get_user_guests(customer_id: str) -> List[Dict[str, Any]]:
75
+ """Get all embedded guests for a specific user from customers collection"""
76
+ try:
77
+ from app.models.user_model import BookMyServiceUserModel
78
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
79
+ if not user:
80
+ return []
81
+
82
+ guests = user.get("guests", [])
83
+ guests.sort(key=lambda x: x.get("created_at", datetime.utcnow()), reverse=True)
84
+ for g in guests:
85
+ g["customer_id"] = customer_id
86
+ logger.info(f"Retrieved {len(guests)} embedded guests for user: {customer_id}")
87
+ return guests
88
+
89
+ except Exception as e:
90
+ logger.error(f"Error getting embedded guests for user {customer_id}: {str(e)}")
91
+ return []
92
+
93
+ @staticmethod
94
+ async def get_guest_by_id(customer_id: str, guest_id: str) -> Optional[Dict[str, Any]]:
95
+ """Get a specific embedded guest by ID for a user"""
96
+ try:
97
+ from app.models.user_model import BookMyServiceUserModel
98
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
99
+ if not user:
100
+ return None
101
+
102
+ guests = user.get("guests", [])
103
+ for g in guests:
104
+ if g.get("guest_id") == guest_id:
105
+ g_copy = dict(g)
106
+ g_copy["customer_id"] = customer_id
107
+ logger.info(f"Embedded guest found: {guest_id} for user: {customer_id}")
108
+ return g_copy
109
+ logger.warning(f"Embedded guest not found: {guest_id} for user: {customer_id}")
110
+ return None
111
+
112
+ except Exception as e:
113
+ logger.error(f"Error getting embedded guest {guest_id} for user {customer_id}: {str(e)}")
114
+ return None
115
+
116
+ @staticmethod
117
+ async def update_guest(customer_id: str, guest_id: str, update_fields: Dict[str, Any]) -> bool:
118
+ """Update an embedded guest's information under a user"""
119
+ try:
120
+ from app.models.user_model import BookMyServiceUserModel
121
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
122
+ if not user:
123
+ return False
124
+
125
+ guests = user.get("guests", [])
126
+ updated = False
127
+
128
+ for idx, g in enumerate(guests):
129
+ if g.get("guest_id") == guest_id:
130
+ normalized_updates: Dict[str, Any] = {}
131
+ for k, v in update_fields.items():
132
+ if hasattr(v, "value"):
133
+ normalized_updates[k] = v.value
134
+ else:
135
+ normalized_updates[k] = v
136
+
137
+ normalized_updates["updated_at"] = datetime.utcnow()
138
+ guests[idx] = {**g, **normalized_updates}
139
+ updated = True
140
+ # If is_default is being set to True, unset default for others
141
+ if update_fields.get("is_default"):
142
+ for j, other in enumerate(guests):
143
+ if other.get("guest_id") != guest_id and other.get("is_default"):
144
+ other["is_default"] = False
145
+ other["updated_at"] = datetime.utcnow()
146
+ guests[j] = other
147
+ break
148
+
149
+ if not updated:
150
+ logger.warning(f"Embedded guest not found for update: {guest_id} (user {customer_id})")
151
+ return False
152
+
153
+ # Sanitize for MongoDB before write
154
+ sanitized_guests = prepare_for_db(guests)
155
+ result = await BookMyServiceUserModel.collection.update_one(
156
+ {"customer_id": customer_id},
157
+ {"$set": {"guests": sanitized_guests}}
158
+ )
159
+
160
+ if result.modified_count > 0:
161
+ logger.info(f"Embedded guest updated successfully: {guest_id} (user {customer_id})")
162
+ return True
163
+ else:
164
+ logger.info(f"Embedded guest update applied with no modified_count: {guest_id} (user {customer_id})")
165
+ return True
166
+
167
+ except Exception as e:
168
+ logger.error(f"Error updating embedded guest {guest_id} for user {customer_id}: {str(e)}")
169
+ return False
170
+
171
+ @staticmethod
172
+ async def delete_guest(customer_id: str, guest_id: str) -> bool:
173
+ """Delete an embedded guest profile under a user"""
174
+ try:
175
+ from app.models.user_model import BookMyServiceUserModel
176
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
177
+ if not user:
178
+ return False
179
+
180
+ guests = user.get("guests", [])
181
+ new_guests = [g for g in guests if g.get("guest_id") != guest_id]
182
+ if len(new_guests) == len(guests):
183
+ logger.warning(f"Embedded guest not found for deletion: {guest_id} (user {customer_id})")
184
+ return False
185
+
186
+ # Sanitize for MongoDB before write
187
+ sanitized_new_guests = prepare_for_db(new_guests)
188
+ result = await BookMyServiceUserModel.collection.update_one(
189
+ {"customer_id": customer_id},
190
+ {"$set": {"guests": sanitized_new_guests}}
191
+ )
192
+
193
+ if result.modified_count > 0:
194
+ logger.info(f"Embedded guest deleted successfully: {guest_id} (user {customer_id})")
195
+ return True
196
+ else:
197
+ logger.info(f"Embedded guest deletion applied with no modified_count: {guest_id} (user {customer_id})")
198
+ return True
199
+
200
+ except Exception as e:
201
+ logger.error(f"Error deleting embedded guest {guest_id} for user {customer_id}: {str(e)}")
202
+ return False
203
+
204
+ @staticmethod
205
+ async def get_guest_count_for_user(customer_id: str) -> int:
206
+ """Get the total number of embedded guests for a user"""
207
+ try:
208
+ from app.models.user_model import BookMyServiceUserModel
209
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
210
+ if not user:
211
+ return 0
212
+ return len(user.get("guests", []))
213
+ except Exception as e:
214
+ logger.error(f"Error counting embedded guests for user {customer_id}: {str(e)}")
215
+ return 0
216
+
217
+ @staticmethod
218
+ async def check_guest_ownership(guest_id: str, customer_id: str) -> bool:
219
+ """
220
+ Check if a guest belongs to a specific user.
221
+
222
+ Args:
223
+ guest_id: ID of the guest
224
+ customer_id: ID of the user
225
+
226
+ Returns:
227
+ True if guest belongs to user, False otherwise
228
+ """
229
+ try:
230
+ from app.models.user_model import BookMyServiceUserModel
231
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
232
+ if not user:
233
+ return False
234
+ guests = user.get("guests", [])
235
+ return any(g.get("guest_id") == guest_id for g in guests)
236
+ except Exception as e:
237
+ logger.error(f"Error checking embedded guest ownership {guest_id} for user {customer_id}: {str(e)}")
238
+ return False
239
+
240
+ @staticmethod
241
+ async def get_default_guest(customer_id: str) -> Optional[Dict[str, Any]]:
242
+ """Get the default guest for a user"""
243
+ try:
244
+ from app.models.user_model import BookMyServiceUserModel
245
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
246
+ if not user:
247
+ return None
248
+ guests = user.get("guests", [])
249
+ for g in guests:
250
+ if g.get("is_default"):
251
+ g_copy = dict(g)
252
+ g_copy["customer_id"] = customer_id
253
+ return g_copy
254
+ return None
255
+ except Exception as e:
256
+ logger.error(f"Error getting default guest for user {customer_id}: {str(e)}")
257
+ return None
258
+
259
+ @staticmethod
260
+ async def set_default_guest(customer_id: str, guest_id: str) -> bool:
261
+ """Set a guest as default for a user, unsetting any existing default"""
262
+ try:
263
+ from app.models.user_model import BookMyServiceUserModel
264
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
265
+ if not user:
266
+ return False
267
+ guests = user.get("guests", [])
268
+ found = False
269
+ now = datetime.utcnow()
270
+ for g in guests:
271
+ if g.get("guest_id") == guest_id:
272
+ g["is_default"] = True
273
+ g["updated_at"] = now
274
+ found = True
275
+ else:
276
+ if g.get("is_default"):
277
+ g["is_default"] = False
278
+ g["updated_at"] = now
279
+ if not found:
280
+ return False
281
+ # Sanitize for MongoDB before write
282
+ sanitized_guests = prepare_for_db(guests)
283
+ res = await BookMyServiceUserModel.collection.update_one(
284
+ {"customer_id": customer_id},
285
+ {"$set": {"guests": sanitized_guests}}
286
+ )
287
+ return True
288
+ except Exception as e:
289
+ logger.error(f"Error setting default guest {guest_id} for user {customer_id}: {str(e)}")
290
+ return False
app/models/otp_model.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException
2
+ from app.core.cache_client import get_redis
3
+ from app.utils.sms_utils import send_sms_otp
4
+ from app.utils.email_utils import send_email_otp
5
+ from app.utils.common_utils import is_email
6
+ import logging
7
+
8
+ logger = logging.getLogger("otp_model")
9
+
10
+ class BookMyServiceOTPModel:
11
+ OTP_TTL = 300 # 5 minutes
12
+ RATE_LIMIT_MAX = 3
13
+ RATE_LIMIT_WINDOW = 600 # 10 minutes
14
+ IP_RATE_LIMIT_MAX = 10 # Max 10 OTPs per IP per hour
15
+ IP_RATE_LIMIT_WINDOW = 3600 # 1 hour
16
+ FAILED_ATTEMPTS_MAX = 5 # Max 5 failed attempts before lock
17
+ FAILED_ATTEMPTS_WINDOW = 3600 # 1 hour
18
+ ACCOUNT_LOCK_DURATION = 1800 # 30 minutes
19
+
20
+ @staticmethod
21
+ async def store_otp(identifier: str, phone: str, otp: str, ttl: int = OTP_TTL, client_ip: str = None):
22
+ logger.info(f"Storing OTP for identifier: {identifier}, IP: {client_ip}")
23
+
24
+ try:
25
+ redis = await get_redis()
26
+ logger.info(f"Redis connection established for OTP storage")
27
+
28
+ # Check if account is locked
29
+ if await BookMyServiceOTPModel.is_account_locked(identifier):
30
+ logger.warning(f"Account locked for identifier: {identifier}")
31
+ raise HTTPException(status_code=423, detail="Account temporarily locked due to too many failed attempts")
32
+
33
+ # Rate limit: max 3 OTPs per identifier per 10 minutes
34
+ rate_key = f"otp_rate_limit:{identifier}"
35
+ logger.info(f"Checking rate limit with key: {rate_key}")
36
+
37
+ attempts = await redis.incr(rate_key)
38
+ logger.info(f"Current OTP attempts for {identifier}: {attempts}")
39
+
40
+ if attempts == 1:
41
+ await redis.expire(rate_key, BookMyServiceOTPModel.RATE_LIMIT_WINDOW)
42
+ logger.info(f"Set rate limit expiry for {identifier}")
43
+ elif attempts > BookMyServiceOTPModel.RATE_LIMIT_MAX:
44
+ logger.warning(f"Rate limit exceeded for {identifier}: {attempts} attempts")
45
+ raise HTTPException(status_code=429, detail="Too many OTP requests. Try again later.")
46
+
47
+ # IP-based rate limiting
48
+ if client_ip:
49
+ ip_rate_key = f"otp_ip_rate_limit:{client_ip}"
50
+ ip_attempts = await redis.incr(ip_rate_key)
51
+
52
+ if ip_attempts == 1:
53
+ await redis.expire(ip_rate_key, BookMyServiceOTPModel.IP_RATE_LIMIT_WINDOW)
54
+ elif ip_attempts > BookMyServiceOTPModel.IP_RATE_LIMIT_MAX:
55
+ logger.warning(f"IP rate limit exceeded for {client_ip}: {ip_attempts} attempts")
56
+ raise HTTPException(status_code=429, detail="Too many OTP requests from this IP address")
57
+
58
+ # Store OTP
59
+ otp_key = f"bms_otp:{identifier}"
60
+ await redis.setex(otp_key, ttl, otp)
61
+ logger.info(f"OTP stored successfully for {identifier} with key: {otp_key}, TTL: {ttl}")
62
+
63
+ except HTTPException as e:
64
+ logger.error(f"HTTP error storing OTP for {identifier}: {e.status_code} - {e.detail}")
65
+ raise e
66
+ except Exception as e:
67
+ logger.error(f"Unexpected error storing OTP for {identifier}: {str(e)}", exc_info=True)
68
+ raise HTTPException(status_code=500, detail="Failed to store OTP")
69
+ '''
70
+ # Send OTP via SMS, fallback to Email if identifier is email
71
+ try:
72
+ sid = send_sms_otp(phone, otp)
73
+ print(f"OTP {otp} sent to {phone} via SMS. SID: {sid}")
74
+ except Exception as sms_error:
75
+ print(f"⚠️ SMS failed: {sms_error}")
76
+ if is_email(identifier):
77
+ try:
78
+ await send_email_otp(identifier, otp)
79
+ print(f"✅ OTP {otp} sent to {identifier} via email fallback.")
80
+ except Exception as email_error:
81
+ raise HTTPException(status_code=500, detail=f"SMS and email both failed: {email_error}")
82
+ else:
83
+ raise HTTPException(status_code=500, detail="SMS failed and no email fallback available.")
84
+ '''
85
+ @staticmethod
86
+ async def verify_otp(identifier: str, otp: str, client_ip: str = None):
87
+ logger.info(f"Verifying OTP for identifier: {identifier}, IP: {client_ip}")
88
+ logger.info(f"Provided OTP: {otp}")
89
+
90
+ try:
91
+ redis = await get_redis()
92
+ logger.info("Redis connection established for OTP verification")
93
+
94
+ # Check if account is locked
95
+ if await BookMyServiceOTPModel.is_account_locked(identifier):
96
+ logger.warning(f"Account locked for identifier: {identifier}")
97
+ raise HTTPException(status_code=423, detail="Account temporarily locked due to too many failed attempts")
98
+
99
+ key = f"bms_otp:{identifier}"
100
+ logger.info(f"Looking up OTP with key: {key}")
101
+
102
+ stored = await redis.get(key)
103
+ logger.info(f"Stored OTP value: {stored}")
104
+
105
+ if stored:
106
+ logger.info(f"OTP found in Redis. Comparing: provided='{otp}' vs stored='{stored}'")
107
+ if stored == otp:
108
+ logger.info(f"OTP verification successful for {identifier}")
109
+ await redis.delete(key)
110
+ # Clear failed attempts on successful verification
111
+ await BookMyServiceOTPModel.clear_failed_attempts(identifier)
112
+ logger.info(f"OTP deleted from Redis after successful verification")
113
+ return True
114
+ else:
115
+ logger.warning(f"OTP mismatch for {identifier}: provided='{otp}' vs stored='{stored}'")
116
+ # Track failed attempt
117
+ await BookMyServiceOTPModel.track_failed_attempt(identifier, client_ip)
118
+ return False
119
+ else:
120
+ logger.warning(f"No OTP found in Redis for identifier: {identifier} with key: {key}")
121
+ # Track failed attempt for expired/non-existent OTP
122
+ await BookMyServiceOTPModel.track_failed_attempt(identifier, client_ip)
123
+ return False
124
+
125
+ except HTTPException as e:
126
+ logger.error(f"HTTP error verifying OTP for {identifier}: {e.status_code} - {e.detail}")
127
+ raise e
128
+ except Exception as e:
129
+ logger.error(f"Error verifying OTP for {identifier}: {str(e)}", exc_info=True)
130
+ return False
131
+
132
+ @staticmethod
133
+ async def read_otp(identifier: str):
134
+ redis = await get_redis()
135
+ key = f"bms_otp:{identifier}"
136
+ otp = await redis.get(key)
137
+ if otp:
138
+ return otp
139
+ raise HTTPException(status_code=404, detail="OTP not found or expired")
140
+
141
+ @staticmethod
142
+ async def track_failed_attempt(identifier: str, client_ip: str = None):
143
+ """Track failed OTP verification attempts"""
144
+ logger.info(f"Tracking failed attempt for identifier: {identifier}, IP: {client_ip}")
145
+
146
+ try:
147
+ redis = await get_redis()
148
+
149
+ # Track failed attempts for identifier
150
+ failed_key = f"failed_otp:{identifier}"
151
+ attempts = await redis.incr(failed_key)
152
+
153
+ if attempts == 1:
154
+ await redis.expire(failed_key, BookMyServiceOTPModel.FAILED_ATTEMPTS_WINDOW)
155
+
156
+ # Lock account if too many failed attempts
157
+ if attempts >= BookMyServiceOTPModel.FAILED_ATTEMPTS_MAX:
158
+ await BookMyServiceOTPModel.lock_account(identifier)
159
+ logger.warning(f"Account locked for {identifier} after {attempts} failed attempts")
160
+
161
+ # Track IP-based failed attempts
162
+ if client_ip:
163
+ ip_failed_key = f"failed_otp_ip:{client_ip}"
164
+ ip_attempts = await redis.incr(ip_failed_key)
165
+
166
+ if ip_attempts == 1:
167
+ await redis.expire(ip_failed_key, BookMyServiceOTPModel.FAILED_ATTEMPTS_WINDOW)
168
+
169
+ logger.info(f"IP {client_ip} failed attempts: {ip_attempts}")
170
+
171
+ except Exception as e:
172
+ logger.error(f"Error tracking failed attempt for {identifier}: {str(e)}", exc_info=True)
173
+
174
+ @staticmethod
175
+ async def clear_failed_attempts(identifier: str):
176
+ """Clear failed attempts counter on successful verification"""
177
+ try:
178
+ redis = await get_redis()
179
+ failed_key = f"failed_otp:{identifier}"
180
+ await redis.delete(failed_key)
181
+ logger.info(f"Cleared failed attempts for {identifier}")
182
+ except Exception as e:
183
+ logger.error(f"Error clearing failed attempts for {identifier}: {str(e)}", exc_info=True)
184
+
185
+ @staticmethod
186
+ async def lock_account(identifier: str):
187
+ """Lock account temporarily"""
188
+ try:
189
+ redis = await get_redis()
190
+ lock_key = f"account_locked:{identifier}"
191
+ await redis.setex(lock_key, BookMyServiceOTPModel.ACCOUNT_LOCK_DURATION, "locked")
192
+ logger.info(f"Account locked for {identifier} for {BookMyServiceOTPModel.ACCOUNT_LOCK_DURATION} seconds")
193
+ except Exception as e:
194
+ logger.error(f"Error locking account for {identifier}: {str(e)}", exc_info=True)
195
+
196
+ @staticmethod
197
+ async def is_account_locked(identifier: str) -> bool:
198
+ """Check if account is currently locked"""
199
+ try:
200
+ redis = await get_redis()
201
+ lock_key = f"account_locked:{identifier}"
202
+ locked = await redis.get(lock_key)
203
+ return locked is not None
204
+ except Exception as e:
205
+ logger.error(f"Error checking account lock for {identifier}: {str(e)}", exc_info=True)
206
+ return False
207
+
208
+ @staticmethod
209
+ async def get_rate_limit_count(rate_key: str) -> int:
210
+ """Get current rate limit count for a key"""
211
+ try:
212
+ redis = await get_redis()
213
+ count = await redis.get(rate_key)
214
+ return int(count) if count else 0
215
+ except Exception as e:
216
+ logger.error(f"Error getting rate limit count for {rate_key}: {str(e)}", exc_info=True)
217
+ return 0
218
+
219
+ @staticmethod
220
+ async def increment_rate_limit(rate_key: str, window: int) -> int:
221
+ """Increment rate limit counter with expiry"""
222
+ try:
223
+ redis = await get_redis()
224
+ count = await redis.incr(rate_key)
225
+ if count == 1:
226
+ await redis.expire(rate_key, window)
227
+ return count
228
+ except Exception as e:
229
+ logger.error(f"Error incrementing rate limit for {rate_key}: {str(e)}", exc_info=True)
230
+ return 0
app/models/pet_model.py ADDED
@@ -0,0 +1,270 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from app.core.nosql_client import db
2
+ from datetime import datetime
3
+ from typing import List, Optional, Dict, Any
4
+ import uuid
5
+ import logging
6
+
7
+ from app.utils.db import prepare_for_db
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class PetModel:
12
+ """Model for managing pet profiles embedded under customer documents"""
13
+
14
+ @staticmethod
15
+ async def create_pet(
16
+ customer_id: str,
17
+ pet_data: dict
18
+ ) -> Optional[str]:
19
+ """Create a new embedded pet profile under a user in customers collection"""
20
+ try:
21
+ from app.models.user_model import BookMyServiceUserModel
22
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
23
+ if not user:
24
+ logger.error(f"User not found for customer_id {customer_id}")
25
+ return None
26
+
27
+ pet_id = str(uuid.uuid4())
28
+ current_time = datetime.utcnow()
29
+
30
+ pet_doc = {
31
+ "pet_id": pet_id,
32
+ "pet_name": pet_data.get('pet_name'),
33
+ "species": getattr(pet_data.get('species'), 'value', pet_data.get('species')),
34
+ "breed": pet_data.get('breed'),
35
+ "date_of_birth": pet_data.get('date_of_birth'),
36
+ "age": pet_data.get('age'),
37
+ "weight": pet_data.get('weight'),
38
+ "gender": getattr(pet_data.get('gender'), 'value', pet_data.get('gender')),
39
+ "temperament": getattr(pet_data.get('temperament'), 'value', pet_data.get('temperament')),
40
+ "health_notes": pet_data.get('health_notes'),
41
+ "is_vaccinated": pet_data.get('is_vaccinated'),
42
+ "pet_photo_url": pet_data.get('pet_photo_url'),
43
+ "is_default": pet_data.get('is_default', False),
44
+ "created_at": current_time,
45
+ "updated_at": current_time
46
+ }
47
+
48
+ pets = user.get("pets", [])
49
+ if pet_doc.get("is_default"):
50
+ for existing in pets:
51
+ if existing.get("is_default"):
52
+ existing["is_default"] = False
53
+ existing["updated_at"] = current_time
54
+ else:
55
+ if len(pets) == 0:
56
+ pet_doc["is_default"] = True
57
+ pets.append(pet_doc)
58
+
59
+ # Sanitize pets list for MongoDB (convert date to datetime, etc.)
60
+ sanitized_pets = prepare_for_db(pets)
61
+ update_res = await BookMyServiceUserModel.collection.update_one(
62
+ {"customer_id": customer_id},
63
+ {"$set": {"pets": sanitized_pets}}
64
+ )
65
+
66
+ if update_res.modified_count > 0:
67
+ logger.info(f"Embedded pet created successfully: {pet_id} for user: {customer_id}")
68
+ return pet_id
69
+ else:
70
+ logger.info(f"Embedded pet creation attempted with no modified_count for user: {customer_id}")
71
+ return pet_id
72
+
73
+ except Exception as e:
74
+ logger.error(f"Error creating embedded pet for user {customer_id}: {str(e)}")
75
+ return None
76
+
77
+ @staticmethod
78
+ async def get_user_pets(customer_id: str) -> List[Dict[str, Any]]:
79
+ """Get all embedded pets for a specific user from customers collection"""
80
+ try:
81
+ from app.models.user_model import BookMyServiceUserModel
82
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
83
+ if not user:
84
+ return []
85
+
86
+ pets = user.get("pets", [])
87
+ pets.sort(key=lambda x: x.get("created_at", datetime.utcnow()), reverse=True)
88
+ for p in pets:
89
+ p["customer_id"] = customer_id
90
+ logger.info(f"Retrieved {len(pets)} embedded pets for user: {customer_id}")
91
+ return pets
92
+
93
+ except Exception as e:
94
+ logger.error(f"Error getting embedded pets for user {customer_id}: {str(e)}")
95
+ return []
96
+
97
+ @staticmethod
98
+ async def get_pet_by_id(customer_id: str, pet_id: str) -> Optional[Dict[str, Any]]:
99
+ """Get a specific embedded pet by ID for a user"""
100
+ try:
101
+ from app.models.user_model import BookMyServiceUserModel
102
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
103
+ if not user:
104
+ return None
105
+
106
+ pets = user.get("pets", [])
107
+ for p in pets:
108
+ if p.get("pet_id") == pet_id:
109
+ p_copy = dict(p)
110
+ p_copy["customer_id"] = customer_id
111
+ logger.info(f"Embedded pet found: {pet_id} for user: {customer_id}")
112
+ return p_copy
113
+ logger.warning(f"Embedded pet not found: {pet_id} for user: {customer_id}")
114
+ return None
115
+
116
+ except Exception as e:
117
+ logger.error(f"Error getting embedded pet {pet_id} for user {customer_id}: {str(e)}")
118
+ return None
119
+
120
+ @staticmethod
121
+ async def update_pet(customer_id: str, pet_id: str, update_fields: Dict[str, Any]) -> bool:
122
+ """Update an embedded pet's information under a user"""
123
+ try:
124
+ from app.models.user_model import BookMyServiceUserModel
125
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
126
+ if not user:
127
+ return False
128
+
129
+ pets = user.get("pets", [])
130
+ updated = False
131
+
132
+ for idx, p in enumerate(pets):
133
+ if p.get("pet_id") == pet_id:
134
+ normalized_updates: Dict[str, Any] = {}
135
+ for k, v in update_fields.items():
136
+ if hasattr(v, "value"):
137
+ normalized_updates[k] = v.value
138
+ else:
139
+ normalized_updates[k] = v
140
+
141
+ normalized_updates["updated_at"] = datetime.utcnow()
142
+ pets[idx] = {**p, **normalized_updates}
143
+ updated = True
144
+ if update_fields.get("is_default"):
145
+ for j, other in enumerate(pets):
146
+ if other.get("pet_id") != pet_id and other.get("is_default"):
147
+ other["is_default"] = False
148
+ other["updated_at"] = datetime.utcnow()
149
+ pets[j] = other
150
+ break
151
+
152
+ if not updated:
153
+ logger.warning(f"Embedded pet not found for update: {pet_id} (user {customer_id})")
154
+ return False
155
+
156
+ # Sanitize pets list for MongoDB
157
+ sanitized_pets = prepare_for_db(pets)
158
+ result = await BookMyServiceUserModel.collection.update_one(
159
+ {"customer_id": customer_id},
160
+ {"$set": {"pets": sanitized_pets}}
161
+ )
162
+
163
+ if result.modified_count > 0:
164
+ logger.info(f"Embedded pet updated successfully: {pet_id} (user {customer_id})")
165
+ return True
166
+ else:
167
+ logger.info(f"Embedded pet update applied with no modified_count: {pet_id} (user {customer_id})")
168
+ return True
169
+
170
+ except Exception as e:
171
+ logger.error(f"Error updating embedded pet {pet_id} for user {customer_id}: {str(e)}")
172
+ return False
173
+
174
+ @staticmethod
175
+ async def delete_pet(customer_id: str, pet_id: str) -> bool:
176
+ """Delete an embedded pet profile under a user"""
177
+ try:
178
+ from app.models.user_model import BookMyServiceUserModel
179
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
180
+ if not user:
181
+ return False
182
+
183
+ pets = user.get("pets", [])
184
+ new_pets = [p for p in pets if p.get("pet_id") != pet_id]
185
+ if len(new_pets) == len(pets):
186
+ logger.warning(f"Embedded pet not found for deletion: {pet_id} (user {customer_id})")
187
+ return False
188
+
189
+ # Sanitize pets list for MongoDB
190
+ sanitized_new_pets = prepare_for_db(new_pets)
191
+ result = await BookMyServiceUserModel.collection.update_one(
192
+ {"customer_id": customer_id},
193
+ {"$set": {"pets": sanitized_new_pets}}
194
+ )
195
+
196
+ if result.modified_count > 0:
197
+ logger.info(f"Embedded pet deleted successfully: {pet_id} (user {customer_id})")
198
+ return True
199
+ else:
200
+ logger.info(f"Embedded pet deletion applied with no modified_count: {pet_id} (user {customer_id})")
201
+ return True
202
+
203
+ except Exception as e:
204
+ logger.error(f"Error deleting embedded pet {pet_id} for user {customer_id}: {str(e)}")
205
+ return False
206
+
207
+ @staticmethod
208
+ async def get_pet_count_for_user(customer_id: str) -> int:
209
+ """Get the total number of embedded pets for a user"""
210
+ try:
211
+ from app.models.user_model import BookMyServiceUserModel
212
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
213
+ if not user:
214
+ return 0
215
+ return len(user.get("pets", []))
216
+ except Exception as e:
217
+ logger.error(f"Error counting embedded pets for user {customer_id}: {str(e)}")
218
+ return 0
219
+
220
+ @staticmethod
221
+ async def get_default_pet(customer_id: str) -> Optional[Dict[str, Any]]:
222
+ """Get the default pet for a user"""
223
+ try:
224
+ from app.models.user_model import BookMyServiceUserModel
225
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
226
+ if not user:
227
+ return None
228
+ pets = user.get("pets", [])
229
+ for p in pets:
230
+ if p.get("is_default"):
231
+ p_copy = dict(p)
232
+ p_copy["customer_id"] = customer_id
233
+ return p_copy
234
+ return None
235
+ except Exception as e:
236
+ logger.error(f"Error getting default pet for user {customer_id}: {str(e)}")
237
+ return None
238
+
239
+ @staticmethod
240
+ async def set_default_pet(customer_id: str, pet_id: str) -> bool:
241
+ """Set a pet as default for a user, unsetting any existing default"""
242
+ try:
243
+ from app.models.user_model import BookMyServiceUserModel
244
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
245
+ if not user:
246
+ return False
247
+ pets = user.get("pets", [])
248
+ found = False
249
+ now = datetime.utcnow()
250
+ for p in pets:
251
+ if p.get("pet_id") == pet_id:
252
+ p["is_default"] = True
253
+ p["updated_at"] = now
254
+ found = True
255
+ else:
256
+ if p.get("is_default"):
257
+ p["is_default"] = False
258
+ p["updated_at"] = now
259
+ if not found:
260
+ return False
261
+ # Sanitize pets list for MongoDB
262
+ sanitized_pets = prepare_for_db(pets)
263
+ res = await BookMyServiceUserModel.collection.update_one(
264
+ {"customer_id": customer_id},
265
+ {"$set": {"pets": sanitized_pets}}
266
+ )
267
+ return True
268
+ except Exception as e:
269
+ logger.error(f"Error setting default pet {pet_id} for user {customer_id}: {str(e)}")
270
+ return False
app/models/refresh_token_model.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ from typing import Optional
3
+ import uuid
4
+ import logging
5
+ from app.core.nosql_client import db
6
+ from app.core.cache_client import get_redis
7
+
8
+ logger = logging.getLogger("refresh_token_model")
9
+
10
+ class RefreshTokenModel:
11
+ """Model for managing refresh tokens with rotation support"""
12
+
13
+ collection = db["refresh_tokens"]
14
+
15
+ # Token family tracking for rotation
16
+ TOKEN_FAMILY_TTL = 30 * 24 * 3600 # 30 days in seconds
17
+
18
+ @staticmethod
19
+ async def create_token_family(customer_id: str, device_info: Optional[str] = None) -> str:
20
+ """Create a new token family for refresh token rotation"""
21
+ family_id = str(uuid.uuid4())
22
+
23
+ try:
24
+ redis = await get_redis()
25
+ family_key = f"token_family:{family_id}"
26
+
27
+ family_data = {
28
+ "customer_id": customer_id,
29
+ "device_info": device_info,
30
+ "created_at": datetime.utcnow().isoformat(),
31
+ "rotation_count": 0
32
+ }
33
+
34
+ import json
35
+ await redis.setex(family_key, RefreshTokenModel.TOKEN_FAMILY_TTL, json.dumps(family_data))
36
+
37
+ logger.info(f"Created token family {family_id} for user {customer_id}")
38
+ return family_id
39
+
40
+ except Exception as e:
41
+ logger.error(f"Error creating token family: {str(e)}", exc_info=True)
42
+ raise
43
+
44
+ @staticmethod
45
+ async def get_token_family(family_id: str) -> Optional[dict]:
46
+ """Get token family data"""
47
+ try:
48
+ redis = await get_redis()
49
+ family_key = f"token_family:{family_id}"
50
+
51
+ data = await redis.get(family_key)
52
+ if data:
53
+ import json
54
+ return json.loads(data)
55
+ return None
56
+
57
+ except Exception as e:
58
+ logger.error(f"Error getting token family: {str(e)}", exc_info=True)
59
+ return None
60
+
61
+ @staticmethod
62
+ async def increment_rotation_count(family_id: str) -> int:
63
+ """Increment rotation count for a token family"""
64
+ try:
65
+ redis = await get_redis()
66
+ family_key = f"token_family:{family_id}"
67
+
68
+ family_data = await RefreshTokenModel.get_token_family(family_id)
69
+ if not family_data:
70
+ return 0
71
+
72
+ family_data["rotation_count"] = family_data.get("rotation_count", 0) + 1
73
+ family_data["last_rotated"] = datetime.utcnow().isoformat()
74
+
75
+ import json
76
+ ttl = await redis.ttl(family_key)
77
+ if ttl > 0:
78
+ await redis.setex(family_key, ttl, json.dumps(family_data))
79
+
80
+ logger.info(f"Incremented rotation count for family {family_id} to {family_data['rotation_count']}")
81
+ return family_data["rotation_count"]
82
+
83
+ except Exception as e:
84
+ logger.error(f"Error incrementing rotation count: {str(e)}", exc_info=True)
85
+ return 0
86
+
87
+ @staticmethod
88
+ async def store_refresh_token(
89
+ token_id: str,
90
+ customer_id: str,
91
+ family_id: str,
92
+ expires_at: datetime,
93
+ remember_me: bool = False,
94
+ device_info: Optional[str] = None,
95
+ ip_address: Optional[str] = None
96
+ ):
97
+ """Store refresh token metadata"""
98
+ try:
99
+ token_doc = {
100
+ "token_id": token_id,
101
+ "customer_id": customer_id,
102
+ "family_id": family_id,
103
+ "expires_at": expires_at,
104
+ "remember_me": remember_me,
105
+ "device_info": device_info,
106
+ "ip_address": ip_address,
107
+ "created_at": datetime.utcnow(),
108
+ "revoked": False,
109
+ "used": False
110
+ }
111
+
112
+ await RefreshTokenModel.collection.insert_one(token_doc)
113
+ logger.info(f"Stored refresh token {token_id} for user {customer_id}")
114
+
115
+ except Exception as e:
116
+ logger.error(f"Error storing refresh token: {str(e)}", exc_info=True)
117
+ raise
118
+
119
+ @staticmethod
120
+ async def mark_token_as_used(token_id: str) -> bool:
121
+ """Mark a refresh token as used (for rotation)"""
122
+ try:
123
+ result = await RefreshTokenModel.collection.update_one(
124
+ {"token_id": token_id},
125
+ {
126
+ "$set": {
127
+ "used": True,
128
+ "used_at": datetime.utcnow()
129
+ }
130
+ }
131
+ )
132
+
133
+ if result.modified_count > 0:
134
+ logger.info(f"Marked token {token_id} as used")
135
+ return True
136
+ return False
137
+
138
+ except Exception as e:
139
+ logger.error(f"Error marking token as used: {str(e)}", exc_info=True)
140
+ return False
141
+
142
+ @staticmethod
143
+ async def is_token_valid(token_id: str) -> bool:
144
+ """Check if a refresh token is valid (not revoked or used)"""
145
+ try:
146
+ token = await RefreshTokenModel.collection.find_one({"token_id": token_id})
147
+
148
+ if not token:
149
+ logger.warning(f"Token {token_id} not found")
150
+ return False
151
+
152
+ if token.get("revoked"):
153
+ logger.warning(f"Token {token_id} is revoked")
154
+ return False
155
+
156
+ if token.get("used"):
157
+ logger.warning(f"Token {token_id} already used - possible replay attack")
158
+ # Revoke entire token family on reuse attempt
159
+ await RefreshTokenModel.revoke_token_family(token.get("family_id"))
160
+ return False
161
+
162
+ if token.get("expires_at") < datetime.utcnow():
163
+ logger.warning(f"Token {token_id} is expired")
164
+ return False
165
+
166
+ return True
167
+
168
+ except Exception as e:
169
+ logger.error(f"Error checking token validity: {str(e)}", exc_info=True)
170
+ return False
171
+
172
+ @staticmethod
173
+ async def get_token_metadata(token_id: str) -> Optional[dict]:
174
+ """Get refresh token metadata"""
175
+ try:
176
+ token = await RefreshTokenModel.collection.find_one({"token_id": token_id})
177
+ return token
178
+ except Exception as e:
179
+ logger.error(f"Error getting token metadata: {str(e)}", exc_info=True)
180
+ return None
181
+
182
+ @staticmethod
183
+ async def revoke_token(token_id: str) -> bool:
184
+ """Revoke a specific refresh token"""
185
+ try:
186
+ result = await RefreshTokenModel.collection.update_one(
187
+ {"token_id": token_id},
188
+ {
189
+ "$set": {
190
+ "revoked": True,
191
+ "revoked_at": datetime.utcnow()
192
+ }
193
+ }
194
+ )
195
+
196
+ if result.modified_count > 0:
197
+ logger.info(f"Revoked token {token_id}")
198
+ return True
199
+ return False
200
+
201
+ except Exception as e:
202
+ logger.error(f"Error revoking token: {str(e)}", exc_info=True)
203
+ return False
204
+
205
+ @staticmethod
206
+ async def revoke_token_family(family_id: str) -> int:
207
+ """Revoke all tokens in a family (security breach detection)"""
208
+ try:
209
+ result = await RefreshTokenModel.collection.update_many(
210
+ {"family_id": family_id, "revoked": False},
211
+ {
212
+ "$set": {
213
+ "revoked": True,
214
+ "revoked_at": datetime.utcnow(),
215
+ "revoke_reason": "token_reuse_detected"
216
+ }
217
+ }
218
+ )
219
+
220
+ # Also delete the family from Redis
221
+ redis = await get_redis()
222
+ await redis.delete(f"token_family:{family_id}")
223
+
224
+ logger.warning(f"Revoked {result.modified_count} tokens in family {family_id}")
225
+ return result.modified_count
226
+
227
+ except Exception as e:
228
+ logger.error(f"Error revoking token family: {str(e)}", exc_info=True)
229
+ return 0
230
+
231
+ @staticmethod
232
+ async def revoke_all_user_tokens(customer_id: str) -> int:
233
+ """Revoke all refresh tokens for a user (logout from all devices)"""
234
+ try:
235
+ result = await RefreshTokenModel.collection.update_many(
236
+ {"customer_id": customer_id, "revoked": False},
237
+ {
238
+ "$set": {
239
+ "revoked": True,
240
+ "revoked_at": datetime.utcnow(),
241
+ "revoke_reason": "user_logout_all"
242
+ }
243
+ }
244
+ )
245
+
246
+ logger.info(f"Revoked {result.modified_count} tokens for user {customer_id}")
247
+ return result.modified_count
248
+
249
+ except Exception as e:
250
+ logger.error(f"Error revoking all user tokens: {str(e)}", exc_info=True)
251
+ return 0
252
+
253
+ @staticmethod
254
+ async def get_active_sessions(customer_id: str) -> list:
255
+ """Get all active sessions (valid refresh tokens) for a user"""
256
+ try:
257
+ tokens = await RefreshTokenModel.collection.find({
258
+ "customer_id": customer_id,
259
+ "revoked": False,
260
+ "expires_at": {"$gt": datetime.utcnow()}
261
+ }).to_list(length=100)
262
+
263
+ return tokens
264
+
265
+ except Exception as e:
266
+ logger.error(f"Error getting active sessions: {str(e)}", exc_info=True)
267
+ return []
268
+
269
+ @staticmethod
270
+ async def cleanup_expired_tokens():
271
+ """Cleanup expired tokens (run periodically)"""
272
+ try:
273
+ result = await RefreshTokenModel.collection.delete_many({
274
+ "expires_at": {"$lt": datetime.utcnow()}
275
+ })
276
+
277
+ logger.info(f"Cleaned up {result.deleted_count} expired tokens")
278
+ return result.deleted_count
279
+
280
+ except Exception as e:
281
+ logger.error(f"Error cleaning up expired tokens: {str(e)}", exc_info=True)
282
+ return 0
app/models/review_model.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException
2
+ from app.core.nosql_client import db
3
+ import logging
4
+ from datetime import datetime
5
+
6
+ from app.utils.db import prepare_for_db
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class ReviewModel:
11
+ collection = db["merchant_reviews"]
12
+
13
+ @staticmethod
14
+ async def create_review(
15
+ review_data:dict
16
+ )->dict:
17
+ logger.info(f"Creating review for merchant {review_data['merchant_id']}")
18
+
19
+ try:
20
+
21
+ # Creating review document
22
+ review_doc = {
23
+ "merchant_id":review_data["merchant_id"],
24
+ "location_id": review_data["location_id"],
25
+ "user_name":review_data["user_name"],
26
+ "rating": review_data[ "rating"],
27
+ "review_text":review_data["review_text"],
28
+ "review_date": datetime.utcnow(),
29
+ "verified_purchase":review_data["verified_purchase"]
30
+ }
31
+
32
+ sanitized_review = prepare_for_db(review_doc)
33
+
34
+ result = await ReviewModel.collection.insert_one(sanitized_review)
35
+
36
+ logger.info(f"review data inserted successfully: {result}")
37
+
38
+ if result.inserted_id:
39
+ return review_doc
40
+
41
+ except HTTPException:
42
+ raise
43
+ except Exception as e:
44
+ logger.error(f"Error while adding review: {str(e)}", exc_info=True)
45
+ raise HTTPException(status_code=500, detail="Failed to add review details")
app/models/social_account_model.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException
2
+ from app.core.nosql_client import db
3
+ from datetime import datetime
4
+ from typing import Optional, List, Dict, Any
5
+ import logging
6
+
7
+ logger = logging.getLogger("social_account_model")
8
+
9
+ class SocialAccountModel:
10
+ """Model for managing social login accounts and linking"""
11
+
12
+ collection = db["social_accounts"]
13
+
14
+ @staticmethod
15
+ async def create_social_account(customer_id: str, provider: str, provider_customer_id: str, user_info: Dict[str, Any]) -> str:
16
+ """Create a new social account record"""
17
+ try:
18
+ social_account = {
19
+ "customer_id": customer_id,
20
+ "provider": provider,
21
+ "provider_customer_id": provider_customer_id,
22
+ "email": user_info.get("email"),
23
+ "name": user_info.get("name"),
24
+ "picture": user_info.get("picture"),
25
+ "profile_data": user_info,
26
+ "created_at": datetime.utcnow(),
27
+ "updated_at": datetime.utcnow(),
28
+ "is_active": True,
29
+ "last_login": datetime.utcnow()
30
+ }
31
+
32
+ result = await SocialAccountModel.collection.insert_one(social_account)
33
+ logger.info(f"Created social account for user {customer_id} with provider {provider}")
34
+ return str(result.inserted_id)
35
+
36
+ except Exception as e:
37
+ logger.error(f"Error creating social account: {str(e)}", exc_info=True)
38
+ raise HTTPException(status_code=500, detail="Failed to create social account")
39
+
40
+ @staticmethod
41
+ async def find_by_provider_and_customer_id(provider: str, provider_customer_id: str) -> Optional[Dict[str, Any]]:
42
+ """Find social account by provider and provider user ID"""
43
+ try:
44
+ account = await SocialAccountModel.collection.find_one({
45
+ "provider": provider,
46
+ "provider_customer_id": provider_customer_id,
47
+ "is_active": True
48
+ })
49
+ return account
50
+ except Exception as e:
51
+ logger.error(f"Error finding social account: {str(e)}", exc_info=True)
52
+ return None
53
+
54
+ @staticmethod
55
+ async def find_by_customer_id(customer_id: str) -> List[Dict[str, Any]]:
56
+ """Find all social accounts for a user"""
57
+ try:
58
+ cursor = SocialAccountModel.collection.find({
59
+ "customer_id": customer_id,
60
+ "is_active": True
61
+ })
62
+ accounts = await cursor.to_list(length=None)
63
+ return accounts
64
+ except Exception as e:
65
+ logger.error(f"Error finding social accounts for user {customer_id}: {str(e)}", exc_info=True)
66
+ return []
67
+
68
+ @staticmethod
69
+ async def update_social_account(provider: str, provider_customer_id: str, user_info: Dict[str, Any]) -> bool:
70
+ """Update social account with latest user info"""
71
+ try:
72
+ update_data = {
73
+ "email": user_info.get("email"),
74
+ "name": user_info.get("name"),
75
+ "picture": user_info.get("picture"),
76
+ "profile_data": user_info,
77
+ "updated_at": datetime.utcnow(),
78
+ "last_login": datetime.utcnow()
79
+ }
80
+
81
+ result = await SocialAccountModel.collection.update_one(
82
+ {
83
+ "provider": provider,
84
+ "provider_customer_id": provider_customer_id,
85
+ "is_active": True
86
+ },
87
+ {"$set": update_data}
88
+ )
89
+
90
+ return result.modified_count > 0
91
+
92
+ except Exception as e:
93
+ logger.error(f"Error updating social account: {str(e)}", exc_info=True)
94
+ return False
95
+
96
+ @staticmethod
97
+ async def link_social_account(customer_id: str, provider: str, provider_customer_id: str, user_info: Dict[str, Any]) -> bool:
98
+ """Link a social account to an existing user"""
99
+ try:
100
+ # Check if this social account is already linked to another user
101
+ existing_account = await SocialAccountModel.find_by_provider_and_customer_id(provider, provider_customer_id)
102
+
103
+ if existing_account and existing_account["customer_id"] != customer_id:
104
+ logger.warning(f"Social account {provider}:{provider_customer_id} already linked to user {existing_account['customer_id']}")
105
+ raise HTTPException(
106
+ status_code=409,
107
+ detail=f"This {provider} account is already linked to another user"
108
+ )
109
+
110
+ if existing_account and existing_account["customer_id"] == customer_id:
111
+ # Update existing account
112
+ await SocialAccountModel.update_social_account(provider, provider_customer_id, user_info)
113
+ return True
114
+
115
+ # Create new social account link
116
+ await SocialAccountModel.create_social_account(customer_id, provider, provider_customer_id, user_info)
117
+ return True
118
+
119
+ except HTTPException:
120
+ raise
121
+ except Exception as e:
122
+ logger.error(f"Error linking social account: {str(e)}", exc_info=True)
123
+ raise HTTPException(status_code=500, detail="Failed to link social account")
124
+
125
+ @staticmethod
126
+ async def unlink_social_account(customer_id: str, provider: str) -> bool:
127
+ """Unlink a social account from a user"""
128
+ try:
129
+ result = await SocialAccountModel.collection.update_one(
130
+ {
131
+ "customer_id": customer_id,
132
+ "provider": provider,
133
+ "is_active": True
134
+ },
135
+ {
136
+ "$set": {
137
+ "is_active": False,
138
+ "updated_at": datetime.utcnow()
139
+ }
140
+ }
141
+ )
142
+
143
+ if result.modified_count > 0:
144
+ logger.info(f"Unlinked {provider} account for user {customer_id}")
145
+ return True
146
+ else:
147
+ logger.warning(f"No active {provider} account found for user {customer_id}")
148
+ return False
149
+
150
+ except Exception as e:
151
+ logger.error(f"Error unlinking social account: {str(e)}", exc_info=True)
152
+ return False
153
+
154
+ @staticmethod
155
+ async def get_profile_picture(customer_id: str, preferred_provider: str = None) -> Optional[str]:
156
+ """Get user's profile picture from social accounts"""
157
+ try:
158
+ query = {"customer_id": customer_id, "is_active": True}
159
+
160
+ # If preferred provider specified, try that first
161
+ if preferred_provider:
162
+ account = await SocialAccountModel.collection.find_one({
163
+ **query,
164
+ "provider": preferred_provider,
165
+ "picture": {"$exists": True, "$ne": None}
166
+ })
167
+ if account and account.get("picture"):
168
+ return account["picture"]
169
+
170
+ # Otherwise, get any account with a profile picture
171
+ account = await SocialAccountModel.collection.find_one({
172
+ **query,
173
+ "picture": {"$exists": True, "$ne": None}
174
+ })
175
+
176
+ return account.get("picture") if account else None
177
+
178
+ except Exception as e:
179
+ logger.error(f"Error getting profile picture for user {customer_id}: {str(e)}", exc_info=True)
180
+ return None
181
+
182
+ @staticmethod
183
+ async def get_social_account_summary(customer_id: str) -> Dict[str, Any]:
184
+ """Get summary of all linked social accounts for a user"""
185
+ try:
186
+ accounts = await SocialAccountModel.find_by_customer_id(customer_id)
187
+
188
+ summary = {
189
+ "linked_accounts": [],
190
+ "total_accounts": len(accounts),
191
+ "profile_picture": None
192
+ }
193
+
194
+ for account in accounts:
195
+ summary["linked_accounts"].append({
196
+ "provider": account["provider"],
197
+ "email": account.get("email"),
198
+ "name": account.get("name"),
199
+ "linked_at": account["created_at"],
200
+ "last_login": account.get("last_login")
201
+ })
202
+
203
+ # Set profile picture if available
204
+ if not summary["profile_picture"] and account.get("picture"):
205
+ summary["profile_picture"] = account["picture"]
206
+
207
+ return summary
208
+
209
+ except Exception as e:
210
+ logger.error(f"Error getting social account summary for user {customer_id}: {str(e)}", exc_info=True)
211
+ return {"linked_accounts": [], "total_accounts": 0, "profile_picture": None}
212
+
213
+ @staticmethod
214
+ async def merge_social_accounts(primary_customer_id: str, secondary_customer_id: str) -> bool:
215
+ """Merge social accounts from secondary user to primary user"""
216
+ try:
217
+ # Get all social accounts from secondary user
218
+ secondary_accounts = await SocialAccountModel.find_by_customer_id(secondary_customer_id)
219
+
220
+ for account in secondary_accounts:
221
+ # Check if primary user already has this provider linked
222
+ existing = await SocialAccountModel.collection.find_one({
223
+ "customer_id": primary_customer_id,
224
+ "provider": account["provider"],
225
+ "is_active": True
226
+ })
227
+
228
+ if not existing:
229
+ # Transfer the account to primary user
230
+ await SocialAccountModel.collection.update_one(
231
+ {"_id": account["_id"]},
232
+ {
233
+ "$set": {
234
+ "customer_id": primary_customer_id,
235
+ "updated_at": datetime.utcnow()
236
+ }
237
+ }
238
+ )
239
+ logger.info(f"Transferred {account['provider']} account from user {secondary_customer_id} to {primary_customer_id}")
240
+ else:
241
+ # Deactivate the secondary account
242
+ await SocialAccountModel.collection.update_one(
243
+ {"_id": account["_id"]},
244
+ {
245
+ "$set": {
246
+ "is_active": False,
247
+ "updated_at": datetime.utcnow()
248
+ }
249
+ }
250
+ )
251
+ logger.info(f"Deactivated duplicate {account['provider']} account for user {secondary_customer_id}")
252
+
253
+ return True
254
+
255
+ except Exception as e:
256
+ logger.error(f"Error merging social accounts: {str(e)}", exc_info=True)
257
+ return False
app/models/social_security_model.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ import logging
3
+ from app.core.cache_client import get_redis
4
+ from fastapi import HTTPException
5
+
6
+ logger = logging.getLogger(__name__)
7
+
8
+ class SocialSecurityModel:
9
+ """Model for handling social login security features"""
10
+
11
+ # Rate limiting constants
12
+ OAUTH_RATE_LIMIT_MAX = 5 # Max OAuth attempts per IP per hour
13
+ OAUTH_RATE_LIMIT_WINDOW = 3600 # 1 hour in seconds
14
+
15
+ # Failed attempt tracking
16
+ OAUTH_FAILED_ATTEMPTS_MAX = 3 # Max failed OAuth attempts per IP
17
+ OAUTH_FAILED_ATTEMPTS_WINDOW = 1800 # 30 minutes
18
+ OAUTH_IP_LOCK_DURATION = 3600 # 1 hour lock for IP
19
+
20
+ @staticmethod
21
+ async def check_oauth_rate_limit(client_ip: str, provider: str) -> bool:
22
+ """Check if OAuth rate limit is exceeded for IP and provider"""
23
+ if not client_ip:
24
+ return True # Allow if no IP provided
25
+
26
+ try:
27
+ redis = await get_redis()
28
+ rate_key = f"oauth_rate:{client_ip}:{provider}"
29
+
30
+ current_count = await redis.get(rate_key)
31
+ if current_count and int(current_count) >= SocialSecurityModel.OAUTH_RATE_LIMIT_MAX:
32
+ logger.warning(f"OAuth rate limit exceeded for IP {client_ip} and provider {provider}")
33
+ return False
34
+
35
+ return True
36
+
37
+ except Exception as e:
38
+ logger.error(f"Error checking OAuth rate limit: {str(e)}", exc_info=True)
39
+ return True # Allow on error to avoid blocking legitimate users
40
+
41
+ @staticmethod
42
+ async def increment_oauth_rate_limit(client_ip: str, provider: str):
43
+ """Increment OAuth rate limit counter"""
44
+ if not client_ip:
45
+ return
46
+
47
+ try:
48
+ redis = await get_redis()
49
+ rate_key = f"oauth_rate:{client_ip}:{provider}"
50
+
51
+ count = await redis.incr(rate_key)
52
+ if count == 1:
53
+ await redis.expire(rate_key, SocialSecurityModel.OAUTH_RATE_LIMIT_WINDOW)
54
+
55
+ logger.info(f"OAuth rate limit count for {client_ip}:{provider} = {count}")
56
+
57
+ except Exception as e:
58
+ logger.error(f"Error incrementing OAuth rate limit: {str(e)}", exc_info=True)
59
+
60
+ @staticmethod
61
+ async def track_oauth_failed_attempt(client_ip: str, provider: str):
62
+ """Track failed OAuth verification attempts"""
63
+ if not client_ip:
64
+ return
65
+
66
+ try:
67
+ redis = await get_redis()
68
+ failed_key = f"oauth_failed:{client_ip}:{provider}"
69
+
70
+ attempts = await redis.incr(failed_key)
71
+ if attempts == 1:
72
+ await redis.expire(failed_key, SocialSecurityModel.OAUTH_FAILED_ATTEMPTS_WINDOW)
73
+
74
+ # Lock IP if too many failed attempts
75
+ if attempts >= SocialSecurityModel.OAUTH_FAILED_ATTEMPTS_MAX:
76
+ await SocialSecurityModel.lock_oauth_ip(client_ip, provider)
77
+ logger.warning(f"IP {client_ip} locked for provider {provider} after {attempts} failed attempts")
78
+
79
+ logger.info(f"OAuth failed attempts for {client_ip}:{provider} = {attempts}")
80
+
81
+ except Exception as e:
82
+ logger.error(f"Error tracking OAuth failed attempt: {str(e)}", exc_info=True)
83
+
84
+ @staticmethod
85
+ async def lock_oauth_ip(client_ip: str, provider: str):
86
+ """Lock IP for OAuth attempts on specific provider"""
87
+ try:
88
+ redis = await get_redis()
89
+ lock_key = f"oauth_ip_locked:{client_ip}:{provider}"
90
+ await redis.setex(lock_key, SocialSecurityModel.OAUTH_IP_LOCK_DURATION, "locked")
91
+ logger.info(f"IP {client_ip} locked for OAuth provider {provider}")
92
+ except Exception as e:
93
+ logger.error(f"Error locking OAuth IP: {str(e)}", exc_info=True)
94
+
95
+ @staticmethod
96
+ async def is_oauth_ip_locked(client_ip: str, provider: str) -> bool:
97
+ """Check if IP is locked for OAuth attempts on specific provider"""
98
+ if not client_ip:
99
+ return False
100
+
101
+ try:
102
+ redis = await get_redis()
103
+ lock_key = f"oauth_ip_locked:{client_ip}:{provider}"
104
+ locked = await redis.get(lock_key)
105
+ return locked is not None
106
+ except Exception as e:
107
+ logger.error(f"Error checking OAuth IP lock: {str(e)}", exc_info=True)
108
+ return False
109
+
110
+ @staticmethod
111
+ async def clear_oauth_failed_attempts(client_ip: str, provider: str):
112
+ """Clear failed OAuth attempts on successful verification"""
113
+ if not client_ip:
114
+ return
115
+
116
+ try:
117
+ redis = await get_redis()
118
+ failed_key = f"oauth_failed:{client_ip}:{provider}"
119
+ await redis.delete(failed_key)
120
+ logger.info(f"Cleared OAuth failed attempts for {client_ip}:{provider}")
121
+ except Exception as e:
122
+ logger.error(f"Error clearing OAuth failed attempts: {str(e)}", exc_info=True)
123
+
124
+ @staticmethod
125
+ async def validate_oauth_token_format(token: str, provider: str) -> bool:
126
+ """Basic validation of OAuth token format"""
127
+ # In local test mode, accept any non-empty string to facilitate testing
128
+ try:
129
+ from app.core.config import settings
130
+ if getattr(settings, "OAUTH_TEST_MODE", False):
131
+ return bool(token)
132
+ except Exception:
133
+ pass
134
+
135
+ if not token or not isinstance(token, str):
136
+ return False
137
+
138
+ # Basic length and format checks
139
+ if provider == "google":
140
+ # Normalize optional Bearer prefix
141
+ t = token.strip()
142
+ if t.lower().startswith("bearer "):
143
+ t = t[7:]
144
+ # Accept Google ID tokens (JWT)
145
+ if len(t) > 100 and t.count('.') == 2:
146
+ return True
147
+ # Accept Google OAuth access tokens (commonly start with 'ya29.') and are shorter
148
+ if t.startswith("ya29.") or (len(t) >= 20 and len(t) <= 4096 and t.count('.') < 2):
149
+ return True
150
+ return False
151
+ elif provider == "apple":
152
+ # Apple ID tokens are also JWT format
153
+ return len(token) > 100 and token.count('.') == 2
154
+ elif provider == "facebook":
155
+ # Facebook access tokens are typically shorter
156
+ return len(token) > 20 and len(token) < 500
157
+
158
+ return True # Allow unknown providers
159
+
160
+ @staticmethod
161
+ async def log_oauth_attempt(client_ip: str, provider: str, success: bool, customer_id: str = None):
162
+ """Log OAuth authentication attempts for security monitoring"""
163
+ try:
164
+ redis = await get_redis()
165
+ log_key = f"oauth_log:{datetime.utcnow().strftime('%Y-%m-%d')}"
166
+
167
+ log_entry = {
168
+ "timestamp": datetime.utcnow().isoformat(),
169
+ "ip": client_ip,
170
+ "provider": provider,
171
+ "success": success,
172
+ "customer_id": customer_id
173
+ }
174
+
175
+ # Store as JSON string in Redis list
176
+ import json
177
+ await redis.lpush(log_key, json.dumps(log_entry))
178
+
179
+ # Keep only last 1000 entries per day
180
+ await redis.ltrim(log_key, 0, 999)
181
+
182
+ # Set expiry for 30 days
183
+ await redis.expire(log_key, 30 * 24 * 3600)
184
+
185
+ logger.info(f"OAuth attempt logged: {provider} from {client_ip} - {'success' if success else 'failed'}")
186
+
187
+ except Exception as e:
188
+ logger.error(f"Error logging OAuth attempt: {str(e)}", exc_info=True)
app/models/user_model.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException
2
+ from app.core.nosql_client import db
3
+ from app.utils.common_utils import is_email, is_phone, validate_identifier # Updated imports
4
+ from app.schemas.user_schema import UserRegisterRequest
5
+ import logging
6
+
7
+ logger = logging.getLogger("user_model")
8
+
9
+ class BookMyServiceUserModel:
10
+ collection = db["customers"]
11
+
12
+ @staticmethod
13
+ async def find_by_email(email: str):
14
+ logger.info(f"Searching for user by email: {email}")
15
+ try:
16
+ user = await BookMyServiceUserModel.collection.find_one({"email": email})
17
+ if user:
18
+ logger.info(f"User found by email: {email}")
19
+ else:
20
+ logger.info(f"No user found with email: {email}")
21
+ return user
22
+ except Exception as e:
23
+ logger.error(f"Error finding user by email {email}: {str(e)}", exc_info=True)
24
+ return None
25
+
26
+ @staticmethod
27
+ async def find_by_phone(phone: str):
28
+ logger.info(f"Searching for user by phone: {phone}")
29
+ try:
30
+ user = await BookMyServiceUserModel.collection.find_one({"phone": phone})
31
+ if user:
32
+ logger.info(f"User found by phone: {phone}")
33
+ else:
34
+ logger.info(f"No user found with phone: {phone}")
35
+ return user
36
+ except Exception as e:
37
+ logger.error(f"Error finding user by phone {phone}: {str(e)}", exc_info=True)
38
+ return None
39
+
40
+ @staticmethod
41
+ async def find_by_mobile(mobile: str):
42
+ """Legacy method for backward compatibility - redirects to find_by_phone"""
43
+ logger.info(f"Legacy find_by_mobile called, redirecting to find_by_phone for: {mobile}")
44
+ return await BookMyServiceUserModel.find_by_phone(mobile)
45
+
46
+ @staticmethod
47
+ async def find_by_identifier(identifier: str):
48
+ logger.info(f"Finding user by identifier: {identifier}")
49
+
50
+ try:
51
+ # Validate and determine identifier type
52
+ identifier_type = validate_identifier(identifier)
53
+ logger.info(f"Identifier type determined: {identifier_type}")
54
+
55
+ if identifier_type == "email":
56
+ logger.info(f"Searching by email for identifier: {identifier}")
57
+ user = await BookMyServiceUserModel.find_by_email(identifier)
58
+ elif identifier_type == "phone":
59
+ logger.info(f"Searching by phone for identifier: {identifier}")
60
+ user = await BookMyServiceUserModel.find_by_phone(identifier)
61
+ else:
62
+ logger.error(f"Invalid identifier type: {identifier_type}")
63
+ raise HTTPException(status_code=400, detail="Invalid identifier format")
64
+
65
+ if not user:
66
+ logger.warning(f"User not found with identifier: {identifier}")
67
+ raise HTTPException(status_code=404, detail="User not found with this email or phone")
68
+
69
+ logger.info(f"User found successfully for identifier: {identifier}")
70
+ logger.info(f"User data keys: {list(user.keys()) if user else 'None'}")
71
+ return user
72
+
73
+ except ValueError as ve:
74
+ logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
75
+ raise HTTPException(status_code=400, detail=str(ve))
76
+ except HTTPException as e:
77
+ logger.error(f"HTTP error finding user by identifier {identifier}: {e.status_code} - {e.detail}")
78
+ raise e
79
+ except Exception as e:
80
+ logger.error(f"Unexpected error finding user by identifier {identifier}: {str(e)}", exc_info=True)
81
+ raise HTTPException(status_code=500, detail="Failed to find user")
82
+
83
+ @staticmethod
84
+ async def exists_by_email_or_phone(email: str = None, phone: str = None) -> bool:
85
+ """Check if user exists by email or phone"""
86
+ query_conditions = []
87
+
88
+ if email:
89
+ query_conditions.append({"email": email})
90
+ if phone:
91
+ query_conditions.append({"phone": phone})
92
+
93
+ if not query_conditions:
94
+ return False
95
+
96
+ query = {"$or": query_conditions} if len(query_conditions) > 1 else query_conditions[0]
97
+
98
+ result = await BookMyServiceUserModel.collection.find_one(query)
99
+ return result is not None
100
+
101
+ @staticmethod
102
+ async def create(user_data: UserRegisterRequest):
103
+ user_dict = user_data.dict()
104
+ result = await BookMyServiceUserModel.collection.insert_one(user_dict)
105
+ return result.inserted_id
106
+
107
+ @staticmethod
108
+ async def update_by_identifier(identifier: str, update_fields: dict):
109
+ try:
110
+ identifier_type = validate_identifier(identifier)
111
+
112
+ if identifier_type == "email":
113
+ query = {"email": identifier}
114
+ elif identifier_type == "phone":
115
+ query = {"phone": identifier}
116
+ else:
117
+ raise HTTPException(status_code=400, detail="Invalid identifier format")
118
+
119
+ result = await BookMyServiceUserModel.collection.update_one(query, {"$set": update_fields})
120
+ if result.matched_count == 0:
121
+ raise HTTPException(status_code=404, detail="User not found")
122
+ return result.modified_count > 0
123
+
124
+ except ValueError as ve:
125
+ logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
126
+ raise HTTPException(status_code=400, detail=str(ve))
127
+
128
+ @staticmethod
129
+ async def update_profile(customer_id: str, update_fields: dict):
130
+ """Update user profile by customer_id"""
131
+ try:
132
+ from datetime import datetime
133
+
134
+ # Add updated_at timestamp
135
+ update_fields["updated_at"] = datetime.utcnow()
136
+
137
+ result = await BookMyServiceUserModel.collection.update_one(
138
+ {"customer_id": customer_id},
139
+ {"$set": update_fields}
140
+ )
141
+
142
+ if result.matched_count == 0:
143
+ raise HTTPException(status_code=404, detail="User not found")
144
+
145
+ return result.modified_count > 0
146
+
147
+ except Exception as e:
148
+ logger.error(f"Error updating profile for user {customer_id}: {str(e)}")
149
+ raise HTTPException(status_code=500, detail="Failed to update profile")
150
+
151
+ @staticmethod
152
+ async def find_by_id(customer_id: str):
153
+ """Find user by customer_id"""
154
+ try:
155
+ user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
156
+ return user
157
+ except Exception as e:
158
+ logger.error(f"Error finding user by ID {customer_id}: {str(e)}")
159
+ return None
app/models/wallet_model.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import Optional, List, Dict, Any
3
+ from bson import ObjectId
4
+ import logging
5
+
6
+ from app.core.nosql_client import db
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+ class WalletModel:
11
+ """Model for managing user wallet operations"""
12
+
13
+ wallet_collection = db["user_wallets"]
14
+ transaction_collection = db["wallet_transactions"]
15
+
16
+ @staticmethod
17
+ async def get_wallet_balance(customer_id: str) -> float:
18
+ """Get current wallet balance for a user"""
19
+ try:
20
+ wallet = await WalletModel.wallet_collection.find_one({"customer_id": customer_id})
21
+ if wallet:
22
+ return wallet.get("balance", 0.0)
23
+ else:
24
+ # Create wallet if doesn't exist
25
+ await WalletModel.create_wallet(customer_id)
26
+ return 0.0
27
+ except Exception as e:
28
+ logger.error(f"Error getting wallet balance for user {customer_id}: {str(e)}")
29
+ return 0.0
30
+
31
+ @staticmethod
32
+ async def create_wallet(customer_id: str, initial_balance: float = 0.0) -> bool:
33
+ """Create a new wallet for a user"""
34
+ try:
35
+ wallet_doc = {
36
+ "customer_id": customer_id,
37
+ "balance": initial_balance,
38
+ "created_at": datetime.utcnow(),
39
+ "updated_at": datetime.utcnow()
40
+ }
41
+
42
+ result = await WalletModel.wallet_collection.insert_one(wallet_doc)
43
+ logger.info(f"Created wallet for user {customer_id} with balance {initial_balance}")
44
+ return result.inserted_id is not None
45
+ except Exception as e:
46
+ logger.error(f"Error creating wallet for user {customer_id}: {str(e)}")
47
+ return False
48
+
49
+ @staticmethod
50
+ async def update_balance(customer_id: str, amount: float, transaction_type: str,
51
+ description: str = "", reference_id: str = None) -> bool:
52
+ """Update wallet balance and create transaction record"""
53
+ try:
54
+ # Get current balance
55
+ current_balance = await WalletModel.get_wallet_balance(customer_id)
56
+
57
+ # Calculate new balance
58
+ if transaction_type in ["credit", "refund", "cashback"]:
59
+ new_balance = current_balance + amount
60
+ elif transaction_type in ["debit", "payment", "withdrawal"]:
61
+ if current_balance < amount:
62
+ logger.warning(f"Insufficient balance for user {customer_id}. Current: {current_balance}, Required: {amount}")
63
+ return False
64
+ new_balance = current_balance - amount
65
+ else:
66
+ logger.error(f"Invalid transaction type: {transaction_type}")
67
+ return False
68
+
69
+ # Update wallet balance
70
+ update_result = await WalletModel.wallet_collection.update_one(
71
+ {"customer_id": customer_id},
72
+ {
73
+ "$set": {
74
+ "balance": new_balance,
75
+ "updated_at": datetime.utcnow()
76
+ }
77
+ },
78
+ upsert=True
79
+ )
80
+
81
+ # Create transaction record
82
+ transaction_doc = {
83
+ "customer_id": customer_id,
84
+ "amount": amount,
85
+ "transaction_type": transaction_type,
86
+ "description": description,
87
+ "reference_id": reference_id,
88
+ "balance_before": current_balance,
89
+ "balance_after": new_balance,
90
+ "timestamp": datetime.utcnow(),
91
+ "status": "completed"
92
+ }
93
+
94
+ await WalletModel.transaction_collection.insert_one(transaction_doc)
95
+
96
+ logger.info(f"Updated wallet for user {customer_id}: {transaction_type} of {amount}, new balance: {new_balance}")
97
+ return True
98
+
99
+ except Exception as e:
100
+ logger.error(f"Error updating wallet balance for user {customer_id}: {str(e)}")
101
+ return False
102
+
103
+ @staticmethod
104
+ async def get_transaction_history(customer_id: str, page: int = 1, per_page: int = 20) -> Dict[str, Any]:
105
+ """Get paginated transaction history for a user"""
106
+ try:
107
+ skip = (page - 1) * per_page
108
+
109
+ # Get transactions with pagination
110
+ cursor = WalletModel.transaction_collection.find(
111
+ {"customer_id": customer_id}
112
+ ).sort("timestamp", -1).skip(skip).limit(per_page)
113
+
114
+ transactions = []
115
+ async for transaction in cursor:
116
+ # Convert ObjectId to string for JSON serialization
117
+ transaction["_id"] = str(transaction["_id"])
118
+ transactions.append(transaction)
119
+
120
+ # Get total count
121
+ total_count = await WalletModel.transaction_collection.count_documents({"customer_id": customer_id})
122
+
123
+ return {
124
+ "transactions": transactions,
125
+ "total_count": total_count,
126
+ "page": page,
127
+ "per_page": per_page,
128
+ "total_pages": (total_count + per_page - 1) // per_page
129
+ }
130
+
131
+ except Exception as e:
132
+ logger.error(f"Error getting transaction history for user {customer_id}: {str(e)}")
133
+ return {
134
+ "transactions": [],
135
+ "total_count": 0,
136
+ "page": page,
137
+ "per_page": per_page,
138
+ "total_pages": 0
139
+ }
140
+
141
+ @staticmethod
142
+ async def get_wallet_summary(customer_id: str) -> Dict[str, Any]:
143
+ """Get wallet summary including balance and recent transactions"""
144
+ try:
145
+ balance = await WalletModel.get_wallet_balance(customer_id)
146
+ recent_transactions = await WalletModel.get_transaction_history(customer_id, page=1, per_page=5)
147
+
148
+ return {
149
+ "balance": balance,
150
+ "recent_transactions": recent_transactions["transactions"]
151
+ }
152
+
153
+ except Exception as e:
154
+ logger.error(f"Error getting wallet summary for user {customer_id}: {str(e)}")
155
+ return {
156
+ "balance": 0.0,
157
+ "recent_transactions": []
158
+ }
app/routers/__init__.py CHANGED
@@ -1,2 +1,11 @@
1
- __all__ = ["user"]
2
-
 
 
 
 
 
 
 
 
 
 
1
+ __all__ = [
2
+ "user_router",
3
+ "profile_router",
4
+ "account_router",
5
+ "wallet_router",
6
+ "address_router",
7
+ "pet_router",
8
+ "guest_router",
9
+ "favorite_router",
10
+ "review_router"
11
+ ]
app/routers/account_router.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Request, Query
2
+ from fastapi.security import HTTPBearer
3
+ from typing import List, Optional
4
+ from datetime import datetime, timedelta
5
+ import logging
6
+
7
+ from app.schemas.user_schema import (
8
+ LinkSocialAccountRequest, UnlinkSocialAccountRequest,
9
+ SocialAccountSummary, LoginHistoryResponse, SecuritySettingsResponse,
10
+ TokenResponse
11
+ )
12
+ from app.services.account_service import AccountService
13
+ from app.utils.jwt import decode_token
14
+
15
+ # Configure logging
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter()
19
+ security = HTTPBearer()
20
+
21
+ def get_current_user(token: str = Depends(security)):
22
+ """Extract user ID from JWT token"""
23
+ try:
24
+ payload = decode_token(token.credentials)
25
+ customer_id = payload.get("sub")
26
+ if not customer_id:
27
+ raise HTTPException(status_code=401, detail="Invalid token")
28
+ return customer_id
29
+ except Exception as e:
30
+ logger.error(f"Token validation error: {str(e)}")
31
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
32
+
33
+ def get_client_ip(request: Request) -> str:
34
+ """Extract client IP from request"""
35
+ forwarded_for = request.headers.get("X-Forwarded-For")
36
+ if forwarded_for:
37
+ return forwarded_for.split(",")[0].strip()
38
+
39
+ real_ip = request.headers.get("X-Real-IP")
40
+ if real_ip:
41
+ return real_ip
42
+
43
+ return request.client.host if request.client else "unknown"
44
+
45
+ @router.get("/social-accounts", response_model=SocialAccountSummary)
46
+ async def get_social_accounts(customer_id: str = Depends(get_current_user)):
47
+ """Get all linked social accounts for the current user"""
48
+ try:
49
+ account_service = AccountService()
50
+ summary = await account_service.get_social_account_summary(customer_id)
51
+ return summary
52
+ except Exception as e:
53
+ logger.error(f"Error fetching social accounts for user {customer_id}: {str(e)}")
54
+ raise HTTPException(status_code=500, detail="Failed to fetch social accounts")
55
+
56
+ @router.post("/link-social-account", response_model=dict)
57
+ async def link_social_account(
58
+ request: LinkSocialAccountRequest,
59
+ req: Request,
60
+ customer_id: str = Depends(get_current_user)
61
+ ):
62
+ """Link a new social account to the current user"""
63
+ try:
64
+ client_ip = get_client_ip(req)
65
+ account_service = AccountService()
66
+
67
+ result = await account_service.link_social_account(
68
+ customer_id=customer_id,
69
+ provider=request.provider,
70
+ token=request.token,
71
+ client_ip=client_ip
72
+ )
73
+
74
+ return {"message": f"Successfully linked {request.provider} account", "result": result}
75
+ except ValueError as e:
76
+ logger.warning(f"Invalid link request for user {customer_id}: {str(e)}")
77
+ raise HTTPException(status_code=400, detail=str(e))
78
+ except Exception as e:
79
+ logger.error(f"Error linking social account for user {customer_id}: {str(e)}")
80
+ raise HTTPException(status_code=500, detail="Failed to link social account")
81
+
82
+ @router.delete("/unlink-social-account", response_model=dict)
83
+ async def unlink_social_account(
84
+ request: UnlinkSocialAccountRequest,
85
+ customer_id: str = Depends(get_current_user)
86
+ ):
87
+ """Unlink a social account from the current user"""
88
+ try:
89
+ account_service = AccountService()
90
+
91
+ result = await account_service.unlink_social_account(
92
+ customer_id=customer_id,
93
+ provider=request.provider
94
+ )
95
+
96
+ return {"message": f"Successfully unlinked {request.provider} account", "result": result}
97
+ except ValueError as e:
98
+ logger.warning(f"Invalid unlink request for user {customer_id}: {str(e)}")
99
+ raise HTTPException(status_code=400, detail=str(e))
100
+ except Exception as e:
101
+ logger.error(f"Error unlinking social account for user {customer_id}: {str(e)}")
102
+ raise HTTPException(status_code=500, detail="Failed to unlink social account")
103
+
104
+ @router.get("/login-history", response_model=LoginHistoryResponse)
105
+ async def get_login_history(
106
+ page: int = Query(1, ge=1, description="Page number"),
107
+ per_page: int = Query(10, ge=1, le=50, description="Items per page"),
108
+ days: int = Query(30, ge=1, le=365, description="Number of days to look back"),
109
+ customer_id: str = Depends(get_current_user)
110
+ ):
111
+ """Get login history for the current user"""
112
+ try:
113
+ account_service = AccountService()
114
+
115
+ history = await account_service.get_login_history(
116
+ customer_id=customer_id,
117
+ page=page,
118
+ per_page=per_page,
119
+ days=days
120
+ )
121
+
122
+ return history
123
+ except Exception as e:
124
+ logger.error(f"Error fetching login history for user {customer_id}: {str(e)}")
125
+ raise HTTPException(status_code=500, detail="Failed to fetch login history")
126
+
127
+ @router.get("/security-settings", response_model=SecuritySettingsResponse)
128
+ async def get_security_settings(customer_id: str = Depends(get_current_user)):
129
+ """Get security settings and status for the current user"""
130
+ try:
131
+ account_service = AccountService()
132
+
133
+ settings = await account_service.get_security_settings(customer_id)
134
+
135
+ return settings
136
+ except Exception as e:
137
+ logger.error(f"Error fetching security settings for user {customer_id}: {str(e)}")
138
+ raise HTTPException(status_code=500, detail="Failed to fetch security settings")
139
+
140
+ @router.post("/merge-accounts", response_model=dict)
141
+ async def merge_social_accounts(
142
+ target_customer_id: str,
143
+ req: Request,
144
+ customer_id: str = Depends(get_current_user)
145
+ ):
146
+ """Merge social accounts from another user (admin function or user-initiated)"""
147
+ try:
148
+ # For security, only allow users to merge their own accounts or implement admin check
149
+ if customer_id != target_customer_id:
150
+ # In a real implementation, you'd check if the current user is an admin
151
+ # or if they have proper authorization to merge accounts
152
+ raise HTTPException(status_code=403, detail="Insufficient permissions")
153
+
154
+ client_ip = get_client_ip(req)
155
+ account_service = AccountService()
156
+
157
+ result = await account_service.merge_social_accounts(
158
+ primary_customer_id=customer_id,
159
+ secondary_customer_id=target_customer_id,
160
+ client_ip=client_ip
161
+ )
162
+
163
+ return {"message": "Successfully merged social accounts", "result": result}
164
+ except ValueError as e:
165
+ logger.warning(f"Invalid merge request for user {customer_id}: {str(e)}")
166
+ raise HTTPException(status_code=400, detail=str(e))
167
+ except Exception as e:
168
+ logger.error(f"Error merging social accounts for user {customer_id}: {str(e)}")
169
+ raise HTTPException(status_code=500, detail="Failed to merge social accounts")
170
+
171
+ @router.delete("/revoke-all-sessions", response_model=dict)
172
+ async def revoke_all_sessions(
173
+ req: Request,
174
+ customer_id: str = Depends(get_current_user)
175
+ ):
176
+ """Revoke all active sessions for security purposes"""
177
+ try:
178
+ client_ip = get_client_ip(req)
179
+ account_service = AccountService()
180
+
181
+ result = await account_service.revoke_all_sessions(customer_id, client_ip)
182
+
183
+ return {"message": "All sessions have been revoked", "result": result}
184
+ except Exception as e:
185
+ logger.error(f"Error revoking sessions for user {customer_id}: {str(e)}")
186
+ raise HTTPException(status_code=500, detail="Failed to revoke sessions")
187
+
188
+ @router.get("/trusted-devices", response_model=dict)
189
+ async def get_trusted_devices(customer_id: str = Depends(get_current_user)):
190
+ """Get list of trusted devices for the current user"""
191
+ try:
192
+ account_service = AccountService()
193
+
194
+ devices = await account_service.get_trusted_devices(customer_id)
195
+
196
+ return {"devices": devices}
197
+ except Exception as e:
198
+ logger.error(f"Error fetching trusted devices for user {customer_id}: {str(e)}")
199
+ raise HTTPException(status_code=500, detail="Failed to fetch trusted devices")
200
+
201
+ @router.delete("/trusted-devices/{device_id}", response_model=dict)
202
+ async def remove_trusted_device(
203
+ device_id: str,
204
+ customer_id: str = Depends(get_current_user)
205
+ ):
206
+ """Remove a trusted device"""
207
+ try:
208
+ account_service = AccountService()
209
+
210
+ result = await account_service.remove_trusted_device(customer_id, device_id)
211
+
212
+ return {"message": "Trusted device removed successfully", "result": result}
213
+ except ValueError as e:
214
+ logger.warning(f"Invalid device removal request for user {customer_id}: {str(e)}")
215
+ raise HTTPException(status_code=400, detail=str(e))
216
+ except Exception as e:
217
+ logger.error(f"Error removing trusted device for user {customer_id}: {str(e)}")
218
+ raise HTTPException(status_code=500, detail="Failed to remove trusted device")
app/routers/address_router.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from typing import List
3
+ import logging
4
+
5
+ from app.utils.jwt import get_current_customer_id
6
+ from app.models.address_model import AddressModel
7
+ from app.schemas.address_schema import (
8
+ AddressCreateRequest, AddressUpdateRequest, AddressResponse,
9
+ AddressListResponse, SetDefaultAddressRequest, AddressOperationResponse
10
+ )
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ router = APIRouter()
15
+
16
+ @router.get("/", response_model=AddressListResponse)
17
+ async def get_user_addresses(current_customer_id: str = Depends(get_current_customer_id)):
18
+ """
19
+ Get all delivery addresses for the current user.
20
+
21
+ This endpoint is JWT protected and requires a valid Bearer token.
22
+ """
23
+ try:
24
+ logger.info(f"Get addresses request for user: {current_customer_id}")
25
+
26
+ addresses = await AddressModel.get_user_addresses(current_customer_id)
27
+
28
+ address_responses = []
29
+ for addr in addresses:
30
+ address_responses.append(AddressResponse(
31
+ address_id=addr["address_id"], # Use the new address_id field
32
+ address_type=addr["address_type"],
33
+ address_line_1=addr["address_line_1"],
34
+ address_line_2=addr.get("address_line_2", ""),
35
+ city=addr["city"],
36
+ state=addr["state"],
37
+ postal_code=addr["postal_code"],
38
+ country=addr.get("country", "India"),
39
+ landmark=addr.get("landmark", ""),
40
+ is_default=addr.get("is_default", False),
41
+ created_at=addr.get("created_at"),
42
+ updated_at=addr.get("updated_at")
43
+ ))
44
+
45
+ if not address_responses:
46
+ return AddressListResponse(
47
+ success=False,
48
+ message="Failed to retrieve address"
49
+ )
50
+
51
+ else:
52
+ return AddressListResponse(
53
+ success=True,
54
+ message="Addresses retrieved successfully",
55
+ addresses=address_responses,
56
+ total_count=len(address_responses)
57
+ )
58
+
59
+
60
+ except Exception as e:
61
+ logger.error(f"Error getting addresses for user {current_customer_id}: {str(e)}")
62
+ raise HTTPException(
63
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
64
+ detail="Failed to retrieve addresses"
65
+ )
66
+
67
+ @router.post("/", response_model=AddressOperationResponse)
68
+ async def create_address(
69
+ address_data: AddressCreateRequest,
70
+ current_customer_id: str = Depends(get_current_customer_id)
71
+ ):
72
+ """
73
+ Create a new delivery address for the current user.
74
+ """
75
+ try:
76
+ logger.info(f"Create address request for user: {current_customer_id}")
77
+
78
+ # Check if user already has 5 addresses (limit)
79
+ existing_addresses = await AddressModel.get_user_addresses(current_customer_id)
80
+ if len(existing_addresses) >= 5:
81
+ raise HTTPException(
82
+ status_code=400,
83
+ detail="Maximum of 5 addresses allowed per user"
84
+ )
85
+
86
+ # If this is the first address, make it default
87
+ is_default = len(existing_addresses) == 0 or address_data.is_default
88
+
89
+ address_id = await AddressModel.create_address(current_customer_id, address_data.dict())
90
+
91
+
92
+ if address_id:
93
+ # Get the created address
94
+ created_address = await AddressModel.get_address_by_id(current_customer_id,address_id)
95
+
96
+ address_response = AddressResponse(
97
+ address_id=created_address["address_id"], # Use the new address_id field
98
+ address_type=created_address["address_type"],
99
+ address_line_1=created_address["address_line_1"],
100
+ address_line_2=created_address.get("address_line_2", ""),
101
+ city=created_address["city"],
102
+ state=created_address["state"],
103
+ postal_code=created_address["postal_code"],
104
+ country=created_address.get("country", "India"),
105
+ is_default=created_address.get("is_default", False),
106
+ landmark=created_address.get("landmark", ""),
107
+ created_at=created_address.get("created_at"),
108
+ updated_at=created_address.get("updated_at")
109
+ )
110
+ return AddressOperationResponse(
111
+ success=True,
112
+ message="Address created successfully",
113
+ address=address_response
114
+ )
115
+ else:
116
+ return AddressOperationResponse(
117
+ success=False,
118
+ message="Failed to create address"
119
+ )
120
+
121
+ except HTTPException:
122
+ raise
123
+ except Exception as e:
124
+ logger.error(f"Error creating address for user {current_customer_id}: {str(e)}")
125
+ raise HTTPException(
126
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
127
+ detail="Failed to create address"
128
+ )
129
+
130
+ @router.put("/{address_id}", response_model=AddressOperationResponse)
131
+ async def update_address(
132
+ address_id: str,
133
+ address_data: AddressUpdateRequest,
134
+ current_customer_id: str = Depends(get_current_customer_id)
135
+ ):
136
+ """
137
+ Update an existing delivery address.
138
+ """
139
+ try:
140
+ logger.info(f"Update address request for user: {current_customer_id}, address: {address_id}")
141
+
142
+ # Check if address exists and belongs to user
143
+ existing_address = await AddressModel.get_address_by_id(current_customer_id,address_id)
144
+ if not existing_address:
145
+ raise HTTPException(status_code=404, detail="Address not found")
146
+
147
+ if existing_address["customer_id"] != current_customer_id:
148
+ raise HTTPException(status_code=403, detail="Access denied")
149
+
150
+ # Prepare update fields
151
+ update_fields = {}
152
+
153
+ if address_data.address_type is not None:
154
+ update_fields["address_type"] = address_data.address_type
155
+ if address_data.address_line_1 is not None:
156
+ update_fields["address_line_1"] = address_data.address_line_1
157
+ if address_data.address_line_2 is not None:
158
+ update_fields["address_line_2"] = address_data.address_line_2
159
+ if address_data.city is not None:
160
+ update_fields["city"] = address_data.city
161
+ if address_data.state is not None:
162
+ update_fields["state"] = address_data.state
163
+ if address_data.is_default is not None:
164
+ update_fields['is_default']=address_data.is_default
165
+ if address_data.landmark is not None:
166
+ update_fields["landmark"] = address_data.landmark
167
+ if address_data.postal_code is not None:
168
+ update_fields["postal_code"] = address_data.postal_code
169
+ if address_data.country is not None:
170
+ update_fields["country"] = address_data.country
171
+ if not update_fields:
172
+ raise HTTPException(status_code=400, detail="No fields to update")
173
+
174
+ success = await AddressModel.update_address(current_customer_id,address_id, update_fields)
175
+
176
+ if success:
177
+ # Get updated address
178
+ updated_address = await AddressModel.get_address_by_id(current_customer_id,address_id)
179
+
180
+ address_response = AddressResponse(
181
+ address_id=updated_address["address_id"], # Use the new address_id field
182
+ address_type=updated_address["address_type"],
183
+ address_line_1=updated_address["address_line_1"],
184
+ address_line_2=updated_address.get("address_line_2", ""),
185
+ city=updated_address["city"],
186
+ state=updated_address["state"],
187
+ postal_code=updated_address["postal_code"],
188
+ country=updated_address.get("country", "India"),
189
+ landmark=updated_address.get("landmark", ""),
190
+ is_default=updated_address.get("is_default", False),
191
+ created_at=updated_address.get("created_at"),
192
+ updated_at=updated_address.get("updated_at")
193
+ )
194
+
195
+ return AddressOperationResponse(
196
+ success=True,
197
+ message="Address updated successfully",
198
+ address=address_response
199
+ )
200
+ else:
201
+ return AddressOperationResponse(
202
+ success=False,
203
+ message="Failed to update address"
204
+ )
205
+
206
+ except HTTPException:
207
+ raise
208
+ except Exception as e:
209
+ logger.error(f"Error updating address {address_id} for user {current_customer_id}: {str(e)}")
210
+ raise HTTPException(
211
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
212
+ detail="Failed to update address"
213
+ )
214
+
215
+ @router.delete("/{address_id}", response_model=AddressOperationResponse)
216
+ async def delete_address(
217
+ address_id: str,
218
+ current_customer_id: str = Depends(get_current_customer_id)
219
+ ):
220
+ """
221
+ Delete a delivery address.
222
+ """
223
+ try:
224
+ logger.info(f"Delete address request for user: {current_customer_id}, address: {address_id}")
225
+
226
+ # Check if address exists and belongs to user
227
+ existing_address = await AddressModel.get_address_by_id(current_customer_id,address_id)
228
+ if not existing_address:
229
+ raise HTTPException(status_code=404, detail="Address not found")
230
+
231
+ if existing_address["customer_id"] != current_customer_id:
232
+ raise HTTPException(status_code=403, detail="Access denied")
233
+
234
+ # Check if this is the default address
235
+ if existing_address.get("is_default", False):
236
+ # Get other addresses to potentially set a new default
237
+ user_addresses = await AddressModel.get_user_addresses(current_customer_id)
238
+ # Compare by new domain id field 'address_id'
239
+ other_addresses = [addr for addr in user_addresses if addr.get("address_id") != address_id]
240
+
241
+ if other_addresses:
242
+ # Set the first other address as default
243
+ await AddressModel.set_default_address(current_customer_id, other_addresses[0]["address_id"])
244
+
245
+ success = await AddressModel.delete_address(current_customer_id,address_id)
246
+
247
+ if success:
248
+ return AddressOperationResponse(
249
+ success=True,
250
+ message="Address deleted successfully"
251
+ )
252
+ else:
253
+ return AddressOperationResponse(
254
+ success=False,
255
+ message="Failed to delete address"
256
+ )
257
+
258
+ except HTTPException:
259
+ raise
260
+ except Exception as e:
261
+ logger.error(f"Error deleting address {address_id} for user {current_customer_id}: {str(e)}")
262
+ raise HTTPException(
263
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
264
+ detail="Failed to delete address"
265
+ )
266
+
267
+ @router.post("/set-default", response_model=AddressOperationResponse)
268
+ async def set_default_address(
269
+ request: SetDefaultAddressRequest,
270
+ current_customer_id: str = Depends(get_current_customer_id)
271
+ ):
272
+ """
273
+ Set an address as the default delivery address.
274
+ """
275
+ try:
276
+ logger.info(f"Set default address request for user: {current_customer_id}, address: {request.address_id}")
277
+
278
+ # Check if address exists and belongs to user
279
+ existing_address = await AddressModel.get_address_by_id(current_customer_id,request.address_id)
280
+ if not existing_address:
281
+ raise HTTPException(status_code=404, detail="Address not found")
282
+
283
+ if existing_address["customer_id"] != current_customer_id:
284
+ raise HTTPException(status_code=403, detail="Access denied")
285
+
286
+ success = await AddressModel.set_default_address(current_customer_id, request.address_id)
287
+
288
+ if success:
289
+ # Get updated address
290
+ updated_address = await AddressModel.get_address_by_id(current_customer_id,request.address_id)
291
+
292
+ address_response = AddressResponse(
293
+ address_id=request.address_id,
294
+ address_type=updated_address["address_type"],
295
+ contact_name=updated_address.get("contact_name", ""),
296
+ contact_phone=updated_address.get("contact_phone", ""),
297
+ address_line_1=updated_address["address_line_1"],
298
+ address_line_2=updated_address.get("address_line_2", ""),
299
+ city=updated_address["city"],
300
+ state=updated_address["state"],
301
+ postal_code=updated_address["postal_code"],
302
+ country=updated_address.get("country", "India"),
303
+ landmark=updated_address.get("landmark", ""),
304
+ is_default=updated_address.get("is_default", False),
305
+ created_at=updated_address.get("created_at"),
306
+ updated_at=updated_address.get("updated_at")
307
+ )
308
+
309
+ return AddressOperationResponse(
310
+ success=True,
311
+ message="Default address set successfully",
312
+ address=address_response
313
+ )
314
+ else:
315
+ return AddressOperationResponse(
316
+ success=False,
317
+ message="Failed to set default address"
318
+ )
319
+
320
+ except HTTPException:
321
+ raise
322
+ except Exception as e:
323
+ logger.error(f"Error setting default address for user {current_customer_id}: {str(e)}")
324
+ raise HTTPException(
325
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
326
+ detail="Failed to set default address"
327
+ )
app/routers/favorite_router.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query, status
2
+ from typing import Optional
3
+ from app.schemas.favorite_schema import (
4
+ FavoriteCreateRequest,
5
+ FavoriteUpdateRequest,
6
+ FavoriteResponse,
7
+ FavoritesListResponse,
8
+ FavoriteStatusResponse,
9
+ FavoriteSuccessResponse,
10
+ FavoriteDataResponse
11
+ )
12
+ from app.services.favorite_service import FavoriteService
13
+ from app.services.user_service import UserService
14
+ from app.utils.jwt import get_current_user
15
+ import logging
16
+
17
+ logger = logging.getLogger("favorite_router")
18
+
19
+ router = APIRouter(
20
+ prefix="/favorites",
21
+ tags=["favorites"],
22
+ responses={404: {"description": "Not found"}},
23
+ )
24
+
25
+ @router.post(
26
+ "",
27
+ response_model=FavoriteSuccessResponse,
28
+ status_code=status.HTTP_201_CREATED,
29
+ summary="Add merchant to favorites",
30
+ description="Add a merchant to the user's list of favorite merchants"
31
+ )
32
+ async def add_favorite(
33
+ favorite_data: FavoriteCreateRequest,
34
+ current_user: dict = Depends(get_current_user)
35
+ ):
36
+ """
37
+ Add a merchant to user's favorites.
38
+
39
+ - **merchant_id**: ID of the merchant to favorite
40
+ - **merchant_category**: Category of the merchant (salon, spa, pet_spa, etc.)
41
+ - **merchant_name**: Name of the merchant for quick display
42
+ - **notes**: Optional note about this favorite
43
+ """
44
+ try:
45
+ return await FavoriteService.add_favorite(
46
+ customer_id=current_user["sub"],
47
+ favorite_data=favorite_data
48
+ )
49
+ except HTTPException as e:
50
+ raise e
51
+ except Exception as e:
52
+ logger.error(f"Error in add_favorite endpoint: {str(e)}", exc_info=True)
53
+ raise HTTPException(status_code=500, detail="Internal server error")
54
+
55
+ @router.delete(
56
+ "/{merchant_id}",
57
+ response_model=FavoriteSuccessResponse,
58
+ status_code=status.HTTP_200_OK,
59
+ summary="Remove merchant from favorites",
60
+ description="Remove a merchant from the user's list of favorite merchants"
61
+ )
62
+ async def remove_favorite(
63
+ merchant_id: str,
64
+ current_user: dict = Depends(get_current_user)
65
+ ):
66
+ """
67
+ Remove a merchant from user's favorites.
68
+
69
+ - **merchant_id**: ID of the merchant to remove from favorites
70
+ """
71
+ try:
72
+
73
+ customer_id=current_user.get("sub")
74
+
75
+ # Check if favorite merchant already exists and belongs to user
76
+ existing_favorite = await FavoriteService.get_favorite_details(customer_id, merchant_id)
77
+
78
+ if not existing_favorite.success:
79
+ raise HTTPException(
80
+ status_code=status.HTTP_404_NOT_FOUND,
81
+ detail="favorite not found"
82
+ )
83
+
84
+ return await FavoriteService.remove_favorite(
85
+ customer_id,
86
+ merchant_id
87
+ )
88
+ except HTTPException as e:
89
+ raise e
90
+ except Exception as e:
91
+ logger.error(f"Error in remove_favorite endpoint: {str(e)}", exc_info=True)
92
+ raise HTTPException(status_code=500, detail="Internal server error")
93
+
94
+ @router.get(
95
+ "",
96
+ response_model=FavoritesListResponse,
97
+ status_code=status.HTTP_200_OK,
98
+ summary="List favorite merchants",
99
+ description="Get user's list of favorite merchants, optionally filtered by category"
100
+ )
101
+ async def list_favorites(
102
+ limit: int = Query(50, ge=1, le=100, description="Maximum number of items to return"),
103
+ current_user: dict = Depends(get_current_user)
104
+ ):
105
+ """
106
+ Get user's favorite merchants.
107
+
108
+ - **category**: Optional filter by merchant category
109
+ - **skip**: Number of items to skip (for pagination)
110
+ - **limit**: Maximum number of items to return (max 100)
111
+ """
112
+ try:
113
+ return await FavoriteService.get_favorites(
114
+ customer_id=current_user["sub"],
115
+ limit=limit
116
+ )
117
+ except HTTPException as e:
118
+ raise e
119
+ except Exception as e:
120
+ logger.error(f"Error in list_favorites endpoint: {str(e)}", exc_info=True)
121
+ raise HTTPException(status_code=500, detail="Internal server error")
122
+
123
+ @router.get(
124
+ "/{merchant_id}/status",
125
+ response_model=FavoriteStatusResponse,
126
+ status_code=status.HTTP_200_OK,
127
+ summary="Check favorite status",
128
+ description="Check if a merchant is in user's favorites"
129
+ )
130
+ async def check_favorite_status(
131
+ merchant_id: str,
132
+ current_user: dict = Depends(get_current_user)
133
+ ):
134
+ """
135
+ Check if a merchant is in user's favorites.
136
+
137
+ - **merchant_id**: ID of the merchant to check
138
+ """
139
+ try:
140
+ return await FavoriteService.check_favorite_status(
141
+ customer_id=current_user["sub"],
142
+ merchant_id=merchant_id
143
+ )
144
+ except HTTPException as e:
145
+ raise e
146
+ except Exception as e:
147
+ logger.error(f"Error in check_favorite_status endpoint: {str(e)}", exc_info=True)
148
+ raise HTTPException(status_code=500, detail="Internal server error")
149
+
150
+ @router.get(
151
+ "/{merchant_id}",
152
+ response_model=FavoriteDataResponse,
153
+ status_code=status.HTTP_200_OK,
154
+ summary="Get favorite details",
155
+ description="Get detailed information about a specific favorite merchant"
156
+ )
157
+ async def get_favorite_details(
158
+ merchant_id: str,
159
+ current_user: dict = Depends(get_current_user)
160
+ ):
161
+ """
162
+ Get detailed information about a specific favorite merchant.
163
+
164
+ - **merchant_id**: ID of the merchant
165
+ """
166
+ try:
167
+ return await FavoriteService.get_favorite_details(
168
+ customer_id=current_user["sub"],
169
+ merchant_id=merchant_id
170
+ )
171
+ except HTTPException as e:
172
+ raise e
173
+ except Exception as e:
174
+ logger.error(f"Error in get_favorite_details endpoint: {str(e)}", exc_info=True)
175
+ raise HTTPException(status_code=500, detail="Internal server error")
176
+
177
+ @router.patch(
178
+ "/{merchant_id}/notes",
179
+ response_model=FavoriteSuccessResponse,
180
+ status_code=status.HTTP_200_OK,
181
+ summary="Update favorite notes",
182
+ description="Update the notes for a favorite merchant"
183
+ )
184
+ async def update_favorite_notes(
185
+ merchant_id: str,
186
+ notes_data: FavoriteUpdateRequest,
187
+ current_user: dict = Depends(get_current_user)
188
+ ):
189
+ """
190
+ Update the notes for a favorite merchant.
191
+
192
+ - **merchant_id**: ID of the merchant
193
+ - **notes**: Updated note about this favorite
194
+ """
195
+ try:
196
+ return await FavoriteService.update_favorite_notes(
197
+ customer_id=current_user["sub"],
198
+ merchant_id=merchant_id,
199
+ notes_data=notes_data
200
+ )
201
+ except HTTPException as e:
202
+ raise e
203
+ except Exception as e:
204
+ logger.error(f"Error in update_favorite_notes endpoint: {str(e)}", exc_info=True)
205
+ raise HTTPException(status_code=500, detail="Internal server error")
app/routers/guest_router.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends, status
2
+ from fastapi.security import HTTPBearer
3
+ from app.models.guest_model import GuestModel
4
+ from app.schemas.guest_schema import (
5
+ GuestCreateRequest,
6
+ GuestUpdateRequest,
7
+ GuestResponse,
8
+ GuestListResponse,
9
+ GuestDeleteResponse,
10
+ SetDefaultGuestRequest
11
+ )
12
+ from app.utils.jwt import verify_token
13
+ from typing import Dict, Any
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter()
19
+ security = HTTPBearer()
20
+
21
+ async def get_current_user(token: str = Depends(security)) -> Dict[str, Any]:
22
+ """
23
+ Dependency to get current authenticated user from JWT token
24
+ """
25
+ try:
26
+ payload = verify_token(token.credentials)
27
+ if not payload:
28
+ raise HTTPException(
29
+ status_code=status.HTTP_401_UNAUTHORIZED,
30
+ detail="Invalid or expired token"
31
+ )
32
+ return payload
33
+ except Exception as e:
34
+ logger.error(f"Token verification failed: {str(e)}")
35
+ raise HTTPException(
36
+ status_code=status.HTTP_401_UNAUTHORIZED,
37
+ detail="Invalid or expired token"
38
+ )
39
+
40
+ @router.get("/guests", response_model=GuestListResponse)
41
+ async def get_user_guests(
42
+ current_user: Dict[str, Any] = Depends(get_current_user)
43
+ ):
44
+ """
45
+ Get all guests for a specific user.
46
+
47
+ - **customer_id**: ID of the user
48
+ - Returns list of guests with total count
49
+ """
50
+ try:
51
+ # Verify user can only access their own guests
52
+ customer_id=current_user.get("sub")
53
+
54
+ guests_data = await GuestModel.get_user_guests(customer_id)
55
+
56
+ guests = [GuestResponse(**guest) for guest in guests_data]
57
+
58
+ return GuestListResponse(
59
+ guests=guests,
60
+ total_count=len(guests)
61
+ )
62
+
63
+ except HTTPException:
64
+ raise
65
+ except Exception as e:
66
+ logger.error(f"Error getting guests for user {customer_id}: {str(e)}")
67
+ raise HTTPException(
68
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
69
+ detail="Failed to retrieve guests"
70
+ )
71
+
72
+ @router.post("/guests", response_model=GuestResponse, status_code=status.HTTP_201_CREATED)
73
+ async def create_guest(
74
+ guest_data: GuestCreateRequest,
75
+ current_user: Dict[str, Any] = Depends(get_current_user)
76
+ ):
77
+ """
78
+ Create a new guest profile for a user.
79
+
80
+ - **customer_id**: ID of the user creating the guest profile
81
+ - **guest_data**: Guest information including name, contact details, etc.
82
+ - Returns the created guest profile
83
+ """
84
+ try:
85
+ # Verify user can only create guests for themselves
86
+ customer_id=current_user.get("sub")
87
+
88
+ # Create guest in database
89
+
90
+ guest_id=await GuestModel.create_guest(customer_id,guest_data.dict())
91
+
92
+ if not guest_id:
93
+ raise HTTPException(
94
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
95
+ detail="Failed to create guest profile"
96
+ )
97
+
98
+ # Retrieve and return the created guest
99
+ created_guest = await GuestModel.get_guest_by_id(customer_id, guest_id)
100
+ if not created_guest:
101
+ raise HTTPException(
102
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
103
+ detail="Guest created but failed to retrieve"
104
+ )
105
+
106
+ return GuestResponse(**created_guest)
107
+
108
+ except HTTPException:
109
+ raise
110
+ except Exception as e:
111
+ logger.error(f"Error creating guest for user {customer_id}: {str(e)}")
112
+ raise HTTPException(
113
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
114
+ detail="Failed to create guest profile"
115
+ )
116
+
117
+ @router.get("/guests/default", response_model=GuestResponse)
118
+ async def get_default_guest(
119
+ current_user: Dict[str, Any] = Depends(get_current_user)
120
+ ):
121
+ """Get the default guest for the current user"""
122
+ try:
123
+ customer_id = current_user.get("sub")
124
+ default_guest = await GuestModel.get_default_guest(customer_id)
125
+ if not default_guest:
126
+ raise HTTPException(
127
+ status_code=status.HTTP_404_NOT_FOUND,
128
+ detail="Default guest not set"
129
+ )
130
+ return GuestResponse(**default_guest)
131
+ except HTTPException:
132
+ raise
133
+ except Exception as e:
134
+ logger.error(f"Error getting default guest for user {customer_id}: {str(e)}")
135
+ raise HTTPException(
136
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
137
+ detail="Failed to retrieve default guest"
138
+ )
139
+
140
+ @router.post("/guests/set-default", response_model=GuestResponse)
141
+ async def set_default_guest(
142
+ req: SetDefaultGuestRequest,
143
+ current_user: Dict[str, Any] = Depends(get_current_user)
144
+ ):
145
+ """Set a guest as default for the current user"""
146
+ try:
147
+ customer_id = current_user.get("sub")
148
+ # Verify guest exists and belongs to user
149
+ existing_guest = await GuestModel.get_guest_by_id(customer_id, req.guest_id)
150
+ if not existing_guest:
151
+ raise HTTPException(
152
+ status_code=status.HTTP_404_NOT_FOUND,
153
+ detail="Guest not found"
154
+ )
155
+ if existing_guest.get("customer_id") != customer_id:
156
+ raise HTTPException(
157
+ status_code=status.HTTP_403_FORBIDDEN,
158
+ detail="Access denied. This guest doesn't belong to you."
159
+ )
160
+ success = await GuestModel.set_default_guest(customer_id, req.guest_id)
161
+ if not success:
162
+ raise HTTPException(
163
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
164
+ detail="Failed to set default guest"
165
+ )
166
+ updated_guest = await GuestModel.get_guest_by_id(customer_id, req.guest_id)
167
+ if not updated_guest:
168
+ raise HTTPException(
169
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
170
+ detail="Default set but failed to retrieve guest"
171
+ )
172
+ return GuestResponse(**updated_guest)
173
+ except HTTPException:
174
+ raise
175
+ except Exception as e:
176
+ logger.error(f"Error setting default guest {req.guest_id} for user {customer_id}: {str(e)}")
177
+ raise HTTPException(
178
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
179
+ detail="Failed to set default guest"
180
+ )
181
+ @router.put("/guests/{guest_id}", response_model=GuestResponse)
182
+ async def update_guest(
183
+ guest_id: str,
184
+ guest_data: GuestUpdateRequest,
185
+ current_user: Dict[str, Any] = Depends(get_current_user)
186
+ ):
187
+ """
188
+ Update an existing guest profile.
189
+
190
+ - **customer_id**: ID of the user who owns the guest profile
191
+ - **guest_id**: ID of the guest to update
192
+ - **guest_data**: Updated guest information
193
+ - Returns the updated guest profile
194
+ """
195
+ try:
196
+ # Verify user can only update their own guests
197
+ customer_id=current_user.get("sub")
198
+
199
+ # Check if guest exists and belongs to user
200
+ existing_guest = await GuestModel.get_guest_by_id(customer_id, guest_id)
201
+ if not existing_guest:
202
+ raise HTTPException(
203
+ status_code=status.HTTP_404_NOT_FOUND,
204
+ detail="Guest not found"
205
+ )
206
+
207
+ if existing_guest.get("customer_id") != customer_id:
208
+ raise HTTPException(
209
+ status_code=status.HTTP_403_FORBIDDEN,
210
+ detail="Access denied. This guest doesn't belong to you."
211
+ )
212
+
213
+ # Prepare update fields (only include non-None values)
214
+ update_fields = {}
215
+ for field, value in guest_data.dict(exclude_unset=True).items():
216
+ if value is not None:
217
+ if hasattr(value, 'value'): # Handle enum values
218
+ update_fields[field] = value.value
219
+ else:
220
+ update_fields[field] = value
221
+
222
+ if not update_fields:
223
+ raise HTTPException(
224
+ status_code=status.HTTP_400_BAD_REQUEST,
225
+ detail="No valid fields provided for update"
226
+ )
227
+
228
+ # Update guest in database
229
+ success = await GuestModel.update_guest(customer_id, guest_id, update_fields)
230
+ if not success:
231
+ raise HTTPException(
232
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
233
+ detail="Failed to update guest profile"
234
+ )
235
+
236
+ # Retrieve and return updated guest
237
+ updated_guest = await GuestModel.get_guest_by_id(customer_id, guest_id)
238
+ if not updated_guest:
239
+ raise HTTPException(
240
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
241
+ detail="Guest updated but failed to retrieve"
242
+ )
243
+
244
+ return GuestResponse(**updated_guest)
245
+
246
+ except HTTPException:
247
+ raise
248
+ except Exception as e:
249
+ logger.error(f"Error updating guest {guest_id} for user {customer_id}: {str(e)}")
250
+ raise HTTPException(
251
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
252
+ detail="Failed to update guest profile"
253
+ )
254
+
255
+ @router.delete("/guests/{guest_id}", response_model=GuestDeleteResponse)
256
+ async def delete_guest(
257
+ guest_id: str,
258
+ current_user: Dict[str, Any] = Depends(get_current_user)
259
+ ):
260
+ """
261
+ Delete a guest profile.
262
+
263
+ - **customer_id**: ID of the user who owns the guest profile
264
+ - **guest_id**: ID of the guest to delete
265
+ - Returns confirmation of deletion
266
+ """
267
+ try:
268
+ # Verify user can only delete their own guests
269
+ customer_id=current_user.get("sub")
270
+
271
+ # Check if guest exists and belongs to user
272
+ existing_guest = await GuestModel.get_guest_by_id(customer_id, guest_id)
273
+ if not existing_guest:
274
+ raise HTTPException(
275
+ status_code=status.HTTP_404_NOT_FOUND,
276
+ detail="Guest not found"
277
+ )
278
+
279
+ if existing_guest.get("customer_id") != customer_id:
280
+ raise HTTPException(
281
+ status_code=status.HTTP_403_FORBIDDEN,
282
+ detail="Access denied. This guest doesn't belong to you."
283
+ )
284
+
285
+ # Delete guest from database
286
+ success = await GuestModel.delete_guest(customer_id, guest_id)
287
+ if not success:
288
+ raise HTTPException(
289
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
290
+ detail="Failed to delete guest profile"
291
+ )
292
+
293
+ guest_name = existing_guest.get('first_name', 'Guest')
294
+ if existing_guest.get('last_name'):
295
+ guest_name += f" {existing_guest.get('last_name')}"
296
+
297
+ return GuestDeleteResponse(
298
+ message=f"Guest '{guest_name}' has been successfully deleted",
299
+ guest_id=guest_id
300
+ )
301
+
302
+ except HTTPException:
303
+ raise
304
+ except Exception as e:
305
+ logger.error(f"Error deleting guest {guest_id} for user {customer_id}: {str(e)}")
306
+ raise HTTPException(
307
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
308
+ detail="Failed to delete guest profile"
309
+ )
app/routers/pet_router.py ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends, status
2
+ from fastapi.security import HTTPBearer
3
+ from app.models.pet_model import PetModel
4
+ from app.schemas.pet_schema import (
5
+ PetCreateRequest,
6
+ PetUpdateRequest,
7
+ PetResponse,
8
+ PetListResponse,
9
+ PetDeleteResponse,
10
+ SetDefaultPetRequest
11
+ )
12
+ from app.utils.jwt import verify_token
13
+ from typing import Dict, Any
14
+ import logging
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter()
19
+ security = HTTPBearer()
20
+
21
+ async def get_current_user(token: str = Depends(security)) -> Dict[str, Any]:
22
+ """
23
+ Dependency to get current authenticated user from JWT token
24
+ """
25
+ try:
26
+ payload = verify_token(token.credentials)
27
+ if not payload:
28
+ raise HTTPException(
29
+ status_code=status.HTTP_401_UNAUTHORIZED,
30
+ detail="Invalid or expired token"
31
+ )
32
+ return payload
33
+ except Exception as e:
34
+ logger.error(f"Token verification failed: {str(e)}")
35
+ raise HTTPException(
36
+ status_code=status.HTTP_401_UNAUTHORIZED,
37
+ detail="Invalid or expired token"
38
+ )
39
+
40
+ @router.get("/pets", response_model=PetListResponse)
41
+ async def get_user_pets(
42
+ current_user: Dict[str, Any] = Depends(get_current_user)
43
+ ):
44
+ """
45
+ Get all pets for a specific user.
46
+
47
+ - **customer_id**: ID of the pet owner
48
+ - Returns list of pets with total count
49
+ """
50
+ try:
51
+ # Verify user can only access their own pets
52
+ customer_id=current_user.get("sub")
53
+
54
+ pets_data = await PetModel.get_user_pets(customer_id)
55
+
56
+ pets = [PetResponse(**pet) for pet in pets_data]
57
+
58
+ return PetListResponse(
59
+ pets=pets,
60
+ total_count=len(pets)
61
+ )
62
+
63
+ except HTTPException:
64
+ raise
65
+ except Exception as e:
66
+ logger.error(f"Error getting pets for user {customer_id}: {str(e)}")
67
+ raise HTTPException(
68
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
69
+ detail="Failed to retrieve pets"
70
+ )
71
+
72
+ @router.post("/pets", response_model=PetResponse, status_code=status.HTTP_201_CREATED)
73
+ async def create_pet(
74
+ pet_data: PetCreateRequest,
75
+ current_user: Dict[str, Any] = Depends(get_current_user)
76
+ ):
77
+ """
78
+ Create a new pet profile for a user.
79
+
80
+ - **customer_id**: ID of the pet owner
81
+ - **pet_data**: Pet information including name, species, breed, etc.
82
+ - Returns the created pet profile
83
+ """
84
+ try:
85
+ # Verify user can only create pets for themselves
86
+ customer_id=current_user.get("sub")
87
+
88
+
89
+ pet_id = await PetModel.create_pet(customer_id,pet_data.dict())
90
+ # Create pet in database
91
+
92
+ if not pet_id:
93
+ raise HTTPException(
94
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
95
+ detail="Failed to create pet profile"
96
+ )
97
+
98
+ # Retrieve and return the created pet
99
+ created_pet = await PetModel.get_pet_by_id(customer_id, pet_id)
100
+ if not created_pet:
101
+ raise HTTPException(
102
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
103
+ detail="Pet created but failed to retrieve"
104
+ )
105
+
106
+ return PetResponse(**created_pet)
107
+
108
+ except HTTPException:
109
+ raise
110
+ except Exception as e:
111
+ logger.error(f"Error creating pet for user {customer_id}: {str(e)}")
112
+ raise HTTPException(
113
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
114
+ detail="Failed to create pet profile"
115
+ )
116
+
117
+ @router.get("/pets/default", response_model=PetResponse)
118
+ async def get_default_pet(
119
+ current_user: Dict[str, Any] = Depends(get_current_user)
120
+ ):
121
+ """Get the default pet for the current user"""
122
+ try:
123
+ customer_id = current_user.get("sub")
124
+ default_pet = await PetModel.get_default_pet(customer_id)
125
+ if not default_pet:
126
+ raise HTTPException(
127
+ status_code=status.HTTP_404_NOT_FOUND,
128
+ detail="Default pet not set"
129
+ )
130
+ return PetResponse(**default_pet)
131
+ except HTTPException:
132
+ raise
133
+ except Exception as e:
134
+ logger.error(f"Error getting default pet for user {customer_id}: {str(e)}")
135
+ raise HTTPException(
136
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
137
+ detail="Failed to retrieve default pet"
138
+ )
139
+
140
+ @router.post("/pets/set-default", response_model=PetResponse)
141
+ async def set_default_pet(
142
+ req: SetDefaultPetRequest,
143
+ current_user: Dict[str, Any] = Depends(get_current_user)
144
+ ):
145
+ """Set a pet as default for the current user"""
146
+ try:
147
+ customer_id = current_user.get("sub")
148
+ # Verify pet exists and belongs to user
149
+ existing_pet = await PetModel.get_pet_by_id(customer_id, req.pet_id)
150
+ if not existing_pet:
151
+ raise HTTPException(
152
+ status_code=status.HTTP_404_NOT_FOUND,
153
+ detail="Pet not found"
154
+ )
155
+ if existing_pet.get("customer_id") != customer_id:
156
+ raise HTTPException(
157
+ status_code=status.HTTP_403_FORBIDDEN,
158
+ detail="Access denied. This pet doesn't belong to you."
159
+ )
160
+ success = await PetModel.set_default_pet(customer_id, req.pet_id)
161
+ if not success:
162
+ raise HTTPException(
163
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
164
+ detail="Failed to set default pet"
165
+ )
166
+ updated_pet = await PetModel.get_pet_by_id(customer_id, req.pet_id)
167
+ if not updated_pet:
168
+ raise HTTPException(
169
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
170
+ detail="Default set but failed to retrieve pet"
171
+ )
172
+ return PetResponse(**updated_pet)
173
+ except HTTPException:
174
+ raise
175
+ except Exception as e:
176
+ logger.error(f"Error setting default pet {req.pet_id} for user {customer_id}: {str(e)}")
177
+ raise HTTPException(
178
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
179
+ detail="Failed to set default pet"
180
+ )
181
+ @router.put("/pets/{pet_id}", response_model=PetResponse)
182
+ async def update_pet(
183
+ pet_id: str,
184
+ pet_data: PetUpdateRequest,
185
+ current_user: Dict[str, Any] = Depends(get_current_user)
186
+ ):
187
+ """
188
+ Update an existing pet profile.
189
+
190
+ - **customer_id**: ID of the pet owner
191
+ - **pet_id**: ID of the pet to update
192
+ - **pet_data**: Updated pet information
193
+ - Returns the updated pet profile
194
+ """
195
+ try:
196
+ # Verify user can only update their own pets
197
+ customer_id=current_user.get("sub")
198
+
199
+ # Check if pet exists and belongs to user
200
+ existing_pet = await PetModel.get_pet_by_id(customer_id, pet_id)
201
+ if not existing_pet:
202
+ raise HTTPException(
203
+ status_code=status.HTTP_404_NOT_FOUND,
204
+ detail="Pet not found"
205
+ )
206
+
207
+ if existing_pet.get("customer_id") != customer_id:
208
+ raise HTTPException(
209
+ status_code=status.HTTP_403_FORBIDDEN,
210
+ detail="Access denied. This pet doesn't belong to you."
211
+ )
212
+
213
+ # Prepare update fields (only include non-None values)
214
+ update_fields = {}
215
+ for field, value in pet_data.dict(exclude_unset=True).items():
216
+ if value is not None:
217
+ if hasattr(value, 'value'): # Handle enum values
218
+ update_fields[field] = value.value
219
+ else:
220
+ update_fields[field] = value
221
+
222
+ if not update_fields:
223
+ raise HTTPException(
224
+ status_code=status.HTTP_400_BAD_REQUEST,
225
+ detail="No valid fields provided for update"
226
+ )
227
+
228
+ # Update pet in database
229
+ success = await PetModel.update_pet(customer_id, pet_id, update_fields)
230
+ if not success:
231
+ raise HTTPException(
232
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
233
+ detail="Failed to update pet profile"
234
+ )
235
+
236
+ # Retrieve and return updated pet
237
+ updated_pet = await PetModel.get_pet_by_id(customer_id, pet_id)
238
+ if not updated_pet:
239
+ raise HTTPException(
240
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
241
+ detail="Pet updated but failed to retrieve"
242
+ )
243
+
244
+ return PetResponse(**updated_pet)
245
+
246
+ except HTTPException:
247
+ raise
248
+ except Exception as e:
249
+ logger.error(f"Error updating pet {pet_id} for user {customer_id}: {str(e)}")
250
+ raise HTTPException(
251
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
252
+ detail="Failed to update pet profile"
253
+ )
254
+
255
+ @router.delete("/pets/{pet_id}", response_model=PetDeleteResponse)
256
+ async def delete_pet(
257
+ pet_id: str,
258
+ current_user: Dict[str, Any] = Depends(get_current_user)
259
+ ):
260
+ """
261
+ Delete a pet profile.
262
+
263
+ - **customer_id**: ID of the pet owner
264
+ - **pet_id**: ID of the pet to delete
265
+ - Returns confirmation of deletion
266
+ """
267
+ try:
268
+ # Verify user can only delete their own pets
269
+ customer_id=current_user.get("sub")
270
+
271
+ # Check if pet exists and belongs to user
272
+ existing_pet = await PetModel.get_pet_by_id(customer_id, pet_id)
273
+ if not existing_pet:
274
+ raise HTTPException(
275
+ status_code=status.HTTP_404_NOT_FOUND,
276
+ detail="Pet not found"
277
+ )
278
+
279
+ if existing_pet.get("customer_id") != customer_id:
280
+ raise HTTPException(
281
+ status_code=status.HTTP_403_FORBIDDEN,
282
+ detail="Access denied. This pet doesn't belong to you."
283
+ )
284
+
285
+ # Delete pet from database
286
+ success = await PetModel.delete_pet(customer_id, pet_id)
287
+ if not success:
288
+ raise HTTPException(
289
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
290
+ detail="Failed to delete pet profile"
291
+ )
292
+
293
+ return PetDeleteResponse(
294
+ message=f"Pet '{existing_pet.get('pet_name')}' has been successfully deleted",
295
+ pet_id=pet_id
296
+ )
297
+
298
+ except HTTPException:
299
+ raise
300
+ except Exception as e:
301
+ logger.error(f"Error deleting pet {pet_id} for user {customer_id}: {str(e)}")
302
+ raise HTTPException(
303
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
304
+ detail="Failed to delete pet profile"
305
+ )
app/routers/profile_router.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ## bookmyservice-ums/app/routers/profile_router.py
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException, status
5
+ from typing import Dict, Any
6
+ import logging
7
+
8
+ from app.utils.jwt import get_current_customer_id
9
+ from app.services.profile_service import profile_service
10
+ from app.services.wallet_service import WalletService
11
+ from app.models.user_model import BookMyServiceUserModel
12
+ from app.models.address_model import AddressModel
13
+ from app.schemas.profile_schema import (
14
+ ProfileUpdateRequest, ProfileResponse, ProfileOperationResponse,
15
+ PersonalDetailsResponse, WalletDisplayResponse, ProfileDashboardResponse
16
+ )
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ router = APIRouter()
21
+
22
+ @router.get("/me", response_model=Dict[str, Any])
23
+ async def get_profile(current_customer_id: str = Depends(get_current_customer_id)):
24
+ """
25
+ Get current user's profile from customers collection.
26
+
27
+ This endpoint is JWT protected and requires a valid Bearer token.
28
+
29
+ Args:
30
+ current_customer_id (str): User ID extracted from JWT token
31
+
32
+ Returns:
33
+ Dict[str, Any]: Customer profile data
34
+
35
+ Raises:
36
+ HTTPException: 401 if token is invalid, 404 if profile not found
37
+ """
38
+ try:
39
+ logger.info(f"Profile request for user: {current_customer_id}")
40
+
41
+ # Fetch customer profile using the service
42
+ profile_data = await profile_service.get_customer_profile(current_customer_id)
43
+
44
+ return {
45
+ "success": True,
46
+ "message": "Profile retrieved successfully",
47
+ "data": profile_data
48
+ }
49
+
50
+ except HTTPException:
51
+ # Re-raise HTTP exceptions from service
52
+ raise
53
+ except Exception as e:
54
+ logger.error(f"Unexpected error in get_profile: {str(e)}")
55
+ raise HTTPException(
56
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
57
+ detail="Internal server error"
58
+ )
59
+
60
+ @router.get("/dashboard", response_model=ProfileDashboardResponse)
61
+ async def get_profile_dashboard(current_customer_id: str = Depends(get_current_customer_id)):
62
+ """
63
+ Get complete profile dashboard with personal details, wallet, and address info.
64
+
65
+ This endpoint matches the screenshot requirements showing:
66
+ - Personal details (name, email, phone, DOB)
67
+ - Wallet balance
68
+ - Address management info
69
+ """
70
+ try:
71
+ logger.info(f"Dashboard request for user: {current_customer_id}")
72
+
73
+ # Get user profile
74
+ user = await BookMyServiceUserModel.find_by_id(current_customer_id)
75
+ if not user:
76
+ raise HTTPException(status_code=404, detail="User not found")
77
+
78
+ # Parse name into first and last name
79
+ name_parts = user.get("name", "").split(" ", 1)
80
+ first_name = name_parts[0] if name_parts else ""
81
+ last_name = name_parts[1] if len(name_parts) > 1 else ""
82
+
83
+ # Get wallet balance
84
+ wallet_balance = await WalletService.get_wallet_balance(current_customer_id)
85
+
86
+ # Get address count and default address status
87
+ addresses = await AddressModel.get_user_addresses(current_customer_id)
88
+ address_count = len(addresses)
89
+ has_default_address = any(addr.get("is_default", False) for addr in addresses)
90
+
91
+ # Build response
92
+ personal_details = PersonalDetailsResponse(
93
+ first_name=first_name,
94
+ last_name=last_name,
95
+ email=user.get("email", ""),
96
+ phone=user.get("phone", ""),
97
+ date_of_birth=user.get("date_of_birth")
98
+ )
99
+
100
+ wallet_display = WalletDisplayResponse(
101
+ balance=wallet_balance.balance,
102
+ formatted_balance=wallet_balance.formatted_balance,
103
+ currency=wallet_balance.currency
104
+ )
105
+
106
+ return ProfileDashboardResponse(
107
+ personal_details=personal_details,
108
+ wallet=wallet_display,
109
+ address_count=address_count,
110
+ has_default_address=has_default_address
111
+ )
112
+
113
+ except HTTPException:
114
+ raise
115
+ except Exception as e:
116
+ logger.error(f"Error getting profile dashboard for user {current_customer_id}: {str(e)}")
117
+ raise HTTPException(
118
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
119
+ detail="Internal server error"
120
+ )
121
+
122
+ @router.put("/update", response_model=ProfileOperationResponse)
123
+ async def update_profile(
124
+ profile_data: ProfileUpdateRequest,
125
+ current_customer_id: str = Depends(get_current_customer_id)
126
+ ):
127
+ """
128
+ Update user profile information including personal details and DOB.
129
+ """
130
+ try:
131
+ logger.info(f"Profile update request for user: {current_customer_id}")
132
+
133
+ # Prepare update fields
134
+ update_fields = {}
135
+
136
+ if profile_data.name is not None:
137
+ update_fields["name"] = profile_data.name
138
+
139
+ if profile_data.email is not None:
140
+ # Check if email is already used by another user
141
+ existing_user = await BookMyServiceUserModel.find_by_email(str(profile_data.email))
142
+ if existing_user and existing_user.get("customer_id") != current_customer_id:
143
+ raise HTTPException(status_code=409, detail="Email already in use by another account")
144
+ update_fields["email"] = str(profile_data.email)
145
+
146
+ if profile_data.phone is not None:
147
+ # Check if phone is already used by another user
148
+ existing_user = await BookMyServiceUserModel.find_by_phone(profile_data.phone)
149
+ if existing_user and existing_user.get("customer_id") != current_customer_id:
150
+ raise HTTPException(status_code=409, detail="Phone number already in use by another account")
151
+ update_fields["phone"] = profile_data.phone
152
+
153
+ if profile_data.date_of_birth is not None:
154
+ update_fields["date_of_birth"] = profile_data.date_of_birth
155
+
156
+ if profile_data.profile_picture is not None:
157
+ update_fields["profile_picture"] = profile_data.profile_picture
158
+
159
+ if not update_fields:
160
+ raise HTTPException(status_code=400, detail="No fields to update")
161
+
162
+ # Update profile
163
+ success = await BookMyServiceUserModel.update_profile(current_customer_id, update_fields)
164
+
165
+ if success:
166
+ # Get updated profile
167
+ updated_user = await BookMyServiceUserModel.find_by_id(current_customer_id)
168
+
169
+ profile_response = ProfileResponse(
170
+ customer_id=updated_user["customer_id"],
171
+ name=updated_user["name"],
172
+ email=updated_user.get("email"),
173
+ phone=updated_user.get("phone"),
174
+ date_of_birth=updated_user.get("date_of_birth"),
175
+ profile_picture=updated_user.get("profile_picture"),
176
+ auth_method=updated_user.get("auth_mode", "unknown"),
177
+ created_at=updated_user.get("created_at"),
178
+ updated_at=updated_user.get("updated_at")
179
+ )
180
+
181
+ return ProfileOperationResponse(
182
+ success=True,
183
+ message="Profile updated successfully",
184
+ profile=profile_response
185
+ )
186
+ else:
187
+ return ProfileOperationResponse(
188
+ success=False,
189
+ message="Failed to update profile"
190
+ )
191
+
192
+ except HTTPException:
193
+ raise
194
+ except Exception as e:
195
+ logger.error(f"Error updating profile for user {current_customer_id}: {str(e)}")
196
+ raise HTTPException(
197
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
198
+ detail="Internal server error"
199
+ )
200
+
app/routers/review_router.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ import logging
3
+ from app.utils.jwt import get_current_user
4
+ from typing import Dict, Any
5
+
6
+ from app.schemas.review_schema import ReviewCreateRequest,ReviewResponse
7
+ from app.models.review_model import ReviewModel
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ router = APIRouter()
12
+
13
+ @router.post("/add", response_model=ReviewResponse,
14
+ status_code=status.HTTP_201_CREATED)
15
+ async def create_review(
16
+ review_data: ReviewCreateRequest,
17
+ current_user: Dict[str, Any] = Depends(get_current_user)
18
+ ):
19
+ try:
20
+
21
+
22
+ review_reponse=await ReviewModel.create_review(review_data.dict())
23
+
24
+ if not review_reponse:
25
+ raise HTTPException(
26
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
27
+ detail="failed to add review details"
28
+ )
29
+ return ReviewResponse(
30
+ merchant_id=review_reponse["merchant_id"],
31
+ location_id=review_reponse["location_id"],
32
+ user_name=review_reponse["user_name"],
33
+ rating=review_reponse["rating"],
34
+ review_text=review_reponse["review_text"],
35
+ review_date=review_reponse["review_date"],
36
+ verified_purchase=review_reponse["verified_purchase"]
37
+
38
+ )
39
+
40
+ except HTTPException:
41
+ raise
42
+ except Exception as e:
43
+ logger.error(f"Error while adding review: {str(e)}")
44
+ raise HTTPException(
45
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
46
+ detail="Failed to add review"
47
+ )
48
+
app/routers/user_router.py ADDED
@@ -0,0 +1,556 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends, Security, status
2
+ from fastapi.security import APIKeyHeader
3
+
4
+ from app.schemas.user_schema import (
5
+ OTPRequest,
6
+ OTPRequestWithLogin,
7
+ OTPVerifyRequest,
8
+ UserRegisterRequest,
9
+ UserLoginRequest,
10
+ OAuthLoginRequest,
11
+ TokenResponse,
12
+ OTPSendResponse,
13
+ )
14
+ from app.services.user_service import UserService
15
+ from app.utils.jwt import create_temp_token, decode_token, create_refresh_token, get_current_customer_id
16
+ from app.utils.social_utils import verify_google_token, verify_google_access_token, verify_apple_token, verify_facebook_token
17
+ from app.utils.common_utils import validate_identifier
18
+ from app.models.social_security_model import SocialSecurityModel
19
+ from app.models.user_model import BookMyServiceUserModel
20
+ from fastapi import Request
21
+ import logging
22
+
23
+ logger = logging.getLogger("user_router")
24
+
25
+ router = APIRouter()
26
+
27
+ # 🔐 Declare API key header scheme (Swagger shows a single token input box)
28
+ api_key_scheme = APIKeyHeader(name="Authorization", auto_error=False)
29
+
30
+ # 🔍 Bearer token parser
31
+ # More flexible bearer token parser
32
+ def get_bearer_token(api_key: str = Security(api_key_scheme)) -> str:
33
+ try:
34
+ # Check if Authorization header is missing
35
+ if not api_key:
36
+ logger.warning("Missing Authorization header")
37
+ raise HTTPException(
38
+ status_code=status.HTTP_401_UNAUTHORIZED,
39
+ detail="Missing Authorization header"
40
+ )
41
+
42
+ # If "Bearer " prefix is included, strip it
43
+ logger.info(f"Received Authorization header: {api_key}")
44
+ if api_key.lower().startswith("bearer "):
45
+ return api_key[7:] # Remove "Bearer " prefix
46
+
47
+ # Else, assume it's already a raw JWT
48
+ return api_key
49
+
50
+ except HTTPException:
51
+ # Re-raise HTTP exceptions
52
+ raise
53
+ except Exception as e:
54
+ logger.error(f"Error processing Authorization header: {str(e)}")
55
+ raise HTTPException(
56
+ status_code=status.HTTP_401_UNAUTHORIZED,
57
+ detail="Invalid Authorization header format"
58
+ )
59
+
60
+
61
+ # 📧📱 Send OTP using single login input (preferred endpoint)
62
+ @router.post("/send-otp-login", response_model=OTPSendResponse)
63
+ async def send_otp_login_handler(payload: OTPRequestWithLogin):
64
+ logger.info(f"OTP login request started - login_input: {payload.login_input}")
65
+
66
+ try:
67
+ # Validate identifier format
68
+ try:
69
+ identifier_type = validate_identifier(payload.login_input)
70
+ logger.info(f"Login input type: {identifier_type}")
71
+ except ValueError as ve:
72
+ logger.error(f"Invalid login input format: {str(ve)}")
73
+ raise HTTPException(status_code=400, detail=str(ve))
74
+
75
+ # Check if user already exists
76
+ user_exists = False
77
+ if identifier_type == "email":
78
+ user_exists = await BookMyServiceUserModel.exists_by_email_or_phone(email=payload.login_input)
79
+ elif identifier_type == "phone":
80
+ user_exists = await BookMyServiceUserModel.exists_by_email_or_phone(phone=payload.login_input)
81
+
82
+ logger.info(f"User existence check result: {user_exists}")
83
+
84
+ # Send OTP via service
85
+ logger.info(f"Calling UserService.send_otp with identifier: {payload.login_input}")
86
+ await UserService.send_otp(payload.login_input)
87
+ logger.info(f"OTP sent successfully to: {payload.login_input}")
88
+
89
+ # Create temporary token
90
+ logger.info("Creating temporary token for OTP verification")
91
+ temp_token = create_temp_token({
92
+ "sub": payload.login_input,
93
+ "type": "otp_verification"
94
+ }, expires_minutes=10)
95
+
96
+ logger.info(f"Temporary token created for: {payload.login_input}")
97
+ logger.info(f"Temp token (first 20 chars): {temp_token[:20]}...")
98
+
99
+ return {
100
+ "message": "OTP sent",
101
+ "temp_token": temp_token,
102
+ "user_exists": user_exists
103
+ }
104
+
105
+ except HTTPException as e:
106
+ logger.error(f"OTP login request failed - HTTP {e.status_code}: {e.detail}")
107
+ raise e
108
+ except Exception as e:
109
+ logger.error(f"Unexpected error during OTP login request: {str(e)}", exc_info=True)
110
+ raise HTTPException(status_code=500, detail="Internal server error during OTP login request")
111
+
112
+ # 🔐 OTP Login using temporary token
113
+ @router.post("/otp-login", response_model=TokenResponse)
114
+ async def otp_login_handler(
115
+ payload: OTPVerifyRequest,
116
+ request: Request,
117
+ temp_token: str = Depends(get_bearer_token)
118
+ ):
119
+ logger.info(f"OTP login attempt started - login_input: {payload.login_input}, remember_me: {payload.remember_me}")
120
+ logger.info(f"Received temp_token: {temp_token[:20]}..." if temp_token else "No temp_token received")
121
+
122
+ # Get client IP
123
+ client_ip = request.client.host if request.client else None
124
+
125
+ try:
126
+ # Decode and validate temporary token
127
+ logger.info("Attempting to decode temporary token")
128
+ decoded = decode_token(temp_token)
129
+ logger.info(f"Decoded token payload: {decoded}")
130
+
131
+ if not decoded:
132
+ logger.warning("Failed to decode temporary token - token is invalid or expired")
133
+ raise HTTPException(status_code=401, detail="Invalid or expired OTP session token")
134
+
135
+ # Validate token subject matches login input
136
+ token_sub = decoded.get("sub")
137
+ token_type = decoded.get("type")
138
+ logger.info(f"Token subject: {token_sub}, Token type: {token_type}")
139
+
140
+ if token_sub != payload.login_input:
141
+ logger.warning(f"Token subject mismatch - token_sub: {token_sub}, login_input: {payload.login_input}")
142
+ raise HTTPException(status_code=401, detail="Invalid or expired OTP session token")
143
+
144
+ if token_type != "otp_verification":
145
+ logger.warning(f"Invalid token type - expected: otp_verification, got: {token_type}")
146
+ raise HTTPException(status_code=401, detail="Invalid or expired OTP session token")
147
+
148
+ logger.info(f"Temporary token validation successful for: {payload.login_input}")
149
+
150
+ # Call user service for OTP verification and login
151
+ logger.info(f"Calling UserService.otp_login_handler with identifier: {payload.login_input}, otp: {payload.otp}")
152
+ result = await UserService.otp_login_handler(
153
+ payload.login_input,
154
+ payload.otp,
155
+ client_ip=client_ip,
156
+ remember_me=payload.remember_me,
157
+ device_info=payload.device_info
158
+ )
159
+
160
+ logger.info(f"OTP login successful for: {payload.login_input}")
161
+ return result
162
+
163
+ except HTTPException as e:
164
+ logger.error(f"OTP login failed for {payload.login_input} - HTTP {e.status_code}: {e.detail}")
165
+ raise e
166
+ except Exception as e:
167
+ logger.error(f"Unexpected error during OTP login for {payload.login_input}: {str(e)}", exc_info=True)
168
+ raise HTTPException(status_code=500, detail="Internal server error during OTP login")
169
+
170
+ # 🌐 OAuth Login for Google / Apple
171
+ @router.post("/oauth-login", response_model=TokenResponse)
172
+ async def oauth_login_handler(payload: OAuthLoginRequest, request: Request):
173
+ from app.core.config import settings
174
+
175
+ # Get client IP
176
+ client_ip = request.client.host if request.client else None
177
+
178
+ # Check if IP is locked for this provider
179
+ if await SocialSecurityModel.is_oauth_ip_locked(client_ip, payload.provider):
180
+ await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, False)
181
+ raise HTTPException(
182
+ status_code=429,
183
+ detail=f"Too many failed attempts. IP temporarily locked for {payload.provider} OAuth."
184
+ )
185
+
186
+ # Check rate limiting
187
+ if not await SocialSecurityModel.check_oauth_rate_limit(client_ip, payload.provider):
188
+ await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, False)
189
+ raise HTTPException(
190
+ status_code=429,
191
+ detail=f"Rate limit exceeded for {payload.provider} OAuth. Please try again later."
192
+ )
193
+
194
+ # Validate token format (allow Google access tokens too)
195
+ if not await SocialSecurityModel.validate_oauth_token_format(payload.token, payload.provider):
196
+ await SocialSecurityModel.track_oauth_failed_attempt(client_ip, payload.provider)
197
+ await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, False)
198
+ raise HTTPException(status_code=400, detail="Invalid token format")
199
+
200
+ # Increment rate limit counter
201
+ await SocialSecurityModel.increment_oauth_rate_limit(client_ip, payload.provider)
202
+
203
+ try:
204
+ if payload.provider == "google":
205
+ # Accept both ID tokens (JWT) and access tokens
206
+ token = payload.token
207
+ is_jwt = token.count('.') == 2
208
+ if is_jwt:
209
+ # ID token verification requires the configured client id
210
+ if not settings.GOOGLE_CLIENT_ID:
211
+ raise HTTPException(status_code=500, detail="Google OAuth not configured")
212
+ user_info = await verify_google_token(token, settings.GOOGLE_CLIENT_ID)
213
+ else:
214
+ # Access token verification via UserInfo does not require client id
215
+ if token.lower().startswith("bearer "):
216
+ token = token[7:]
217
+ user_info = await verify_google_access_token(token)
218
+ provider_customer_id = user_info.get('sub', user_info.get('id'))
219
+ customer_id = f"google_{provider_customer_id}"
220
+
221
+ elif payload.provider == "apple":
222
+ if not settings.APPLE_AUDIENCE:
223
+ raise HTTPException(status_code=500, detail="Apple OAuth not configured")
224
+ user_info = await verify_apple_token(payload.token, settings.APPLE_AUDIENCE)
225
+ provider_customer_id = user_info.get('sub', user_info.get('id'))
226
+ customer_id = f"apple_{provider_customer_id}"
227
+
228
+ elif payload.provider == "facebook":
229
+ if not settings.FACEBOOK_APP_ID or not settings.FACEBOOK_APP_SECRET:
230
+ raise HTTPException(status_code=500, detail="Facebook OAuth not configured")
231
+ user_info = await verify_facebook_token(payload.token, settings.FACEBOOK_APP_ID, settings.FACEBOOK_APP_SECRET)
232
+ provider_customer_id = user_info.get('id')
233
+ customer_id = f"facebook_{provider_customer_id}"
234
+
235
+ else:
236
+ raise HTTPException(status_code=400, detail="Unsupported OAuth provider")
237
+
238
+ # Clear failed attempts on successful verification
239
+ await SocialSecurityModel.clear_oauth_failed_attempts(client_ip, payload.provider)
240
+
241
+ # Log successful attempt
242
+ await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, True, customer_id)
243
+
244
+ # Resolve existing UUID via social linkage if available
245
+ user_exists = False
246
+ resolved_customer_uuid = None
247
+ try:
248
+ from app.models.social_account_model import SocialAccountModel
249
+ linked_account = await SocialAccountModel.find_by_provider_and_customer_id(payload.provider, provider_customer_id)
250
+ if linked_account and linked_account.get("customer_id"):
251
+ resolved_customer_uuid = linked_account["customer_id"]
252
+ user_exists = True
253
+ else:
254
+ # Backward compatibility: some records may still have provider-prefixed customer_id
255
+ existing_user = await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id})
256
+ if existing_user:
257
+ resolved_customer_uuid = existing_user["customer_id"]
258
+ user_exists = True
259
+ except Exception:
260
+ # Do not block login on existence check errors; default to False
261
+ user_exists = False
262
+
263
+ temp_token = create_temp_token({
264
+ "sub": resolved_customer_uuid or customer_id,
265
+ "type": "oauth_session",
266
+ "verified": True,
267
+ "provider": payload.provider,
268
+ "user_info": user_info
269
+ })
270
+
271
+ # Log temporary OAuth session token (truncated)
272
+ logger.info(f"OAuth temp token generated (first 25 chars): {temp_token[:25]}...")
273
+
274
+ # Populate response with available customer details for frontend convenience
275
+ return {
276
+ "access_token": temp_token,
277
+ "token_type": "bearer",
278
+ "expires_in": settings.JWT_TEMP_TOKEN_EXPIRE_MINUTES * 60,
279
+ "refresh_token": None,
280
+ "customer_id": resolved_customer_uuid if user_exists else None,
281
+ "name": user_info.get("name"),
282
+ "email": user_info.get("email"),
283
+ "profile_picture": user_info.get("picture"),
284
+ "auth_method": "oauth",
285
+ "provider": payload.provider,
286
+ "user_exists": user_exists,
287
+ "security_info": {
288
+ "verified": user_info.get("email_verified"),
289
+ "provider_user_id": provider_customer_id
290
+ }
291
+ }
292
+
293
+ except HTTPException:
294
+ # Re-raise HTTP exceptions (configuration errors, etc.)
295
+ raise
296
+ except Exception as e:
297
+ # Track failed attempt for token verification failures
298
+ await SocialSecurityModel.track_oauth_failed_attempt(client_ip, payload.provider)
299
+ await SocialSecurityModel.log_oauth_attempt(client_ip, payload.provider, False)
300
+ logger.error(f"OAuth verification failed for {payload.provider}: {str(e)}", exc_info=True)
301
+ raise HTTPException(status_code=401, detail="OAuth token verification failed")
302
+
303
+ # 👤 Final user registration after OTP or OAuth
304
+ @router.post("/register", response_model=TokenResponse)
305
+ async def register_user(
306
+ payload: UserRegisterRequest,
307
+ temp_token: str = Depends(get_bearer_token)
308
+ ):
309
+ logger.info(f"Received registration request with payload: {payload}")
310
+
311
+ decoded = decode_token(temp_token)
312
+ if not decoded or decoded.get("type") not in ["otp_verification", "oauth_session"]:
313
+ raise HTTPException(status_code=401, detail="Invalid or expired registration token")
314
+
315
+ logger.info(f"Registering user with payload: {payload}")
316
+
317
+ result = await UserService.register(payload, decoded)
318
+ # Log tokens returned on register (truncated)
319
+ if result and isinstance(result, dict):
320
+ at = result.get("access_token")
321
+ rt = result.get("refresh_token")
322
+ if at:
323
+ logger.info(f"Register access token (first 25 chars): {at[:25]}...")
324
+ if rt:
325
+ logger.info(f"Register refresh token (first 25 chars): {rt[:25]}...")
326
+ return result
327
+
328
+ # 🔄 Refresh access token using refresh token with rotation
329
+ @router.post("/refresh-token", response_model=TokenResponse)
330
+ async def refresh_token_handler(
331
+ request: Request,
332
+ refresh_token: str = Depends(get_bearer_token)
333
+ ):
334
+ from app.models.refresh_token_model import RefreshTokenModel
335
+ from app.utils.jwt import create_access_token
336
+
337
+ logger.info("Refresh token request received")
338
+
339
+ # Get client IP
340
+ client_ip = request.client.host if request.client else None
341
+
342
+ try:
343
+ # Decode and validate refresh token
344
+ decoded = decode_token(refresh_token)
345
+ logger.info(f"Decoded refresh token payload: {decoded}")
346
+
347
+ if not decoded:
348
+ logger.warning("Failed to decode refresh token - token is invalid or expired")
349
+ raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
350
+
351
+ # Validate token type
352
+ token_type = decoded.get("type")
353
+ if token_type != "refresh":
354
+ logger.warning(f"Invalid token type for refresh - expected: refresh, got: {token_type}")
355
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
356
+
357
+ # Extract token information
358
+ customer_id = decoded.get("sub")
359
+ token_id = decoded.get("jti")
360
+ family_id = decoded.get("family_id")
361
+ remember_me = decoded.get("remember_me", False)
362
+
363
+ if not customer_id or not token_id:
364
+ logger.warning("Refresh token missing required claims")
365
+ raise HTTPException(status_code=401, detail="Invalid refresh token")
366
+
367
+ # Validate token hasn't been revoked or reused
368
+ if not await RefreshTokenModel.is_token_valid(token_id):
369
+ logger.error(f"Token {token_id} is invalid - possible security breach")
370
+ raise HTTPException(
371
+ status_code=401,
372
+ detail="Invalid refresh token. Please login again."
373
+ )
374
+
375
+ # Mark current token as used
376
+ await RefreshTokenModel.mark_token_as_used(token_id)
377
+
378
+ # Increment rotation count
379
+ if family_id:
380
+ await RefreshTokenModel.increment_rotation_count(family_id)
381
+
382
+ logger.info(f"Refresh token validation successful for user: {customer_id}")
383
+
384
+ # Create new access token
385
+ access_token = create_access_token({
386
+ "sub": customer_id
387
+ })
388
+
389
+ # Create new refresh token (rotation)
390
+ new_refresh_token, new_token_id, new_expires_at = create_refresh_token(
391
+ {"sub": customer_id},
392
+ remember_me=remember_me,
393
+ family_id=family_id
394
+ )
395
+
396
+ # Store new refresh token metadata
397
+ token_metadata = await RefreshTokenModel.get_token_metadata(token_id)
398
+ await RefreshTokenModel.store_refresh_token(
399
+ token_id=new_token_id,
400
+ customer_id=customer_id,
401
+ family_id=family_id,
402
+ expires_at=new_expires_at,
403
+ remember_me=remember_me,
404
+ device_info=token_metadata.get("device_info") if token_metadata else None,
405
+ ip_address=client_ip
406
+ )
407
+
408
+ logger.info(f"New tokens generated for user: {customer_id} (rotation)")
409
+
410
+ return {
411
+ "access_token": access_token,
412
+ "token_type": "bearer",
413
+ "expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
414
+ "refresh_token": new_refresh_token,
415
+ "customer_id": customer_id
416
+ }
417
+
418
+ except HTTPException as e:
419
+ logger.error(f"Refresh token failed - HTTP {e.status_code}: {e.detail}")
420
+ raise e
421
+ except Exception as e:
422
+ logger.error(f"Unexpected error during refresh token: {str(e)}", exc_info=True)
423
+ raise HTTPException(status_code=500, detail="Internal server error during token refresh")
424
+
425
+
426
+ # 🚪 Logout - Revoke refresh token
427
+ @router.post("/logout")
428
+ async def logout_handler(
429
+ refresh_token: str = Depends(get_bearer_token)
430
+ ):
431
+ from app.models.refresh_token_model import RefreshTokenModel
432
+
433
+ logger.info("Logout request received")
434
+
435
+ try:
436
+ # Decode refresh token to get token ID
437
+ decoded = decode_token(refresh_token)
438
+
439
+ if decoded and decoded.get("type") == "refresh":
440
+ token_id = decoded.get("jti")
441
+ customer_id = decoded.get("sub")
442
+
443
+ if token_id:
444
+ # Revoke the refresh token
445
+ await RefreshTokenModel.revoke_token(token_id)
446
+ logger.info(f"Revoked refresh token {token_id} for user {customer_id}")
447
+
448
+ return {
449
+ "message": "Logged out successfully",
450
+ "success": True
451
+ }
452
+
453
+ return {
454
+ "message": "Invalid token",
455
+ "success": False
456
+ }
457
+
458
+ except Exception as e:
459
+ logger.error(f"Error during logout: {str(e)}", exc_info=True)
460
+ raise HTTPException(status_code=500, detail="Internal server error during logout")
461
+
462
+ # 🚪 Logout from all devices - Revoke all refresh tokens
463
+ @router.post("/logout-all")
464
+ async def logout_all_handler(
465
+ customer_id: str = Depends(get_current_customer_id)
466
+ ):
467
+ from app.models.refresh_token_model import RefreshTokenModel
468
+
469
+ logger.info(f"Logout all devices request for user: {customer_id}")
470
+
471
+ try:
472
+ # Revoke all refresh tokens for the user
473
+ revoked_count = await RefreshTokenModel.revoke_all_user_tokens(customer_id)
474
+
475
+ logger.info(f"Revoked {revoked_count} tokens for user {customer_id}")
476
+
477
+ return {
478
+ "message": f"Logged out from {revoked_count} device(s) successfully",
479
+ "success": True,
480
+ "revoked_count": revoked_count
481
+ }
482
+
483
+ except Exception as e:
484
+ logger.error(f"Error during logout all: {str(e)}", exc_info=True)
485
+ raise HTTPException(status_code=500, detail="Internal server error during logout")
486
+
487
+ # 📱 Get active sessions
488
+ @router.get("/sessions")
489
+ async def get_active_sessions_handler(
490
+ customer_id: str = Depends(get_current_customer_id)
491
+ ):
492
+ from app.models.refresh_token_model import RefreshTokenModel
493
+
494
+ logger.info(f"Get active sessions request for user: {customer_id}")
495
+
496
+ try:
497
+ sessions = await RefreshTokenModel.get_active_sessions(customer_id)
498
+
499
+ # Format session data for response
500
+ formatted_sessions = []
501
+ for session in sessions:
502
+ formatted_sessions.append({
503
+ "token_id": session.get("token_id"),
504
+ "device_info": session.get("device_info"),
505
+ "ip_address": session.get("ip_address"),
506
+ "created_at": session.get("created_at"),
507
+ "expires_at": session.get("expires_at"),
508
+ "remember_me": session.get("remember_me", False),
509
+ "last_used": session.get("used_at")
510
+ })
511
+
512
+ return {
513
+ "sessions": formatted_sessions,
514
+ "total": len(formatted_sessions)
515
+ }
516
+
517
+ except Exception as e:
518
+ logger.error(f"Error getting active sessions: {str(e)}", exc_info=True)
519
+ raise HTTPException(status_code=500, detail="Internal server error getting sessions")
520
+
521
+ # 🗑️ Revoke specific session
522
+ @router.delete("/sessions/{token_id}")
523
+ async def revoke_session_handler(
524
+ token_id: str,
525
+ customer_id: str = Depends(get_current_customer_id)
526
+ ):
527
+ from app.models.refresh_token_model import RefreshTokenModel
528
+
529
+ logger.info(f"Revoke session request for token: {token_id} by user: {customer_id}")
530
+
531
+ try:
532
+ # Verify the token belongs to the user
533
+ token_metadata = await RefreshTokenModel.get_token_metadata(token_id)
534
+
535
+ if not token_metadata:
536
+ raise HTTPException(status_code=404, detail="Session not found")
537
+
538
+ if token_metadata.get("customer_id") != customer_id:
539
+ raise HTTPException(status_code=403, detail="Unauthorized to revoke this session")
540
+
541
+ # Revoke the token
542
+ success = await RefreshTokenModel.revoke_token(token_id)
543
+
544
+ if success:
545
+ return {
546
+ "message": "Session revoked successfully",
547
+ "success": True
548
+ }
549
+ else:
550
+ raise HTTPException(status_code=404, detail="Session not found or already revoked")
551
+
552
+ except HTTPException:
553
+ raise
554
+ except Exception as e:
555
+ logger.error(f"Error revoking session: {str(e)}", exc_info=True)
556
+ raise HTTPException(status_code=500, detail="Internal server error revoking session")
app/routers/wallet_router.py ADDED
@@ -0,0 +1,223 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status, Query
2
+ from typing import List, Optional
3
+ import logging
4
+
5
+ from app.utils.jwt import get_current_customer_id
6
+ from app.services.wallet_service import WalletService
7
+ from app.schemas.wallet_schema import (
8
+ WalletBalanceResponse, TransactionHistoryResponse, WalletSummaryResponse,
9
+ AddMoneyRequest, WithdrawMoneyRequest, TransactionRequest, TransactionResponse
10
+ )
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ router = APIRouter()
15
+
16
+ @router.get("/balance", response_model=WalletBalanceResponse)
17
+ async def get_wallet_balance(current_customer_id: str = Depends(get_current_customer_id)):
18
+ """
19
+ Get current user's wallet balance.
20
+
21
+ This endpoint is JWT protected and requires a valid Bearer token.
22
+ """
23
+ try:
24
+ logger.info(f"Wallet balance request for user: {current_customer_id}")
25
+
26
+ balance_info = await WalletService.get_wallet_balance(current_customer_id)
27
+ return balance_info
28
+
29
+ except Exception as e:
30
+ logger.error(f"Error getting wallet balance for user {current_customer_id}: {str(e)}")
31
+ raise HTTPException(
32
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
33
+ detail="Failed to retrieve wallet balance"
34
+ )
35
+
36
+ @router.get("/summary", response_model=WalletSummaryResponse)
37
+ async def get_wallet_summary(current_customer_id: str = Depends(get_current_customer_id)):
38
+ """
39
+ Get wallet summary including balance and recent transaction stats.
40
+ """
41
+ try:
42
+ logger.info(f"Wallet summary request for user: {current_customer_id}")
43
+
44
+ summary = await WalletService.get_wallet_summary(current_customer_id)
45
+ return summary
46
+
47
+ except Exception as e:
48
+ logger.error(f"Error getting wallet summary for user {current_customer_id}: {str(e)}")
49
+ raise HTTPException(
50
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
51
+ detail="Failed to retrieve wallet summary"
52
+ )
53
+
54
+ @router.get("/transactions", response_model=TransactionHistoryResponse)
55
+ async def get_transaction_history(
56
+ current_customer_id: str = Depends(get_current_customer_id),
57
+ page: int = Query(1, ge=1, description="Page number"),
58
+ limit: int = Query(10, ge=1, le=100, description="Number of transactions per page"),
59
+ transaction_type: Optional[str] = Query(None, description="Filter by transaction type")
60
+ ):
61
+ """
62
+ Get paginated transaction history for the current user.
63
+
64
+ Query parameters:
65
+ - page: Page number (default: 1)
66
+ - limit: Number of transactions per page (default: 10, max: 100)
67
+ - transaction_type: Filter by type (credit, debit, refund, etc.)
68
+ """
69
+ try:
70
+ logger.info(f"Transaction history request for user: {current_customer_id}, page: {page}, limit: {limit}")
71
+
72
+ history = await WalletService.get_transaction_history(
73
+ current_customer_id,
74
+ page=page,
75
+ limit=limit,
76
+ transaction_type=transaction_type
77
+ )
78
+ return history
79
+
80
+ except Exception as e:
81
+ logger.error(f"Error getting transaction history for user {current_customer_id}: {str(e)}")
82
+ raise HTTPException(
83
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
84
+ detail="Failed to retrieve transaction history"
85
+ )
86
+
87
+ @router.post("/add-money", response_model=TransactionResponse)
88
+ async def add_money_to_wallet(
89
+ request: AddMoneyRequest,
90
+ current_customer_id: str = Depends(get_current_customer_id)
91
+ ):
92
+ """
93
+ Add money to user's wallet.
94
+
95
+ This would typically integrate with a payment gateway.
96
+ For now, it simulates adding money to the wallet.
97
+ """
98
+ try:
99
+ logger.info(f"Add money request for user: {current_customer_id}, amount: {request.amount}")
100
+
101
+ if request.amount <= 0:
102
+ raise HTTPException(status_code=400, detail="Amount must be greater than zero")
103
+
104
+ transaction = await WalletService.add_money(
105
+ current_customer_id,
106
+ request.amount,
107
+ request.payment_method,
108
+ request.reference_id,
109
+ request.description
110
+ )
111
+
112
+ return TransactionResponse(
113
+ success=True,
114
+ message="Money added successfully",
115
+ transaction=transaction,
116
+ new_balance=transaction.balance_after
117
+ )
118
+
119
+ except HTTPException:
120
+ raise
121
+ except Exception as e:
122
+ logger.error(f"Error adding money for user {current_customer_id}: {str(e)}")
123
+ raise HTTPException(
124
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
125
+ detail="Failed to add money to wallet"
126
+ )
127
+
128
+ @router.post("/withdraw", response_model=TransactionResponse)
129
+ async def withdraw_money(
130
+ request: WithdrawMoneyRequest,
131
+ current_customer_id: str = Depends(get_current_customer_id)
132
+ ):
133
+ """
134
+ Withdraw money from user's wallet.
135
+
136
+ This would typically integrate with a payment gateway for bank transfers.
137
+ """
138
+ try:
139
+ logger.info(f"Withdraw request for user: {current_customer_id}, amount: {request.amount}")
140
+
141
+ if request.amount <= 0:
142
+ raise HTTPException(status_code=400, detail="Amount must be greater than zero")
143
+
144
+ # Check if user has sufficient balance
145
+ balance_info = await WalletService.get_wallet_balance(current_customer_id)
146
+ if balance_info.balance < request.amount:
147
+ raise HTTPException(status_code=400, detail="Insufficient wallet balance")
148
+
149
+ transaction = await WalletService.deduct_money(
150
+ current_customer_id,
151
+ request.amount,
152
+ "withdrawal",
153
+ request.bank_account_id,
154
+ request.description or "Wallet withdrawal"
155
+ )
156
+
157
+ return TransactionResponse(
158
+ success=True,
159
+ message="Withdrawal processed successfully",
160
+ transaction=transaction,
161
+ new_balance=transaction.balance_after
162
+ )
163
+
164
+ except HTTPException:
165
+ raise
166
+ except Exception as e:
167
+ logger.error(f"Error processing withdrawal for user {current_customer_id}: {str(e)}")
168
+ raise HTTPException(
169
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
170
+ detail="Failed to process withdrawal"
171
+ )
172
+
173
+ @router.post("/transaction", response_model=TransactionResponse)
174
+ async def create_transaction(
175
+ request: TransactionRequest,
176
+ current_customer_id: str = Depends(get_current_customer_id)
177
+ ):
178
+ """
179
+ Create a generic transaction (for internal use, service bookings, etc.).
180
+ """
181
+ try:
182
+ logger.info(f"Transaction request for user: {current_customer_id}, type: {request.transaction_type}, amount: {request.amount}")
183
+
184
+ if request.amount <= 0:
185
+ raise HTTPException(status_code=400, detail="Amount must be greater than zero")
186
+
187
+ if request.transaction_type == "debit":
188
+ # Check if user has sufficient balance for debit transactions
189
+ balance_info = await WalletService.get_wallet_balance(current_customer_id)
190
+ if balance_info.balance < request.amount:
191
+ raise HTTPException(status_code=400, detail="Insufficient wallet balance")
192
+
193
+ transaction = await WalletService.deduct_money(
194
+ current_customer_id,
195
+ request.amount,
196
+ request.category or "service",
197
+ request.reference_id,
198
+ request.description
199
+ )
200
+ else: # credit or refund
201
+ transaction = await WalletService.add_money(
202
+ current_customer_id,
203
+ request.amount,
204
+ request.category or "refund",
205
+ request.reference_id,
206
+ request.description
207
+ )
208
+
209
+ return TransactionResponse(
210
+ success=True,
211
+ message=f"Transaction processed successfully",
212
+ transaction=transaction,
213
+ new_balance=transaction.balance_after
214
+ )
215
+
216
+ except HTTPException:
217
+ raise
218
+ except Exception as e:
219
+ logger.error(f"Error creating transaction for user {current_customer_id}: {str(e)}")
220
+ raise HTTPException(
221
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
222
+ detail="Failed to process transaction"
223
+ )
app/schemas/__init__.py ADDED
File without changes
app/schemas/address_schema.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field, validator
2
+ from datetime import datetime
3
+ from typing import List, Optional, Literal
4
+
5
+ class AddressCreateRequest(BaseModel):
6
+ """Request model for creating a new address"""
7
+ address_line_1: str = Field(..., min_length=5, max_length=200, description="Primary address line")
8
+ address_line_2: Optional[str] = Field("", max_length=200, description="Secondary address line")
9
+ city: str = Field(..., min_length=2, max_length=100, description="City name")
10
+ state: str = Field(..., min_length=2, max_length=100, description="State name")
11
+ postal_code: str = Field(..., min_length=5, max_length=10, description="Postal/ZIP code")
12
+ country: str = Field(default="India", max_length=100, description="Country name")
13
+ address_type: Literal["home", "work", "other"] = Field(default="home", description="Type of address")
14
+ is_default: bool = Field(default=False, description="Set as default address")
15
+ landmark: Optional[str] = Field("", max_length=200, description="Nearby landmark")
16
+
17
+ @validator('postal_code')
18
+ def validate_postal_code(cls, v):
19
+ if not v.isdigit():
20
+ raise ValueError('Postal code must contain only digits')
21
+ return v
22
+
23
+ class AddressUpdateRequest(BaseModel):
24
+ """Request model for updating an existing address"""
25
+ address_line_1: Optional[str] = Field(None, min_length=5, max_length=200, description="Primary address line")
26
+ address_line_2: Optional[str] = Field(None, max_length=200, description="Secondary address line")
27
+ city: Optional[str] = Field(None, min_length=2, max_length=100, description="City name")
28
+ state: Optional[str] = Field(None, min_length=2, max_length=100, description="State name")
29
+ postal_code: Optional[str] = Field(None, min_length=5, max_length=10, description="Postal/ZIP code")
30
+ country: Optional[str] = Field(None, max_length=100, description="Country name")
31
+ address_type: Optional[Literal["home", "work", "other"]] = Field(None, description="Type of address")
32
+ is_default: Optional[bool] = Field(None, description="Set as default address")
33
+ landmark: Optional[str] = Field(None, max_length=200, description="Nearby landmark")
34
+
35
+ @validator('postal_code')
36
+ def validate_postal_code(cls, v):
37
+ if v and not v.isdigit():
38
+ raise ValueError('Postal code must contain only digits')
39
+ return v
40
+
41
+ class AddressResponse(BaseModel):
42
+ """Response model for address data"""
43
+ address_id: str = Field(..., description="Unique address ID")
44
+ address_line_1: str = Field(..., description="Primary address line")
45
+ address_line_2: str = Field(..., description="Secondary address line")
46
+ city: str = Field(..., description="City name")
47
+ state: str = Field(..., description="State name")
48
+ postal_code: str = Field(..., description="Postal/ZIP code")
49
+ country: str = Field(..., description="Country name")
50
+ address_type: str = Field(..., description="Type of address")
51
+ is_default: bool = Field(..., description="Is default address")
52
+ landmark: str = Field(..., description="Nearby landmark")
53
+ created_at: datetime = Field(..., description="Address creation timestamp")
54
+ updated_at: datetime = Field(..., description="Address last update timestamp")
55
+
56
+ class Config:
57
+ from_attributes = True
58
+
59
+ class AddressListResponse(BaseModel):
60
+ """Response model for list of addresses"""
61
+ success: bool = Field(..., description="Operation success status")
62
+ message: str = Field(..., description="Response message")
63
+ addresses: Optional[List[AddressResponse]]= Field(None, description="List of user addresses")
64
+ total_count: Optional[int] = Field(0, description="Total number of addresses")
65
+
66
+ class SetDefaultAddressRequest(BaseModel):
67
+ """Request model for setting default address"""
68
+ address_id: str = Field(..., description="Address ID to set as default")
69
+
70
+ class AddressOperationResponse(BaseModel):
71
+ """Response model for address operations"""
72
+ success: bool = Field(..., description="Operation success status")
73
+ message: str = Field(..., description="Response message")
74
+ address: Optional[AddressResponse] = Field(None, description="Address ID if applicable")
app/schemas/favorite_schema.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional, List
3
+ from datetime import datetime
4
+
5
+ class FavoriteCreateRequest(BaseModel):
6
+ """Request schema for creating a favorite"""
7
+ merchant_id: str = Field(..., description="ID of the merchant to favorite")
8
+ source: str = Field(..., description="Source of the favorite action")
9
+ merchant_category: str = Field(..., description="Category of the merchant (salon, spa, pet_spa, etc.)")
10
+ merchant_name: str = Field(..., description="Name of the merchant for quick display")
11
+ notes: Optional[str] = Field(None, description="Optional note about this favorite")
12
+ class FavoriteUpdateRequest(BaseModel):
13
+ """Request schema for updating favorite notes"""
14
+ notes: Optional[str] = Field(None, description="Updated note about this favorite")
15
+
16
+ class FavoriteResponse(BaseModel):
17
+ """Response schema for a favorite merchant"""
18
+ merchant_id: str = Field(..., description="ID of the merchant")
19
+ source: str = Field(..., description="Source of the favorite action")
20
+ merchant_category: str = Field(..., description="Category of the merchant")
21
+ merchant_name: str = Field(..., description="Name of the merchant")
22
+ added_at: datetime = Field(..., description="When the merchant was favorited")
23
+ notes: Optional[str] = Field(None, description="Optional note about this favorite")
24
+
25
+ class FavoritesListResponse(BaseModel):
26
+ """Response schema for listing favorites"""
27
+ favorites: List[FavoriteResponse] = Field(..., description="List of favorite merchants")
28
+ total_count: int = Field(..., description="Total number of favorites")
29
+ limit: int = Field(..., description="Maximum number of items returned")
30
+
31
+ class FavoriteStatusResponse(BaseModel):
32
+ """Response schema for checking favorite status"""
33
+ is_favorite: bool = Field(..., description="Whether the merchant is in favorites")
34
+ merchant_id: Optional[str] = Field(None, description="ID of the merchant if exists")
35
+
36
+ class FavoriteSuccessResponse(BaseModel):
37
+ """Response schema for successful favorite operations"""
38
+ success: bool = Field(..., description="Operation success status")
39
+ message: str = Field(..., description="Operation result message")
40
+ merchant_id: Optional[str] = Field(None, description="ID of the merchant")
41
+
42
+ class FavoriteDataResponse(BaseModel):
43
+ success: bool = Field(..., description="Operation success status")
44
+ message: str = Field(..., description="Operation result message")
45
+ favorite_data: Optional[FavoriteResponse]=Field(None, description="List of favorite merchants")
app/schemas/guest_schema.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field, validator, EmailStr
2
+ from typing import Optional, List
3
+ from datetime import datetime,date
4
+ from enum import Enum
5
+
6
+ class GenderEnum(str, Enum):
7
+ MALE = "Male"
8
+ FEMALE = "Female"
9
+ OTHER = "Other"
10
+
11
+ class RelationshipEnum(str, Enum):
12
+ FAMILY = "Family"
13
+ FRIEND = "Friend"
14
+ COLLEAGUE = "Colleague"
15
+ OTHER = "Other"
16
+
17
+ class GuestCreateRequest(BaseModel):
18
+ """Schema for creating a new guest profile"""
19
+ first_name: str = Field(..., min_length=1, max_length=100, description="Guest's first name")
20
+ last_name: Optional[str] = Field(None, max_length=100, description="Guest's last name")
21
+ email: Optional[EmailStr] = Field(None, description="Guest's email address")
22
+ phone_number: Optional[str] = Field(None, max_length=20, description="Guest's phone number")
23
+ gender: Optional[GenderEnum] = Field(None, description="Guest's gender")
24
+ date_of_birth: Optional[date] = Field(None, description="Guest's date of birth for age calculation")
25
+ relationship: Optional[RelationshipEnum] = Field(None, description="Relationship to the user")
26
+ notes: Optional[str] = Field(None, max_length=500, description="Additional notes about the guest")
27
+ is_default: Optional[bool] = Field(None, description="Mark as default guest")
28
+
29
+ @validator('email', pre=True)
30
+ def optional_email_empty_to_none(cls, v):
31
+ if v is None:
32
+ return None
33
+ if isinstance(v, str) and v.strip() == '':
34
+ return None
35
+ return v
36
+
37
+ @validator('phone_number', pre=True)
38
+ def optional_phone_empty_to_none(cls, v):
39
+ if v is None:
40
+ return None
41
+ if isinstance(v, str) and v.strip() == '':
42
+ return None
43
+ return v
44
+
45
+ @validator('date_of_birth', pre=True)
46
+ def coerce_date_of_birth(cls, v):
47
+ if v is None:
48
+ return None
49
+ if isinstance(v, str):
50
+ s = v.strip()
51
+ if s == '':
52
+ return None
53
+ try:
54
+ # Accept ISO date or datetime strings; convert to date
55
+ if 'T' in s or 'Z' in s or '+' in s:
56
+ return datetime.fromisoformat(s.replace('Z', '+00:00')).date()
57
+ return date.fromisoformat(s)
58
+ except Exception:
59
+ return v
60
+ if isinstance(v, datetime):
61
+ return v.date()
62
+ return v
63
+
64
+ @validator('first_name')
65
+ def validate_first_name(cls, v):
66
+ if not v or not v.strip():
67
+ raise ValueError('First name cannot be empty')
68
+ return v.strip()
69
+
70
+ @validator('last_name')
71
+ def validate_last_name(cls, v):
72
+ if v is not None and v.strip() == '':
73
+ return None
74
+ return v.strip() if v else v
75
+
76
+ @validator('phone_number')
77
+ def validate_phone_number(cls, v):
78
+ if v is not None:
79
+ # Remove spaces and special characters for validation
80
+ cleaned = ''.join(filter(str.isdigit, v))
81
+ if len(cleaned) < 10 or len(cleaned) > 15:
82
+ raise ValueError('Phone number must be between 10 and 15 digits')
83
+ return v
84
+
85
+ @validator('date_of_birth')
86
+ def validate_date_of_birth(cls, v):
87
+ if v is not None:
88
+ if v > date.today():
89
+ raise ValueError('Date of birth cannot be in the future')
90
+ # Check if age would be reasonable (not more than 120 years old)
91
+ age = (date.today() - v).days // 365
92
+ if age > 120:
93
+ raise ValueError('Date of birth indicates unrealistic age')
94
+ return v
95
+
96
+ class GuestUpdateRequest(BaseModel):
97
+ """Schema for updating a guest profile"""
98
+ first_name: Optional[str] = Field(None, min_length=1, max_length=100, description="Guest's first name")
99
+ last_name: Optional[str] = Field(None, max_length=100, description="Guest's last name")
100
+ email: Optional[EmailStr] = Field(None, description="Guest's email address")
101
+ phone_number: Optional[str] = Field(None, max_length=20, description="Guest's phone number")
102
+ gender: Optional[GenderEnum] = Field(None, description="Guest's gender")
103
+ date_of_birth: Optional[date] = Field(None, description="Guest's date of birth for age calculation")
104
+ relationship: Optional[RelationshipEnum] = Field(None, description="Relationship to the user")
105
+ notes: Optional[str] = Field(None, max_length=500, description="Additional notes about the guest")
106
+ is_default: Optional[bool] = Field(None, description="Mark as default guest")
107
+
108
+ @validator('email', pre=True)
109
+ def optional_email_empty_to_none_update(cls, v):
110
+ if v is None:
111
+ return None
112
+ if isinstance(v, str) and v.strip() == '':
113
+ return None
114
+ return v
115
+
116
+ @validator('phone_number', pre=True)
117
+ def optional_phone_empty_to_none_update(cls, v):
118
+ if v is None:
119
+ return None
120
+ if isinstance(v, str) and v.strip() == '':
121
+ return None
122
+ return v
123
+
124
+ @validator('date_of_birth', pre=True)
125
+ def coerce_date_of_birth_update(cls, v):
126
+ if v is None:
127
+ return None
128
+ if isinstance(v, str):
129
+ s = v.strip()
130
+ if s == '':
131
+ return None
132
+ try:
133
+ if 'T' in s or 'Z' in s or '+' in s:
134
+ return datetime.fromisoformat(s.replace('Z', '+00:00')).date()
135
+ return date.fromisoformat(s)
136
+ except Exception:
137
+ return v
138
+ if isinstance(v, datetime):
139
+ return v.date()
140
+ return v
141
+
142
+ @validator('first_name')
143
+ def validate_first_name(cls, v):
144
+ if v is not None and (not v or not v.strip()):
145
+ raise ValueError('First name cannot be empty')
146
+ return v.strip() if v else v
147
+
148
+ @validator('last_name')
149
+ def validate_last_name(cls, v):
150
+ if v is not None and v.strip() == '':
151
+ return None
152
+ return v.strip() if v else v
153
+
154
+ @validator('phone_number')
155
+ def validate_phone_number(cls, v):
156
+ if v is not None:
157
+ # Remove spaces and special characters for validation
158
+ cleaned = ''.join(filter(str.isdigit, v))
159
+ if len(cleaned) < 10 or len(cleaned) > 15:
160
+ raise ValueError('Phone number must be between 10 and 15 digits')
161
+ return v
162
+
163
+ @validator('date_of_birth')
164
+ def validate_date_of_birth(cls, v):
165
+ if v is not None:
166
+ if v > date.today():
167
+ raise ValueError('Date of birth cannot be in the future')
168
+ # Check if age would be reasonable (not more than 120 years old)
169
+ age = (date.today() - v).days // 365
170
+ if age > 120:
171
+ raise ValueError('Date of birth indicates unrealistic age')
172
+ return v
173
+
174
+ class GuestResponse(BaseModel):
175
+ """Schema for guest profile response"""
176
+ guest_id: str = Field(..., description="Unique guest identifier")
177
+ customer_id: str = Field(..., description="User ID who created this guest profile")
178
+ first_name: str = Field(..., description="Guest's first name")
179
+ last_name: Optional[str] = Field(None, description="Guest's last name")
180
+ email: Optional[str] = Field(None, description="Guest's email address")
181
+ phone_number: Optional[str] = Field(None, description="Guest's phone number")
182
+ gender: Optional[str] = Field(None, description="Guest's gender")
183
+ date_of_birth: Optional[date] = Field(None, description="Guest's date of birth")
184
+ relationship: Optional[str] = Field(None, description="Relationship to the user")
185
+ notes: Optional[str] = Field(None, description="Additional notes about the guest")
186
+ is_default: bool = Field(..., description="Is default guest")
187
+ created_at: datetime = Field(..., description="Guest profile creation timestamp")
188
+ updated_at: datetime = Field(..., description="Guest profile last update timestamp")
189
+
190
+ @property
191
+ def full_name(self) -> str:
192
+ """Get the full name of the guest"""
193
+ if self.last_name:
194
+ return f"{self.first_name} {self.last_name}"
195
+ return self.first_name
196
+
197
+ @property
198
+ def age(self) -> Optional[int]:
199
+ """Calculate age from date of birth"""
200
+ if self.date_of_birth:
201
+ today = datetime.now()
202
+ return today.year - self.date_of_birth.year - (
203
+ (today.month, today.day) < (self.date_of_birth.month, self.date_of_birth.day)
204
+ )
205
+ return None
206
+
207
+ class Config:
208
+ from_attributes = True
209
+ json_encoders = {
210
+ datetime: lambda v: v.isoformat()
211
+ }
212
+
213
+ class GuestListResponse(BaseModel):
214
+ """Schema for list of guests response"""
215
+ guests: List[GuestResponse] = Field(..., description="List of user's guests")
216
+ total_count: int = Field(..., description="Total number of guests")
217
+
218
+ class Config:
219
+ from_attributes = True
220
+
221
+ class GuestDeleteResponse(BaseModel):
222
+ """Schema for guest deletion response"""
223
+ message: str = Field(..., description="Deletion confirmation message")
224
+ guest_id: str = Field(..., description="ID of the deleted guest")
225
+
226
+ class Config:
227
+ from_attributes = True
228
+
229
+ class SetDefaultGuestRequest(BaseModel):
230
+ """Request model for setting default guest"""
231
+ guest_id: str = Field(..., description="Guest ID to set as default")
app/schemas/pet_schema.py ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field, validator
2
+ from typing import Optional, List
3
+ from datetime import datetime,date
4
+ from enum import Enum
5
+
6
+ class SpeciesEnum(str, Enum):
7
+ DOG = "Dog"
8
+ CAT = "Cat"
9
+ OTHER = "Other"
10
+
11
+ class GenderEnum(str, Enum):
12
+ MALE = "Male"
13
+ FEMALE = "Female"
14
+ OTHER = "Other"
15
+
16
+ class TemperamentEnum(str, Enum):
17
+ CALM = "Calm"
18
+ NERVOUS = "Nervous"
19
+ AGGRESSIVE = "Aggressive"
20
+ SOCIAL = "Social"
21
+
22
+ class PetCreateRequest(BaseModel):
23
+ """Schema for creating a new pet profile"""
24
+ pet_name: str = Field(..., min_length=1, max_length=100, description="Name of the pet")
25
+ species: SpeciesEnum = Field(..., description="Species of the pet")
26
+ breed: Optional[str] = Field(None, max_length=100, description="Breed of the pet")
27
+ date_of_birth: Optional[date] = Field(None, description="Pet's date of birth")
28
+ age: Optional[int] = Field(None, ge=0, le=50, description="Pet's age in years")
29
+ weight: Optional[float] = Field(None, ge=0, le=200, description="Pet's weight in kg")
30
+ gender: Optional[GenderEnum] = Field(None, description="Pet's gender")
31
+ temperament: Optional[TemperamentEnum] = Field(None, description="Pet's temperament")
32
+ health_notes: Optional[str] = Field(None, max_length=1000, description="Health notes, allergies, medications")
33
+ is_vaccinated: bool = Field(False, description="Vaccination status")
34
+ pet_photo_url: Optional[str] = Field(None, max_length=500, description="URL to pet's photo")
35
+
36
+ @validator('pet_name')
37
+ def validate_pet_name(cls, v):
38
+ if not v or not v.strip():
39
+ raise ValueError('Pet name cannot be empty')
40
+ return v.strip()
41
+
42
+ @validator('age', 'date_of_birth')
43
+ def validate_age_or_dob(cls, v, values):
44
+ # At least one of age or date_of_birth should be provided
45
+ if 'age' in values and 'date_of_birth' in values:
46
+ if not values.get('age') and not values.get('date_of_birth'):
47
+ raise ValueError('Either age or date of birth must be provided')
48
+ return v
49
+
50
+ class PetUpdateRequest(BaseModel):
51
+ """Schema for updating a pet profile"""
52
+ pet_name: Optional[str] = Field(None, min_length=1, max_length=100, description="Name of the pet")
53
+ species: Optional[SpeciesEnum] = Field(None, description="Species of the pet")
54
+ breed: Optional[str] = Field(None, max_length=100, description="Breed of the pet")
55
+ date_of_birth: Optional[date] = Field(None, description="Pet's date of birth")
56
+ age: Optional[int] = Field(None, ge=0, le=50, description="Pet's age in years")
57
+ weight: Optional[float] = Field(None, ge=0, le=200, description="Pet's weight in kg")
58
+ gender: Optional[GenderEnum] = Field(None, description="Pet's gender")
59
+ temperament: Optional[TemperamentEnum] = Field(None, description="Pet's temperament")
60
+ health_notes: Optional[str] = Field(None, max_length=1000, description="Health notes, allergies, medications")
61
+ is_vaccinated: Optional[bool] = Field(None, description="Vaccination status")
62
+ pet_photo_url: Optional[str] = Field(None, max_length=500, description="URL to pet's photo")
63
+ is_default: Optional[bool] = Field(None, description="Mark as default pet")
64
+
65
+ @validator('pet_name')
66
+ def validate_pet_name(cls, v):
67
+ if v is not None and (not v or not v.strip()):
68
+ raise ValueError('Pet name cannot be empty')
69
+ return v.strip() if v else v
70
+
71
+ class PetResponse(BaseModel):
72
+ """Schema for pet profile response"""
73
+ pet_id: str = Field(..., description="Unique pet identifier")
74
+ customer_id: str = Field(..., description="Owner's user ID")
75
+ pet_name: str = Field(..., description="Name of the pet")
76
+ species: str = Field(..., description="Species of the pet")
77
+ breed: Optional[str] = Field(None, description="Breed of the pet")
78
+ date_of_birth: Optional[datetime] = Field(None, description="Pet's date of birth")
79
+ age: Optional[int] = Field(None, description="Pet's age in years")
80
+ weight: Optional[float] = Field(None, description="Pet's weight in kg")
81
+ gender: Optional[str] = Field(None, description="Pet's gender")
82
+ temperament: Optional[str] = Field(None, description="Pet's temperament")
83
+ health_notes: Optional[str] = Field(None, description="Health notes, allergies, medications")
84
+ is_vaccinated: bool = Field(..., description="Vaccination status")
85
+ pet_photo_url: Optional[str] = Field(None, description="URL to pet's photo")
86
+ is_default: bool = Field(..., description="Is default pet")
87
+ created_at: datetime = Field(..., description="Pet profile creation timestamp")
88
+ updated_at: datetime = Field(..., description="Pet profile last update timestamp")
89
+
90
+ class Config:
91
+ from_attributes = True
92
+ json_encoders = {
93
+ datetime: lambda v: v.isoformat()
94
+ }
95
+
96
+ class PetListResponse(BaseModel):
97
+ """Schema for list of pets response"""
98
+ pets: List[PetResponse] = Field(..., description="List of user's pets")
99
+ total_count: int = Field(..., description="Total number of pets")
100
+
101
+ class Config:
102
+ from_attributes = True
103
+
104
+ class PetDeleteResponse(BaseModel):
105
+ """Schema for pet deletion response"""
106
+ message: str = Field(..., description="Deletion confirmation message")
107
+ pet_id: str = Field(..., description="ID of the deleted pet")
108
+
109
+ class Config:
110
+ from_attributes = True
111
+
112
+ class SetDefaultPetRequest(BaseModel):
113
+ """Request model for setting default pet"""
114
+ pet_id: str = Field(..., description="Pet ID to set as default")
app/schemas/profile_schema.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr, Field, validator
2
+ from datetime import datetime, date
3
+ from typing import Optional, Dict, Any
4
+ import re
5
+
6
+ class ProfileUpdateRequest(BaseModel):
7
+ """Request model for updating user profile"""
8
+ name: Optional[str] = Field(None, min_length=2, max_length=100, description="User's full name")
9
+ email: Optional[EmailStr] = Field(None, description="User's email address")
10
+ phone: Optional[str] = Field(None, description="User's phone number")
11
+ date_of_birth: Optional[str] = Field(None, description="Date of birth in DD/MM/YYYY format")
12
+ profile_picture: Optional[str] = Field(None, description="Profile picture URL")
13
+
14
+ @validator('phone')
15
+ def validate_phone(cls, v):
16
+ if v is not None:
17
+ # Remove any non-digit characters
18
+ phone_digits = re.sub(r'\D', '', v)
19
+ if len(phone_digits) != 10:
20
+ raise ValueError('Phone number must be exactly 10 digits')
21
+ return phone_digits
22
+ return v
23
+
24
+ @validator('date_of_birth')
25
+ def validate_date_of_birth(cls, v):
26
+ if v is not None:
27
+ try:
28
+ # Parse DD/MM/YYYY format
29
+ day, month, year = map(int, v.split('/'))
30
+ birth_date = date(year, month, day)
31
+
32
+ # Check if date is not in the future
33
+ if birth_date > date.today():
34
+ raise ValueError('Date of birth cannot be in the future')
35
+
36
+ # Check if age is reasonable (not more than 120 years)
37
+ age = (date.today() - birth_date).days // 365
38
+ if age > 120:
39
+ raise ValueError('Invalid date of birth')
40
+
41
+ return v
42
+ except ValueError as e:
43
+ if "Invalid date of birth" in str(e) or "Date of birth cannot be in the future" in str(e):
44
+ raise e
45
+ raise ValueError('Date of birth must be in DD/MM/YYYY format')
46
+ return v
47
+
48
+ class ProfileResponse(BaseModel):
49
+ """Response model for user profile"""
50
+ customer_id: str = Field(..., description="Unique user identifier")
51
+ name: str = Field(..., description="User's full name")
52
+ email: Optional[str] = Field(None, description="User's email address")
53
+ phone: Optional[str] = Field(None, description="User's phone number")
54
+ date_of_birth: Optional[str] = Field(None, description="Date of birth in DD/MM/YYYY format")
55
+ profile_picture: Optional[str] = Field(None, description="Profile picture URL")
56
+ auth_method: str = Field(..., description="Authentication method used")
57
+ created_at: datetime = Field(..., description="Account creation timestamp")
58
+ updated_at: Optional[datetime] = Field(None, description="Last profile update timestamp")
59
+
60
+ class Config:
61
+ from_attributes = True
62
+
63
+ class PersonalDetailsResponse(BaseModel):
64
+ """Response model for personal details section"""
65
+ first_name: str = Field(..., description="User's first name")
66
+ last_name: str = Field(..., description="User's last name")
67
+ email: str = Field(..., description="User's email address")
68
+ phone: str = Field(..., description="User's phone number")
69
+ date_of_birth: Optional[str] = Field(None, description="Date of birth in DD/MM/YYYY format")
70
+
71
+ class Config:
72
+ from_attributes = True
73
+
74
+ class ProfileOperationResponse(BaseModel):
75
+ """Response model for profile operations"""
76
+ success: bool = Field(..., description="Operation success status")
77
+ message: str = Field(..., description="Response message")
78
+ profile: Optional[ProfileResponse] = Field(None, description="Updated profile data")
79
+
80
+ class WalletDisplayResponse(BaseModel):
81
+ """Response model for wallet display in profile"""
82
+ balance: float = Field(..., description="Current wallet balance")
83
+ formatted_balance: str = Field(..., description="Formatted balance with currency symbol")
84
+ currency: str = Field(default="INR", description="Currency code")
85
+
86
+ class ProfileDashboardResponse(BaseModel):
87
+ """Complete response model for profile dashboard"""
88
+ personal_details: PersonalDetailsResponse = Field(..., description="Personal details")
89
+ wallet: WalletDisplayResponse = Field(..., description="Wallet information")
90
+ address_count: int = Field(..., description="Number of saved addresses")
91
+ has_default_address: bool = Field(..., description="Whether user has a default address set")
app/schemas/review_schema.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from typing import Optional, List
3
+ from datetime import datetime
4
+
5
+ class ReviewCreateRequest(BaseModel):
6
+ """Request schema for creating a review"""
7
+ merchant_id: str = Field(..., description="Unique ID of the merchant")
8
+ location_id: str = Field(..., description="ID of the merchant location")
9
+ user_name: str = Field(..., description="Name of the user submitting the review")
10
+ rating: float = Field(..., description="Rating given by the user")
11
+ review_text: str = Field(..., description="review text provided by the user")
12
+ verified_purchase: bool = Field(..., description="Indicates if the review is from a verified purchase")
13
+
14
+
15
+ class ReviewResponse(BaseModel):
16
+ """Response schema for creating a review"""
17
+ merchant_id: str = Field(..., description="Unique ID of the merchant")
18
+ location_id: str = Field(..., description="ID of the merchant location")
19
+ user_name: str = Field(..., description="Name of the user submitting the review")
20
+ rating: float = Field(..., description="Rating given by the user")
21
+ review_text: str = Field(..., description="review text provided by the user")
22
+ review_date: datetime = Field(..., description="Date and time when the review was created")
23
+ verified_purchase: bool = Field(..., description="Indicates if the review is from a verified purchase")
24
+
app/schemas/user_schema.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, EmailStr, validator
2
+ from typing import Optional, Literal, List, Dict, Any
3
+ from datetime import datetime
4
+ import re
5
+
6
+ # Used for OTP-based or OAuth-based user registration
7
+ class UserRegisterRequest(BaseModel):
8
+ name: str
9
+ email: EmailStr # Mandatory for all registration modes
10
+ phone: str # Mandatory for all registration modes (always used as OTP identifier)
11
+ otp: Optional[str] = None # Required for OTP mode
12
+ dob: Optional[str] = None # ISO format date string
13
+ oauth_token: Optional[str] = None # Required for OAuth mode
14
+ provider: Optional[Literal["google", "apple", "facebook"]] = None # Required for OAuth mode
15
+ mode: Literal["otp", "oauth"]
16
+ remember_me: Optional[bool] = False
17
+ device_info: Optional[str] = None
18
+
19
+ @validator('phone')
20
+ def validate_phone(cls, v):
21
+ if v is not None:
22
+ # Remove all non-digit characters except +
23
+ cleaned = re.sub(r'[^\d+]', '', v)
24
+ # Check if it's a valid phone number (8-15 digits, optionally starting with +)
25
+ if not re.match(r'^\+?[1-9]\d{7,14}$', cleaned):
26
+ raise ValueError('Invalid phone number format')
27
+ return v
28
+
29
+ @validator('mode')
30
+ def validate_mode_dependent_fields(cls, v, values):
31
+ if v == "otp":
32
+ if not values.get('otp'):
33
+ raise ValueError('OTP is required for OTP registration mode')
34
+ elif v == "oauth":
35
+ if not values.get('oauth_token'):
36
+ raise ValueError('OAuth token is required for OAuth registration mode')
37
+ if not values.get('provider'):
38
+ raise ValueError('Provider is required for OAuth registration mode')
39
+ return v
40
+
41
+ # Used in login form (optional display name prefilled from local storage)
42
+ class UserLoginRequest(BaseModel):
43
+ name: Optional[str] = None
44
+ login_input: str # Changed from email to support both email and phone
45
+
46
+ @validator('login_input')
47
+ def validate_login_input(cls, v):
48
+ # Check if it's either email or phone
49
+ email_pattern = r"[^@]+@[^@]+\.[^@]+"
50
+ phone_cleaned = re.sub(r'[^\d+]', '', v)
51
+ phone_pattern = r'^\+?[1-9]\d{7,14}$'
52
+
53
+ if not (re.match(email_pattern, v) or re.match(phone_pattern, phone_cleaned)):
54
+ raise ValueError('Login input must be a valid email address or phone number')
55
+ return v
56
+
57
+ # OTP request via email or phone - updated to ensure only one is provided
58
+ class OTPRequest(BaseModel):
59
+ email: Optional[EmailStr] = None
60
+ phone: Optional[str] = None
61
+
62
+ @validator('phone')
63
+ def validate_phone(cls, v):
64
+ if v is not None:
65
+ # Remove all non-digit characters except +
66
+ cleaned = re.sub(r'[^\d+]', '', v)
67
+ # Check if it's a valid phone number (8-15 digits, optionally starting with +)
68
+ if not re.match(r'^\+?[1-9]\d{7,14}$', cleaned):
69
+ raise ValueError('Invalid phone number format')
70
+ return v
71
+
72
+ @validator('phone')
73
+ def validate_only_one_identifier(cls, v, values):
74
+ if v is not None and values.get('email') is not None:
75
+ raise ValueError('Provide either email or phone, not both')
76
+ if v is None and values.get('email') is None:
77
+ raise ValueError('Either email or phone must be provided')
78
+ return v
79
+
80
+ # Generic OTP request using single login input
81
+ class OTPRequestWithLogin(BaseModel):
82
+ login_input: str # email or phone
83
+
84
+ @validator('login_input')
85
+ def validate_login_input(cls, v):
86
+ # Check if it's either email or phone
87
+ email_pattern = r"[^@]+@[^@]+\.[^@]+"
88
+ phone_cleaned = re.sub(r'[^\d+]', '', v)
89
+ phone_pattern = r'^\+?[1-9]\d{7,14}$'
90
+
91
+ if not (re.match(email_pattern, v) or re.match(phone_pattern, phone_cleaned)):
92
+ raise ValueError('Login input must be a valid email address or phone number')
93
+ return v
94
+
95
+ # OTP verification input
96
+ class OTPVerifyRequest(BaseModel):
97
+ login_input: str
98
+ otp: str
99
+ remember_me: Optional[bool] = False
100
+ device_info: Optional[str] = None
101
+
102
+ @validator('login_input')
103
+ def validate_login_input(cls, v):
104
+ # Check if it's either email or phone
105
+ email_pattern = r"[^@]+@[^@]+\.[^@]+"
106
+ phone_cleaned = re.sub(r'[^\d+]', '', v)
107
+ phone_pattern = r'^\+?[1-9]\d{7,14}$'
108
+
109
+ if not (re.match(email_pattern, v) or re.match(phone_pattern, phone_cleaned)):
110
+ raise ValueError('Login input must be a valid email address or phone number')
111
+ return v
112
+
113
+ # OTP send response with user existence flag
114
+ class OTPSendResponse(BaseModel):
115
+ message: str
116
+ temp_token: str
117
+ user_exists: bool = False
118
+
119
+ # OAuth login using Google/Apple
120
+ class OAuthLoginRequest(BaseModel):
121
+ provider: Literal["google", "apple", "facebook"]
122
+ token: str
123
+ remember_me: Optional[bool] = False
124
+ device_info: Optional[str] = None
125
+
126
+ # JWT Token response format with enhanced security info
127
+ class TokenResponse(BaseModel):
128
+ access_token: str
129
+ token_type: str = "bearer"
130
+ expires_in: Optional[int] = None
131
+ refresh_token: Optional[str] = None
132
+ customer_id: Optional[str] = None
133
+ name: Optional[str] = None
134
+ email: Optional[str] = None
135
+ profile_picture: Optional[str] = None
136
+ auth_method: Optional[str] = None # "otp" or "oauth"
137
+ provider: Optional[str] = None # For OAuth logins
138
+ user_exists: Optional[bool] = None # Indicates if user already exists for OAuth
139
+ security_info: Optional[Dict[str, Any]] = None
140
+
141
+ # Enhanced user profile response with social accounts
142
+ class UserProfileResponse(BaseModel):
143
+ customer_id: str
144
+ name: str
145
+ email: Optional[EmailStr] = None
146
+ phone: Optional[str] = None
147
+ profile_picture: Optional[str] = None
148
+ auth_method: str
149
+ created_at: datetime
150
+ social_accounts: Optional[List[Dict[str, Any]]] = None
151
+ security_info: Optional[Dict[str, Any]] = None
152
+
153
+ # Social account information
154
+ class SocialAccountInfo(BaseModel):
155
+ provider: str
156
+ email: Optional[str] = None
157
+ name: Optional[str] = None
158
+ linked_at: datetime
159
+ last_login: Optional[datetime] = None
160
+
161
+ # Social account summary response
162
+ class SocialAccountSummary(BaseModel):
163
+ linked_accounts: List[SocialAccountInfo]
164
+ total_accounts: int
165
+ profile_picture: Optional[str] = None
166
+
167
+ # Account linking request
168
+ class LinkSocialAccountRequest(BaseModel):
169
+ provider: Literal["google", "apple", "facebook"]
170
+ token: str
171
+
172
+ # Account unlinking request
173
+ class UnlinkSocialAccountRequest(BaseModel):
174
+ provider: Literal["google", "apple", "facebook"]
175
+
176
+ # Login history entry
177
+ class LoginHistoryEntry(BaseModel):
178
+ timestamp: datetime
179
+ method: str # "otp" or "oauth"
180
+ provider: Optional[str] = None
181
+ ip_address: Optional[str] = None
182
+ success: bool
183
+ device_info: Optional[str] = None
184
+
185
+ # Login history response
186
+ class LoginHistoryResponse(BaseModel):
187
+ entries: List[LoginHistoryEntry]
188
+ total_entries: int
189
+ page: int
190
+ per_page: int
191
+
192
+ # Security settings response
193
+ class SecuritySettingsResponse(BaseModel):
194
+ two_factor_enabled: bool = False
195
+ linked_social_accounts: int
196
+ last_password_change: Optional[datetime] = None
197
+ recent_login_attempts: int
198
+ account_locked: bool = False
app/schemas/wallet_schema.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pydantic import BaseModel, Field
2
+ from datetime import datetime
3
+ from typing import List, Optional, Literal
4
+ from decimal import Decimal
5
+
6
+ class WalletBalanceResponse(BaseModel):
7
+ """Response model for wallet balance"""
8
+ balance: float = Field(..., description="Current wallet balance")
9
+ currency: str = Field(default="INR", description="Currency code")
10
+ formatted_balance: str = Field(..., description="Formatted balance with currency symbol")
11
+
12
+ class TransactionEntry(BaseModel):
13
+ """Model for individual transaction entry"""
14
+ transaction_id: str = Field(..., description="Unique transaction ID")
15
+ amount: float = Field(..., description="Transaction amount")
16
+ transaction_type: Literal["credit", "debit", "refund", "cashback", "payment", "withdrawal"] = Field(..., description="Type of transaction")
17
+ description: str = Field(..., description="Transaction description")
18
+ reference_id: Optional[str] = Field(None, description="Reference ID for the transaction")
19
+ balance_before: float = Field(..., description="Balance before transaction")
20
+ balance_after: float = Field(..., description="Balance after transaction")
21
+ timestamp: datetime = Field(..., description="Transaction timestamp")
22
+ status: Literal["completed", "pending", "failed"] = Field(default="completed", description="Transaction status")
23
+
24
+ class TransactionHistoryResponse(BaseModel):
25
+ """Response model for transaction history"""
26
+ transactions: List[TransactionEntry] = Field(..., description="List of transactions")
27
+ total_count: int = Field(..., description="Total number of transactions")
28
+ page: int = Field(..., description="Current page number")
29
+ per_page: int = Field(..., description="Number of items per page")
30
+ total_pages: int = Field(..., description="Total number of pages")
31
+
32
+ class WalletSummaryResponse(BaseModel):
33
+ """Response model for wallet summary"""
34
+ balance: float = Field(..., description="Current wallet balance")
35
+ formatted_balance: str = Field(..., description="Formatted balance with currency symbol")
36
+ recent_transactions: List[TransactionEntry] = Field(..., description="Recent transactions")
37
+
38
+ class AddMoneyRequest(BaseModel):
39
+ """Request model for adding money to wallet"""
40
+ amount: float = Field(..., gt=0, description="Amount to add (must be positive)")
41
+ payment_method: Literal["card", "upi", "netbanking"] = Field(..., description="Payment method")
42
+ reference_id: Optional[str] = Field(None, description="Payment reference ID")
43
+ description: Optional[str] = Field("Wallet top-up", description="Transaction description")
44
+
45
+ class WithdrawMoneyRequest(BaseModel):
46
+ """Request model for withdrawing money from wallet"""
47
+ amount: float = Field(..., gt=0, description="Amount to withdraw (must be positive)")
48
+ bank_account_id: str = Field(..., description="Bank account ID for withdrawal")
49
+ description: Optional[str] = Field("Wallet withdrawal", description="Transaction description")
50
+
51
+ class TransactionRequest(BaseModel):
52
+ """Generic transaction request model"""
53
+ amount: float = Field(..., gt=0, description="Transaction amount")
54
+ transaction_type: Literal["credit", "debit", "refund", "cashback", "payment"] = Field(..., description="Transaction type")
55
+ description: str = Field(..., description="Transaction description")
56
+ reference_id: Optional[str] = Field(None, description="Reference ID")
57
+ category: Optional[str] = Field(None, description="Transaction category")
58
+
59
+ class TransactionResponse(BaseModel):
60
+ """Response model for transaction operations"""
61
+ success: bool = Field(..., description="Transaction success status")
62
+ message: str = Field(..., description="Response message")
63
+ transaction: Optional[TransactionEntry] = Field(None, description="Transaction details")
64
+ new_balance: Optional[float] = Field(None, description="New wallet balance after transaction")
65
+
66
+ class WalletTransactionResponse(BaseModel):
67
+ """Response model for wallet transaction operations"""
68
+ success: bool = Field(..., description="Transaction success status")
69
+ message: str = Field(..., description="Response message")
70
+ transaction_id: Optional[str] = Field(None, description="Transaction ID if successful")
71
+ new_balance: Optional[float] = Field(None, description="New wallet balance after transaction")
app/services/__init__.py ADDED
File without changes
app/services/account_service.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime, timedelta
2
+ from typing import List, Dict, Any, Optional
3
+ import logging
4
+ from bson import ObjectId
5
+
6
+ from app.models.social_account_model import SocialAccountModel
7
+ from app.models.user_model import BookMyServiceUserModel
8
+ from app.schemas.user_schema import (
9
+ SocialAccountSummary, SocialAccountInfo, LoginHistoryResponse,
10
+ LoginHistoryEntry, SecuritySettingsResponse
11
+ )
12
+ from app.utils.social_utils import verify_google_token, verify_apple_token, verify_facebook_token
13
+ from app.core.nosql_client import db
14
+
15
+ # Configure logging
16
+ logger = logging.getLogger(__name__)
17
+
18
+ class AccountService:
19
+ """Service for managing user accounts, social accounts, and security settings"""
20
+
21
+ def __init__(self):
22
+ self.security_collection = db.get_collection("security_logs")
23
+ self.device_collection = db.get_collection("device_tracking")
24
+ self.session_collection = db.get_collection("user_sessions")
25
+
26
+ async def get_social_account_summary(self, customer_id: str) -> SocialAccountSummary:
27
+ """Get summary of all linked social accounts for a user"""
28
+ try:
29
+ social_accounts = await SocialAccountModel.find_by_customer_id(customer_id)
30
+
31
+ linked_accounts = []
32
+ profile_picture = None
33
+
34
+ for account in social_accounts:
35
+ account_info = SocialAccountInfo(
36
+ provider=account["provider"],
37
+ email=account.get("email"),
38
+ name=account.get("name"),
39
+ linked_at=account["created_at"],
40
+ last_login=account.get("last_login")
41
+ )
42
+ linked_accounts.append(account_info)
43
+
44
+ # Use the first available profile picture
45
+ if not profile_picture and account.get("profile_picture"):
46
+ profile_picture = account["profile_picture"]
47
+
48
+ return SocialAccountSummary(
49
+ linked_accounts=linked_accounts,
50
+ total_accounts=len(linked_accounts),
51
+ profile_picture=profile_picture
52
+ )
53
+
54
+ except Exception as e:
55
+ logger.error(f"Error getting social account summary for user {customer_id}: {str(e)}")
56
+ raise
57
+
58
+ async def link_social_account(self, customer_id: str, provider: str, token: str, client_ip: str) -> Dict[str, Any]:
59
+ """Link a new social account to an existing user"""
60
+ try:
61
+ # Verify the token and get user info
62
+ user_info = await self._verify_social_token(provider, token)
63
+
64
+ # Check if this social account is already linked to another user
65
+ existing_account = await SocialAccountModel.find_by_provider_id(
66
+ provider, user_info["id"]
67
+ )
68
+
69
+ if existing_account and existing_account["customer_id"] != customer_id:
70
+ raise ValueError(f"This {provider} account is already linked to another user")
71
+
72
+ # Check if user already has this provider linked
73
+ user_provider_account = await SocialAccountModel.find_by_user_and_provider(
74
+ customer_id, provider
75
+ )
76
+
77
+ if user_provider_account:
78
+ # Update existing account
79
+ await SocialAccountModel.update_social_account(
80
+ customer_id, provider, user_info, client_ip
81
+ )
82
+ action = "updated"
83
+ else:
84
+ # Create new social account link
85
+ await SocialAccountModel.create_social_account(
86
+ customer_id, provider, user_info, client_ip
87
+ )
88
+ action = "linked"
89
+
90
+ # Log the action
91
+ await self._log_account_action(
92
+ customer_id, f"social_account_{action}",
93
+ {"provider": provider, "client_ip": client_ip}
94
+ )
95
+
96
+ return {"action": action, "provider": provider, "user_info": user_info}
97
+
98
+ except Exception as e:
99
+ logger.error(f"Error linking social account for user {customer_id}: {str(e)}")
100
+ raise
101
+
102
+ async def unlink_social_account(self, customer_id: str, provider: str) -> Dict[str, Any]:
103
+ """Unlink a social account from a user"""
104
+ try:
105
+ # Check if account exists
106
+ account = await SocialAccountModel.find_by_user_and_provider(customer_id, provider)
107
+ if not account:
108
+ raise ValueError(f"No {provider} account found for this user")
109
+
110
+ # Check if this is the only authentication method
111
+ user = await BookMyServiceUserModel.find_by_id(customer_id)
112
+ if not user:
113
+ raise ValueError("User not found")
114
+
115
+ # Count total social accounts
116
+ social_accounts = await SocialAccountModel.find_by_customer_id(customer_id)
117
+
118
+ # If user has no phone/email and this is their only social account, prevent unlinking
119
+ if (len(social_accounts) == 1 and
120
+ not user.get("phone") and not user.get("email")):
121
+ raise ValueError("Cannot unlink the only authentication method")
122
+
123
+ # Unlink the account
124
+ result = await SocialAccountModel.unlink_social_account(customer_id, provider)
125
+
126
+ # Log the action
127
+ await self._log_account_action(
128
+ customer_id, "social_account_unlinked",
129
+ {"provider": provider}
130
+ )
131
+
132
+ return {"action": "unlinked", "provider": provider, "result": result}
133
+
134
+ except Exception as e:
135
+ logger.error(f"Error unlinking social account for user {customer_id}: {str(e)}")
136
+ raise
137
+
138
+ async def get_login_history(self, customer_id: str, page: int = 1,
139
+ per_page: int = 10, days: int = 30) -> LoginHistoryResponse:
140
+ """Get login history for a user"""
141
+ try:
142
+ # Calculate date range
143
+ end_date = datetime.utcnow()
144
+ start_date = end_date - timedelta(days=days)
145
+
146
+ # Query security logs for login events
147
+ skip = (page - 1) * per_page
148
+
149
+ pipeline = [
150
+ {
151
+ "$match": {
152
+ "customer_id": customer_id,
153
+ "timestamp": {"$gte": start_date, "$lte": end_date},
154
+ "$or": [
155
+ {"path": {"$regex": "/login"}},
156
+ {"path": {"$regex": "/oauth"}},
157
+ {"path": {"$regex": "/otp"}}
158
+ ]
159
+ }
160
+ },
161
+ {"$sort": {"timestamp": -1}},
162
+ {"$skip": skip},
163
+ {"$limit": per_page}
164
+ ]
165
+
166
+ cursor = self.security_collection.aggregate(pipeline)
167
+ logs = await cursor.to_list(length=per_page)
168
+
169
+ # Count total entries
170
+ total_count = await self.security_collection.count_documents({
171
+ "customer_id": customer_id,
172
+ "timestamp": {"$gte": start_date, "$lte": end_date},
173
+ "$or": [
174
+ {"path": {"$regex": "/login"}},
175
+ {"path": {"$regex": "/oauth"}},
176
+ {"path": {"$regex": "/otp"}}
177
+ ]
178
+ })
179
+
180
+ # Convert to response format
181
+ entries = []
182
+ for log in logs:
183
+ method = "oauth" if "oauth" in log["path"] else "otp"
184
+ provider = None
185
+
186
+ # Extract provider from query params if available
187
+ if method == "oauth" and log.get("query_params"):
188
+ provider = log["query_params"].get("provider")
189
+
190
+ entry = LoginHistoryEntry(
191
+ timestamp=log["timestamp"],
192
+ method=method,
193
+ provider=provider,
194
+ ip_address=log.get("client_ip"),
195
+ success=log["status_code"] < 400,
196
+ device_info=log.get("device_info", {}).get("user_agent")
197
+ )
198
+ entries.append(entry)
199
+
200
+ return LoginHistoryResponse(
201
+ entries=entries,
202
+ total_entries=total_count,
203
+ page=page,
204
+ per_page=per_page
205
+ )
206
+
207
+ except Exception as e:
208
+ logger.error(f"Error getting login history for user {customer_id}: {str(e)}")
209
+ raise
210
+
211
+ async def get_security_settings(self, customer_id: str) -> SecuritySettingsResponse:
212
+ """Get security settings and status for a user"""
213
+ try:
214
+ # Get user info
215
+ user = await BookMyServiceUserModel.find_by_id(customer_id)
216
+ if not user:
217
+ raise ValueError("User not found")
218
+
219
+ # Count linked social accounts
220
+ social_accounts = await SocialAccountModel.find_by_customer_id(customer_id)
221
+ linked_accounts_count = len(social_accounts)
222
+
223
+ # Get recent login attempts (last 24 hours)
224
+ yesterday = datetime.utcnow() - timedelta(days=1)
225
+ recent_attempts = await self.security_collection.count_documents({
226
+ "customer_id": customer_id,
227
+ "timestamp": {"$gte": yesterday},
228
+ "$or": [
229
+ {"path": {"$regex": "/login"}},
230
+ {"path": {"$regex": "/oauth"}},
231
+ {"path": {"$regex": "/otp"}}
232
+ ]
233
+ })
234
+
235
+ # Check if account is locked (this would be implemented based on your locking logic)
236
+ account_locked = False # Implement based on your account locking mechanism
237
+
238
+ return SecuritySettingsResponse(
239
+ two_factor_enabled=False, # Implement 2FA if needed
240
+ linked_social_accounts=linked_accounts_count,
241
+ last_password_change=None, # Implement if you have password functionality
242
+ recent_login_attempts=recent_attempts,
243
+ account_locked=account_locked
244
+ )
245
+
246
+ except Exception as e:
247
+ logger.error(f"Error getting security settings for user {customer_id}: {str(e)}")
248
+ raise
249
+
250
+ async def merge_social_accounts(self, primary_customer_id: str, secondary_customer_id: str,
251
+ client_ip: str) -> Dict[str, Any]:
252
+ """Merge social accounts from secondary user to primary user"""
253
+ try:
254
+ # Get social accounts from secondary user
255
+ secondary_accounts = await SocialAccountModel.find_by_customer_id(secondary_customer_id)
256
+
257
+ merged_count = 0
258
+ for account in secondary_accounts:
259
+ # Check if primary user already has this provider
260
+ existing = await SocialAccountModel.find_by_user_and_provider(
261
+ primary_customer_id, account["provider"]
262
+ )
263
+
264
+ if not existing:
265
+ # Transfer the account to primary user
266
+ await SocialAccountModel.update_customer_id(
267
+ account["_id"], primary_customer_id
268
+ )
269
+ merged_count += 1
270
+
271
+ # Log the merge action
272
+ await self._log_account_action(
273
+ primary_customer_id, "accounts_merged",
274
+ {
275
+ "secondary_customer_id": secondary_customer_id,
276
+ "merged_accounts": merged_count,
277
+ "client_ip": client_ip
278
+ }
279
+ )
280
+
281
+ return {
282
+ "merged_accounts": merged_count,
283
+ "primary_customer_id": primary_customer_id,
284
+ "secondary_customer_id": secondary_customer_id
285
+ }
286
+
287
+ except Exception as e:
288
+ logger.error(f"Error merging accounts {secondary_customer_id} -> {primary_customer_id}: {str(e)}")
289
+ raise
290
+
291
+ async def revoke_all_sessions(self, customer_id: str, client_ip: str) -> Dict[str, Any]:
292
+ """Revoke all active sessions for a user"""
293
+ try:
294
+ # In a real implementation, you'd have a sessions collection
295
+ # For now, we'll just log the action
296
+ await self._log_account_action(
297
+ customer_id, "all_sessions_revoked",
298
+ {"client_ip": client_ip}
299
+ )
300
+
301
+ # Here you would typically:
302
+ # 1. Delete all session tokens from database
303
+ # 2. Add tokens to a blacklist
304
+ # 3. Force re-authentication on next request
305
+
306
+ return {"action": "revoked", "customer_id": customer_id}
307
+
308
+ except Exception as e:
309
+ logger.error(f"Error revoking sessions for user {customer_id}: {str(e)}")
310
+ raise
311
+
312
+ async def get_trusted_devices(self, customer_id: str) -> List[Dict[str, Any]]:
313
+ """Get list of trusted devices for a user"""
314
+ try:
315
+ cursor = self.device_collection.find({
316
+ "customer_id": customer_id,
317
+ "is_trusted": True
318
+ }).sort("last_seen", -1)
319
+
320
+ devices = await cursor.to_list(length=None)
321
+
322
+ # Format device information
323
+ trusted_devices = []
324
+ for device in devices:
325
+ device_info = {
326
+ "device_id": str(device["_id"]),
327
+ "device_fingerprint": device["device_fingerprint"],
328
+ "platform": device.get("device_info", {}).get("platform", "Unknown"),
329
+ "browser": device.get("device_info", {}).get("browser", "Unknown"),
330
+ "first_seen": device["first_seen"],
331
+ "last_seen": device["last_seen"],
332
+ "access_count": device.get("access_count", 0)
333
+ }
334
+ trusted_devices.append(device_info)
335
+
336
+ return trusted_devices
337
+
338
+ except Exception as e:
339
+ logger.error(f"Error getting trusted devices for user {customer_id}: {str(e)}")
340
+ raise
341
+
342
+ async def remove_trusted_device(self, customer_id: str, device_id: str) -> Dict[str, Any]:
343
+ """Remove a trusted device"""
344
+ try:
345
+ result = await self.device_collection.update_one(
346
+ {
347
+ "_id": ObjectId(device_id),
348
+ "customer_id": customer_id
349
+ },
350
+ {"$set": {"is_trusted": False}}
351
+ )
352
+
353
+ if result.matched_count == 0:
354
+ raise ValueError("Device not found or not owned by user")
355
+
356
+ await self._log_account_action(
357
+ customer_id, "trusted_device_removed",
358
+ {"device_id": device_id}
359
+ )
360
+
361
+ return {"action": "removed", "device_id": device_id}
362
+
363
+ except Exception as e:
364
+ logger.error(f"Error removing trusted device for user {customer_id}: {str(e)}")
365
+ raise
366
+
367
+ async def _verify_social_token(self, provider: str, token: str) -> Dict[str, Any]:
368
+ """Verify social media token and return user info"""
369
+ try:
370
+ if provider == "google":
371
+ return await verify_google_token(token)
372
+ elif provider == "apple":
373
+ return await verify_apple_token(token)
374
+ elif provider == "facebook":
375
+ return await verify_facebook_token(token)
376
+ else:
377
+ raise ValueError(f"Unsupported provider: {provider}")
378
+ except Exception as e:
379
+ logger.error(f"Token verification failed for {provider}: {str(e)}")
380
+ raise ValueError(f"Invalid {provider} token")
381
+
382
+ async def _log_account_action(self, customer_id: str, action: str, details: Dict[str, Any]):
383
+ """Log account-related actions for audit purposes"""
384
+ try:
385
+ log_entry = {
386
+ "timestamp": datetime.utcnow(),
387
+ "customer_id": customer_id,
388
+ "action": action,
389
+ "details": details,
390
+ "type": "account_management"
391
+ }
392
+
393
+ await self.security_collection.insert_one(log_entry)
394
+
395
+ except Exception as e:
396
+ logger.error(f"Failed to log account action: {str(e)}")
app/services/favorite_service.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException, Depends
2
+ from typing import Optional
3
+ from app.models.favorite_model import BookMyServiceFavoriteModel
4
+ from app.schemas.favorite_schema import (
5
+ FavoriteCreateRequest,
6
+ FavoriteUpdateRequest,
7
+ FavoriteResponse,
8
+ FavoritesListResponse,
9
+ FavoriteStatusResponse,
10
+ FavoriteSuccessResponse,
11
+ FavoriteDataResponse
12
+ )
13
+ from app.services.user_service import UserService
14
+ import logging
15
+
16
+ logger = logging.getLogger("favorite_service")
17
+
18
+ class FavoriteService:
19
+
20
+ @staticmethod
21
+ async def add_favorite(
22
+ customer_id: str,
23
+ favorite_data: FavoriteCreateRequest
24
+ ) -> FavoriteSuccessResponse:
25
+ """Add a merchant to user's favorites"""
26
+ try:
27
+ # Create favorite in database
28
+ favorite_merchant_id= await BookMyServiceFavoriteModel.create_favorite(
29
+ customer_id,
30
+ favorite_data.dict()
31
+ )
32
+
33
+ return FavoriteSuccessResponse(
34
+ success=True,
35
+ message="Merchant added to favorites successfully",
36
+ merchant_id=favorite_merchant_id
37
+ )
38
+
39
+ except HTTPException as e:
40
+ raise e
41
+ except Exception as e:
42
+ logger.error(f"Error adding favorite: {str(e)}", exc_info=True)
43
+ raise HTTPException(status_code=500, detail="Failed to add favorite")
44
+
45
+ @staticmethod
46
+ async def remove_favorite(customer_id: str, merchant_id: str) -> FavoriteSuccessResponse:
47
+ """Remove a merchant from user's favorites"""
48
+ try:
49
+ await BookMyServiceFavoriteModel.delete_favorite(customer_id, merchant_id)
50
+
51
+ return FavoriteSuccessResponse(
52
+ success=True,
53
+ message="Merchant removed from favorites successfully",
54
+ merchant_id=None
55
+ )
56
+
57
+ except HTTPException as e:
58
+ raise e
59
+ except Exception as e:
60
+ logger.error(f"Error removing favorite: {str(e)}", exc_info=True)
61
+ raise HTTPException(status_code=500, detail="Failed to remove favorite")
62
+
63
+ @staticmethod
64
+ async def get_favorites(
65
+ customer_id: str,
66
+ limit: int = 50
67
+ ) -> FavoritesListResponse:
68
+ """Get user's favorite merchants"""
69
+ try:
70
+ result = await BookMyServiceFavoriteModel.get_favorites(customer_id,limit )
71
+
72
+ # Convert MongoDB documents to response format
73
+ favorites = []
74
+ for fav in result["favorites"]:
75
+ favorites.append(FavoriteResponse(
76
+ merchant_id=fav["merchant_id"],
77
+ merchant_category=fav["merchant_category"],
78
+ merchant_name=fav["merchant_name"],
79
+ source=fav["source"],
80
+ added_at=fav["added_at"],
81
+ notes=fav.get("notes")
82
+ ))
83
+
84
+ return FavoritesListResponse(
85
+ favorites=favorites,
86
+ total_count=result["total_count"],
87
+ limit=result["limit"]
88
+ )
89
+
90
+ except Exception as e:
91
+ logger.error(f"Error getting favorites: {str(e)}", exc_info=True)
92
+ raise HTTPException(status_code=500, detail="Failed to get favorites")
93
+
94
+ @staticmethod
95
+ async def check_favorite_status(customer_id: str, merchant_id: str) -> FavoriteStatusResponse:
96
+ """Check if a merchant is in user's favorites"""
97
+ try:
98
+ favorite_data = await BookMyServiceFavoriteModel.get_favorite(customer_id, merchant_id)
99
+
100
+ return FavoriteStatusResponse(
101
+ is_favorite=bool(favorite_data),
102
+ merchant_id=merchant_id
103
+ )
104
+
105
+ except Exception as e:
106
+ logger.error(f"Error checking favorite status: {str(e)}", exc_info=True)
107
+ raise HTTPException(status_code=500, detail="Failed to check favorite status")
108
+
109
+ @staticmethod
110
+ async def update_favorite_notes(
111
+ customer_id: str,
112
+ merchant_id: str,
113
+ notes_data: FavoriteUpdateRequest
114
+ ) -> FavoriteSuccessResponse:
115
+ """Update notes for a favorite merchant"""
116
+ try:
117
+ await BookMyServiceFavoriteModel.update_favorite_notes(
118
+ customer_id=customer_id,
119
+ merchant_id=merchant_id,
120
+ notes=notes_data.notes
121
+ )
122
+
123
+ return FavoriteSuccessResponse(
124
+ success=True,
125
+ message="Favorite notes updated successfully",
126
+ merchant_id=merchant_id
127
+ )
128
+
129
+ except HTTPException as e:
130
+ raise e
131
+ except Exception as e:
132
+ logger.error(f"Error updating favorite notes: {str(e)}", exc_info=True)
133
+ raise HTTPException(status_code=500, detail="Failed to update favorite notes")
134
+
135
+ @staticmethod
136
+ async def get_favorite_details(customer_id: str, merchant_id: str)->FavoriteDataResponse:
137
+ """Get detailed information about a specific favorite"""
138
+ try:
139
+ favorite = await BookMyServiceFavoriteModel.get_favorite(customer_id, merchant_id)
140
+
141
+ if favorite:
142
+ return FavoriteDataResponse(
143
+ success=True,
144
+ message="Favorite found successfully",
145
+ favorite_data=favorite
146
+ )
147
+ else:
148
+ return FavoriteDataResponse(
149
+ success=False,
150
+ message="Favorite not found",
151
+ favorite_data=None
152
+ )
153
+
154
+ except HTTPException as e:
155
+ raise e
156
+ except Exception as e:
157
+ logger.error(f"Error getting favorite details: {str(e)}", exc_info=True)
158
+ raise HTTPException(status_code=500, detail="Failed to get favorite details")
app/services/otp_service.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException
2
+ from app.core.cache_client import get_redis
3
+ from app.utils.sms_utils import send_sms_otp
4
+ from app.utils.email_utils import send_email_otp
5
+ from app.utils.common_utils import is_email
6
+
7
+ class BookMyServiceOTPModel:
8
+ OTP_TTL = 300 # 5 minutes
9
+ RATE_LIMIT_MAX = 3
10
+ RATE_LIMIT_WINDOW = 600 # 10 minutes
11
+
12
+ @staticmethod
13
+ async def store_otp(identifier: str, phone: str, otp: str, ttl: int = OTP_TTL):
14
+ redis = await get_redis()
15
+
16
+ rate_key = f"otp_rate_limit:{identifier}"
17
+ attempts = await redis.incr(rate_key)
18
+ if attempts == 1:
19
+ await redis.expire(rate_key, BookMyServiceOTPModel.RATE_LIMIT_WINDOW)
20
+ elif attempts > BookMyServiceOTPModel.RATE_LIMIT_MAX:
21
+ raise HTTPException(status_code=429, detail="Too many OTP requests. Try again later.")
22
+
23
+ await redis.setex(f"bms_otp:{identifier}", ttl, otp)
24
+
25
+
26
+
27
+
28
+ @staticmethod
29
+ async def verify_otp(identifier: str, otp: str):
30
+ redis = await get_redis()
31
+ key = f"bms_otp:{identifier}"
32
+ stored = await redis.get(key)
33
+ if stored and stored == otp:
34
+ await redis.delete(key)
35
+ return True
36
+ return False
app/services/profile_service.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Profile service for customer profile operations.
3
+ """
4
+
5
+ import logging
6
+ from typing import Optional, Dict, Any
7
+ from bson import ObjectId
8
+ from app.core.nosql_client import db
9
+ from fastapi import HTTPException, status
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class ProfileService:
14
+ """Service class for profile-related operations."""
15
+
16
+ @staticmethod
17
+ async def get_customer_profile(customer_id: str) -> Dict[str, Any]:
18
+ """
19
+ Fetch customer profile from the customers collection.
20
+
21
+ Args:
22
+ customer_id (str): The user ID from JWT token
23
+
24
+ Returns:
25
+ Dict[str, Any]: Customer profile data
26
+
27
+ Raises:
28
+ HTTPException: If customer not found or database error
29
+ """
30
+ try:
31
+ # Convert string ID to ObjectId if needed
32
+ if ObjectId.is_valid(customer_id):
33
+ query = {"_id": ObjectId(customer_id)}
34
+ else:
35
+ # If not a valid ObjectId, try searching by other fields
36
+ query = {"$or": [
37
+ {"customer_id": customer_id},
38
+ {"email": customer_id},
39
+ {"mobile": customer_id}
40
+ ]}
41
+
42
+ logger.info(f"Fetching profile for user: {customer_id}")
43
+
44
+ # Fetch customer from customers collection
45
+ customer = await db.customers.find_one(query)
46
+
47
+ if not customer:
48
+ logger.warning(f"Customer not found for customer_id: {customer_id}")
49
+ raise HTTPException(
50
+ status_code=status.HTTP_404_NOT_FOUND,
51
+ detail="Customer profile not found"
52
+ )
53
+
54
+ # Convert ObjectId to string for JSON serialization
55
+ if "_id" in customer:
56
+ customer["_id"] = str(customer["_id"])
57
+
58
+ logger.info(f"Successfully fetched profile for user: {customer_id}")
59
+ return customer
60
+
61
+ except HTTPException:
62
+ # Re-raise HTTP exceptions
63
+ raise
64
+ except Exception as e:
65
+ logger.error(f"Error fetching customer profile for user {customer_id}: {str(e)}")
66
+ raise HTTPException(
67
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
68
+ detail="Internal server error while fetching profile"
69
+ )
70
+
71
+ # Create service instance
72
+ profile_service = ProfileService()
app/services/user_service.py ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import uuid
3
+ from jose import jwt
4
+ from datetime import datetime, timedelta
5
+ from fastapi import HTTPException
6
+ from app.models.user_model import BookMyServiceUserModel
7
+ from app.models.otp_model import BookMyServiceOTPModel
8
+ from app.models.social_account_model import SocialAccountModel
9
+ from app.models.refresh_token_model import RefreshTokenModel
10
+ from app.core.config import settings
11
+ from app.utils.common_utils import is_email, validate_identifier
12
+ from app.utils.jwt import create_refresh_token
13
+ from app.schemas.user_schema import UserRegisterRequest
14
+ import logging
15
+
16
+ logger = logging.getLogger("user_service")
17
+
18
+
19
+
20
+
21
+ class UserService:
22
+ @staticmethod
23
+ async def send_otp(identifier: str, phone: str = None, client_ip: str = None):
24
+ logger.info(f"UserService.send_otp called - identifier: {identifier}, phone: {phone}, ip: {client_ip}")
25
+
26
+ try:
27
+ # Validate identifier format
28
+ identifier_type = validate_identifier(identifier)
29
+ logger.info(f"Identifier type: {identifier_type}")
30
+
31
+ # Enhanced rate limiting by IP and identifier
32
+ if client_ip:
33
+ ip_rate_key = f"otp_ip_rate:{client_ip}"
34
+ ip_attempts = await BookMyServiceOTPModel.get_rate_limit_count(ip_rate_key)
35
+ if ip_attempts >= 10: # Max 10 OTPs per IP per hour
36
+ logger.warning(f"IP rate limit exceeded for {client_ip}")
37
+ raise HTTPException(status_code=429, detail="Too many OTP requests from this IP")
38
+
39
+ # For phone identifiers, use the identifier itself as phone
40
+ # For email identifiers, use the provided phone parameter
41
+ if identifier_type == "phone":
42
+ phone_number = identifier
43
+ elif identifier_type == "email" and phone:
44
+ phone_number = phone
45
+ else:
46
+ # If email identifier but no phone provided, we'll send OTP via email
47
+ phone_number = None
48
+
49
+ # Generate OTP - hardcoded for testing purposes
50
+ otp = '777777'
51
+ logger.info(f"Generated hardcoded OTP for identifier: {identifier}")
52
+
53
+ await BookMyServiceOTPModel.store_otp(identifier, phone_number, otp)
54
+
55
+ # Track IP-based rate limiting
56
+ if client_ip:
57
+ await BookMyServiceOTPModel.increment_rate_limit(ip_rate_key, 3600) # 1 hour window
58
+
59
+ logger.info(f"OTP stored successfully for identifier: {identifier}")
60
+ logger.info(f"OTP sent to {identifier}")
61
+
62
+ except ValueError as ve:
63
+ logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
64
+ raise HTTPException(status_code=400, detail=str(ve))
65
+ except Exception as e:
66
+ logger.error(f"Error in send_otp for identifier {identifier}: {str(e)}", exc_info=True)
67
+ raise HTTPException(status_code=500, detail="Failed to send OTP")
68
+
69
+ @staticmethod
70
+ async def otp_login_handler(
71
+ identifier: str,
72
+ otp: str,
73
+ client_ip: str = None,
74
+ remember_me: bool = False,
75
+ device_info: str = None
76
+ ):
77
+ logger.info(f"UserService.otp_login_handler called - identifier: {identifier}, otp: {otp}, ip: {client_ip}, remember_me: {remember_me}")
78
+
79
+ try:
80
+ # Validate identifier format
81
+ identifier_type = validate_identifier(identifier)
82
+ logger.info(f"Identifier type: {identifier_type}")
83
+
84
+ # Check if account is locked
85
+ if await BookMyServiceOTPModel.is_account_locked(identifier):
86
+ logger.warning(f"Account locked for identifier: {identifier}")
87
+ raise HTTPException(status_code=423, detail="Account temporarily locked due to too many failed attempts")
88
+
89
+ # Verify OTP with client IP tracking
90
+ logger.info(f"Verifying OTP for identifier: {identifier}")
91
+ otp_valid = await BookMyServiceOTPModel.verify_otp(identifier, otp, client_ip)
92
+ logger.info(f"OTP verification result: {otp_valid}")
93
+
94
+ if not otp_valid:
95
+ logger.warning(f"Invalid or expired OTP for identifier: {identifier}")
96
+ # Track failed attempt
97
+ await BookMyServiceOTPModel.track_failed_attempt(identifier, client_ip)
98
+ raise HTTPException(status_code=400, detail="Invalid or expired OTP")
99
+
100
+ # Clear failed attempts on successful verification
101
+ await BookMyServiceOTPModel.clear_failed_attempts(identifier)
102
+ logger.info(f"OTP verification successful for identifier: {identifier}")
103
+
104
+ # Find user by identifier
105
+ logger.info(f"Looking up user by identifier: {identifier}")
106
+ user = await BookMyServiceUserModel.find_by_identifier(identifier)
107
+ logger.info(f"User lookup result: {user is not None}")
108
+
109
+ if not user:
110
+ logger.warning(f"No user found for identifier: {identifier}")
111
+ raise HTTPException(status_code=404, detail="User not found")
112
+
113
+ customer_id = user.get("customer_id")
114
+ logger.info(f"User found for identifier: {identifier}, customer_id: {customer_id}")
115
+
116
+ # Create token family for refresh token rotation
117
+ family_id = await RefreshTokenModel.create_token_family(customer_id, device_info)
118
+
119
+ # Create JWT access token
120
+ logger.info("Creating JWT token for authenticated user")
121
+ token_data = {
122
+ "sub": customer_id,
123
+ "exp": datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
124
+ }
125
+
126
+ access_token = jwt.encode(token_data, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
127
+
128
+ # Create refresh token with rotation support
129
+ refresh_token, token_id, expires_at = create_refresh_token(
130
+ {"sub": customer_id},
131
+ remember_me=remember_me,
132
+ family_id=family_id
133
+ )
134
+
135
+ # Store refresh token metadata
136
+ await RefreshTokenModel.store_refresh_token(
137
+ token_id=token_id,
138
+ customer_id=customer_id,
139
+ family_id=family_id,
140
+ expires_at=expires_at,
141
+ remember_me=remember_me,
142
+ device_info=device_info,
143
+ ip_address=client_ip
144
+ )
145
+
146
+ # Log generated tokens (truncated for security)
147
+ logger.info(f"Access token generated (first 25 chars): {access_token[:25]}...")
148
+ logger.info(f"Refresh token generated (first 25 chars): {refresh_token[:25]}...")
149
+ logger.info(f"JWT tokens created successfully for user: {customer_id}")
150
+
151
+ return {
152
+ "access_token": access_token,
153
+ "refresh_token": refresh_token,
154
+ "token_type": "bearer",
155
+ "expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
156
+ "customer_id": customer_id,
157
+ "name": user.get("name"),
158
+ "email": user.get("email"),
159
+ "profile_picture": user.get("profile_picture"),
160
+ "auth_method": user.get("auth_mode"),
161
+ "provider": None,
162
+ "security_info": None
163
+ }
164
+
165
+ except ValueError as ve:
166
+ logger.error(f"Validation error for identifier {identifier}: {str(ve)}")
167
+ raise HTTPException(status_code=400, detail=str(ve))
168
+ except HTTPException as e:
169
+ logger.error(f"HTTP error in otp_login_handler for {identifier}: {e.status_code} - {e.detail}")
170
+ raise e
171
+ except Exception as e:
172
+ logger.error(f"Unexpected error in otp_login_handler for {identifier}: {str(e)}", exc_info=True)
173
+ raise HTTPException(status_code=500, detail="Internal server error during login")
174
+
175
+ @staticmethod
176
+ async def register(data: UserRegisterRequest, decoded):
177
+ logger.info(f"Registering user with data: {data}")
178
+
179
+ # Validate mandatory fields for all registration modes
180
+ if not data.name or not data.name.strip():
181
+ raise HTTPException(status_code=400, detail="Name is required")
182
+
183
+ if not data.email:
184
+ raise HTTPException(status_code=400, detail="Email is required")
185
+
186
+ if not data.phone or not data.phone.strip():
187
+ raise HTTPException(status_code=400, detail="Phone is required")
188
+
189
+ if data.mode == "otp":
190
+ # Always use phone as the OTP identifier as per documentation
191
+ identifier = data.phone
192
+
193
+ # Validate phone format
194
+ try:
195
+ identifier_type = validate_identifier(identifier)
196
+ if identifier_type != "phone":
197
+ raise ValueError("Phone number format is invalid")
198
+ logger.info(f"Registration identifier type: {identifier_type}")
199
+ except ValueError as ve:
200
+ logger.error(f"Invalid phone format during registration: {str(ve)}")
201
+ raise HTTPException(status_code=400, detail=str(ve))
202
+
203
+ redis_key = f"bms_otp:{identifier}"
204
+ logger.info(f"Verifying OTP for Redis key: {redis_key}")
205
+
206
+ if not data.otp:
207
+ raise HTTPException(status_code=400, detail="OTP is required")
208
+
209
+ if not await BookMyServiceOTPModel.verify_otp(identifier, data.otp):
210
+ raise HTTPException(status_code=400, detail="Invalid or expired OTP")
211
+
212
+ customer_id = str(uuid.uuid4())
213
+
214
+ elif data.mode == "oauth":
215
+ # Validate OAuth-specific mandatory fields
216
+ if not data.oauth_token or not data.provider:
217
+ raise HTTPException(status_code=400, detail="OAuth token and provider are required")
218
+
219
+ # Extract user info from decoded token
220
+ user_info = decoded.get("user_info", {})
221
+ provider_customer_id = user_info.get("sub") or user_info.get("id")
222
+
223
+ if not provider_customer_id:
224
+ raise HTTPException(status_code=400, detail="Invalid OAuth user information")
225
+
226
+ # Check if this social account already exists
227
+ existing_social_account = await SocialAccountModel.find_by_provider_and_customer_id(
228
+ data.provider, provider_customer_id
229
+ )
230
+
231
+ if existing_social_account:
232
+ # User already has this social account linked
233
+ existing_user = await BookMyServiceUserModel.collection.find_one({
234
+ "customer_id": existing_social_account["customer_id"]
235
+ })
236
+ if existing_user:
237
+ # Update social account with latest info and return existing user token
238
+ await SocialAccountModel.update_social_account(data.provider, provider_customer_id, user_info)
239
+
240
+ token_data = {
241
+ "sub": existing_user["customer_id"],
242
+ "exp": datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
243
+ }
244
+ access_token = jwt.encode(token_data, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
245
+
246
+ # Create refresh token
247
+ refresh_token_data = {
248
+ "sub": existing_user["customer_id"],
249
+ "exp": datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS)
250
+ }
251
+ refresh_token = jwt.encode(refresh_token_data, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
252
+
253
+ # Log generated tokens for existing linked user (truncated)
254
+ logger.info(f"Access token for existing user (first 25 chars): {access_token[:25]}...")
255
+ logger.info(f"Refresh token for existing user (first 25 chars): {refresh_token[:25]}...")
256
+
257
+ return {
258
+ "access_token": access_token,
259
+ "refresh_token": refresh_token,
260
+ "token_type": "bearer",
261
+ "expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60
262
+ }
263
+
264
+ # Generate a new UUID for customer_id instead of provider-prefixed ID
265
+ customer_id = str(uuid.uuid4())
266
+
267
+ else:
268
+ raise HTTPException(status_code=400, detail="Unsupported registration mode")
269
+
270
+ # Check if user already exists
271
+ if await BookMyServiceUserModel.collection.find_one({"customer_id": customer_id}):
272
+ raise HTTPException(status_code=409, detail="User already registered")
273
+
274
+ # Check for existing email or phone
275
+ existing_user = await BookMyServiceUserModel.exists_by_email_or_phone(
276
+ email=data.email,
277
+ phone=data.phone
278
+ )
279
+ if existing_user:
280
+ raise HTTPException(status_code=409, detail="User with this email or phone already exists")
281
+
282
+ # Create user document
283
+ user_doc = {
284
+ "customer_id": customer_id,
285
+ "name": data.name,
286
+ "email": data.email,
287
+ "phone": data.phone,
288
+ "auth_mode": data.mode,
289
+ "created_at": datetime.utcnow()
290
+ }
291
+
292
+ # Add profile picture from social account if available
293
+ if data.mode == "oauth" and user_info.get("picture"):
294
+ user_doc["profile_picture"] = user_info["picture"]
295
+
296
+ await BookMyServiceUserModel.collection.insert_one(user_doc)
297
+ logger.info(f"Created new user: {customer_id}")
298
+
299
+ # Create social account record for OAuth registration using UUID customer_id
300
+ if data.mode == "oauth":
301
+ await SocialAccountModel.create_social_account(
302
+ customer_id, data.provider, provider_customer_id, user_info
303
+ )
304
+ logger.info(f"Created social account link for {data.provider} -> {customer_id}")
305
+
306
+ # Create token family for refresh token rotation
307
+ family_id = await RefreshTokenModel.create_token_family(customer_id, data.device_info)
308
+
309
+ token_data = {
310
+ "sub": customer_id,
311
+ "exp": datetime.utcnow() + timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
312
+ }
313
+
314
+ access_token = jwt.encode(token_data, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
315
+
316
+ # Create refresh token with rotation support
317
+ refresh_token, token_id, expires_at = create_refresh_token(
318
+ {"sub": customer_id},
319
+ remember_me=data.remember_me,
320
+ family_id=family_id
321
+ )
322
+
323
+ # Store refresh token metadata
324
+ await RefreshTokenModel.store_refresh_token(
325
+ token_id=token_id,
326
+ customer_id=customer_id,
327
+ family_id=family_id,
328
+ expires_at=expires_at,
329
+ remember_me=data.remember_me,
330
+ device_info=data.device_info,
331
+ ip_address=None # Can be passed from router if needed
332
+ )
333
+
334
+ # Log generated tokens for new registration (truncated)
335
+ logger.info(f"Access token on register (first 25 chars): {access_token[:25]}...")
336
+ logger.info(f"Refresh token on register (first 25 chars): {refresh_token[:25]}...")
337
+
338
+ return {
339
+ "access_token": access_token,
340
+ "refresh_token": refresh_token,
341
+ "token_type": "bearer",
342
+ "expires_in": settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES * 60,
343
+ "customer_id": customer_id,
344
+ "name": data.name,
345
+ "email": data.email,
346
+ "profile_picture": user_doc.get("profile_picture"),
347
+ "auth_method": data.mode,
348
+ "provider": data.provider if data.mode == "oauth" else None,
349
+ "security_info": None
350
+ }
app/services/wallet_service.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, Any, Optional
2
+ import logging
3
+ from datetime import datetime
4
+
5
+ from app.models.wallet_model import WalletModel
6
+ from app.schemas.wallet_schema import (
7
+ WalletBalanceResponse, WalletSummaryResponse, TransactionHistoryResponse,
8
+ TransactionEntry, WalletTransactionResponse
9
+ )
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class WalletService:
14
+ """Service for wallet operations"""
15
+
16
+ @staticmethod
17
+ async def get_wallet_balance(customer_id: str) -> WalletBalanceResponse:
18
+ """Get formatted wallet balance for user"""
19
+ try:
20
+ balance = await WalletModel.get_wallet_balance(customer_id)
21
+
22
+ return WalletBalanceResponse(
23
+ balance=balance,
24
+ currency="INR",
25
+ formatted_balance=f"₹{balance:,.2f}"
26
+ )
27
+
28
+ except Exception as e:
29
+ logger.error(f"Error getting wallet balance for user {customer_id}: {str(e)}")
30
+ return WalletBalanceResponse(
31
+ balance=0.0,
32
+ currency="INR",
33
+ formatted_balance="₹0.00"
34
+ )
35
+
36
+ @staticmethod
37
+ async def get_wallet_summary(customer_id: str) -> WalletSummaryResponse:
38
+ """Get wallet summary with balance and recent transactions"""
39
+ try:
40
+ summary_data = await WalletModel.get_wallet_summary(customer_id)
41
+
42
+ # Convert transactions to schema format
43
+ recent_transactions = []
44
+ for transaction in summary_data.get("recent_transactions", []):
45
+ recent_transactions.append(TransactionEntry(
46
+ transaction_id=transaction["_id"],
47
+ amount=transaction["amount"],
48
+ transaction_type=transaction["transaction_type"],
49
+ description=transaction["description"],
50
+ reference_id=transaction.get("reference_id"),
51
+ balance_before=transaction["balance_before"],
52
+ balance_after=transaction["balance_after"],
53
+ timestamp=transaction["timestamp"],
54
+ status=transaction["status"]
55
+ ))
56
+
57
+ balance = summary_data.get("balance", 0.0)
58
+
59
+ return WalletSummaryResponse(
60
+ balance=balance,
61
+ formatted_balance=f"₹{balance:,.2f}",
62
+ recent_transactions=recent_transactions
63
+ )
64
+
65
+ except Exception as e:
66
+ logger.error(f"Error getting wallet summary for user {customer_id}: {str(e)}")
67
+ return WalletSummaryResponse(
68
+ balance=0.0,
69
+ formatted_balance="₹0.00",
70
+ recent_transactions=[]
71
+ )
72
+
73
+ @staticmethod
74
+ async def get_transaction_history(customer_id: str, page: int = 1, per_page: int = 20) -> TransactionHistoryResponse:
75
+ """Get paginated transaction history"""
76
+ try:
77
+ history_data = await WalletModel.get_transaction_history(customer_id, page, per_page)
78
+
79
+ # Convert transactions to schema format
80
+ transactions = []
81
+ for transaction in history_data.get("transactions", []):
82
+ transactions.append(TransactionEntry(
83
+ transaction_id=transaction["_id"],
84
+ amount=transaction["amount"],
85
+ transaction_type=transaction["transaction_type"],
86
+ description=transaction["description"],
87
+ reference_id=transaction.get("reference_id"),
88
+ balance_before=transaction["balance_before"],
89
+ balance_after=transaction["balance_after"],
90
+ timestamp=transaction["timestamp"],
91
+ status=transaction["status"]
92
+ ))
93
+
94
+ return TransactionHistoryResponse(
95
+ transactions=transactions,
96
+ total_count=history_data.get("total_count", 0),
97
+ page=page,
98
+ per_page=per_page,
99
+ total_pages=history_data.get("total_pages", 0)
100
+ )
101
+
102
+ except Exception as e:
103
+ logger.error(f"Error getting transaction history for user {customer_id}: {str(e)}")
104
+ return TransactionHistoryResponse(
105
+ transactions=[],
106
+ total_count=0,
107
+ page=page,
108
+ per_page=per_page,
109
+ total_pages=0
110
+ )
111
+
112
+ @staticmethod
113
+ async def add_money(customer_id: str, amount: float, payment_method: str,
114
+ description: str = "Wallet top-up", reference_id: str = None) -> WalletTransactionResponse:
115
+ """Add money to wallet"""
116
+ try:
117
+ success = await WalletModel.update_balance(
118
+ customer_id=customer_id,
119
+ amount=amount,
120
+ transaction_type="credit",
121
+ description=f"{description} via {payment_method}",
122
+ reference_id=reference_id
123
+ )
124
+
125
+ if success:
126
+ new_balance = await WalletModel.get_wallet_balance(customer_id)
127
+ return WalletTransactionResponse(
128
+ success=True,
129
+ message=f"Successfully added ₹{amount:,.2f} to wallet",
130
+ transaction_id=reference_id,
131
+ new_balance=new_balance
132
+ )
133
+ else:
134
+ return WalletTransactionResponse(
135
+ success=False,
136
+ message="Failed to add money to wallet"
137
+ )
138
+
139
+ except Exception as e:
140
+ logger.error(f"Error adding money to wallet for user {customer_id}: {str(e)}")
141
+ return WalletTransactionResponse(
142
+ success=False,
143
+ message="Internal error occurred while adding money"
144
+ )
145
+
146
+ @staticmethod
147
+ async def deduct_money(customer_id: str, amount: float, description: str,
148
+ reference_id: str = None) -> WalletTransactionResponse:
149
+ """Deduct money from wallet (for payments)"""
150
+ try:
151
+ success = await WalletModel.update_balance(
152
+ customer_id=customer_id,
153
+ amount=amount,
154
+ transaction_type="debit",
155
+ description=description,
156
+ reference_id=reference_id
157
+ )
158
+
159
+ if success:
160
+ new_balance = await WalletModel.get_wallet_balance(customer_id)
161
+ return WalletTransactionResponse(
162
+ success=True,
163
+ message=f"Successfully deducted ₹{amount:,.2f} from wallet",
164
+ transaction_id=reference_id,
165
+ new_balance=new_balance
166
+ )
167
+ else:
168
+ return WalletTransactionResponse(
169
+ success=False,
170
+ message="Insufficient balance or transaction failed"
171
+ )
172
+
173
+ except Exception as e:
174
+ logger.error(f"Error deducting money from wallet for user {customer_id}: {str(e)}")
175
+ return WalletTransactionResponse(
176
+ success=False,
177
+ message="Internal error occurred while processing payment"
178
+ )
179
+
180
+ @staticmethod
181
+ async def process_refund(customer_id: str, amount: float, description: str,
182
+ reference_id: str = None) -> WalletTransactionResponse:
183
+ """Process refund to wallet"""
184
+ try:
185
+ success = await WalletModel.update_balance(
186
+ customer_id=customer_id,
187
+ amount=amount,
188
+ transaction_type="refund",
189
+ description=description,
190
+ reference_id=reference_id
191
+ )
192
+
193
+ if success:
194
+ new_balance = await WalletModel.get_wallet_balance(customer_id)
195
+ return WalletTransactionResponse(
196
+ success=True,
197
+ message=f"Refund of ₹{amount:,.2f} processed successfully",
198
+ transaction_id=reference_id,
199
+ new_balance=new_balance
200
+ )
201
+ else:
202
+ return WalletTransactionResponse(
203
+ success=False,
204
+ message="Failed to process refund"
205
+ )
206
+
207
+ except Exception as e:
208
+ logger.error(f"Error processing refund for user {customer_id}: {str(e)}")
209
+ return WalletTransactionResponse(
210
+ success=False,
211
+ message="Internal error occurred while processing refund"
212
+ )
app/utils/__init__.py ADDED
File without changes
app/utils/common_utils.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ def is_email(identifier: str) -> bool:
4
+ return re.match(r"[^@]+@[^@]+\.[^@]+", identifier) is not None
5
+
6
+ def is_phone(identifier: str) -> bool:
7
+ """
8
+ Validate phone number format. Supports:
9
+ - International format: +1234567890, +91-9876543210
10
+ - National format: 9876543210, (123) 456-7890
11
+ - With/without spaces, dashes, parentheses
12
+ """
13
+ # Remove all non-digit characters except +
14
+ cleaned = re.sub(r'[^\d+]', '', identifier)
15
+
16
+ # Check if it's a valid phone number (8-15 digits, optionally starting with +)
17
+ if re.match(r'^\+?[1-9]\d{7,14}$', cleaned):
18
+ return True
19
+ return False
20
+
21
+ def validate_identifier(identifier: str) -> str:
22
+ """
23
+ Validate and return the type of identifier (email or phone).
24
+ Raises ValueError if neither email nor phone format.
25
+ """
26
+ if is_email(identifier):
27
+ return "email"
28
+ elif is_phone(identifier):
29
+ return "phone"
30
+ else:
31
+ raise ValueError("Identifier must be a valid email address or phone number")
app/utils/db.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime,date
2
+ from decimal import Decimal
3
+ from typing import Any
4
+ from pydantic import BaseModel
5
+
6
+ def prepare_for_db(obj: Any) -> Any:
7
+ """
8
+ Recursively sanitizes the object to be MongoDB-compatible:
9
+ - Converts Decimal to float
10
+ - Converts datetime with tzinfo to naive datetime
11
+ - Converts Pydantic BaseModel to dict
12
+ """
13
+ if isinstance(obj, Decimal):
14
+ return float(obj)
15
+ elif isinstance(obj, date) and not isinstance(obj, datetime):
16
+ return datetime(obj.year, obj.month, obj.day)
17
+ elif isinstance(obj, datetime):
18
+ return obj.replace(tzinfo=None)
19
+ elif isinstance(obj, BaseModel):
20
+ return prepare_for_db(obj.dict())
21
+ elif isinstance(obj, dict):
22
+ return {k: prepare_for_db(v) for k, v in obj.items()}
23
+ elif isinstance(obj, list):
24
+ return [prepare_for_db(v) for v in obj]
25
+ else:
26
+ return obj
app/utils/email_utils.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import smtplib
2
+ from email.mime.text import MIMEText
3
+ from app.core.config import settings
4
+
5
+ async def send_email_otp(to_email: str, otp: str, timeout: float = 10.0):
6
+ msg = MIMEText(f"Your OTP is {otp}. It is valid for 5 minutes.")
7
+ msg["Subject"] = "Your One-Time Password"
8
+ msg["From"] = settings.SMTP_FROM
9
+ msg["To"] = to_email
10
+
11
+ server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=timeout)
12
+ server.connect(settings.SMTP_HOST, settings.SMTP_PORT)
13
+ server.starttls()
14
+ server.login(settings.SMTP_USER, settings.SMTP_PASS)
15
+ server.send_message(msg)
16
+ server.quit()
app/utils/jwt.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ ## bookmyservice-ums/app/utils/jwt.py
3
+
4
+ from jose import jwt, JWTError
5
+ from datetime import datetime, timedelta
6
+ from fastapi import Depends, HTTPException, status
7
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
8
+ from typing import Optional
9
+ from app.core.config import settings
10
+ import logging
11
+ import uuid
12
+
13
+ SECRET_KEY = settings.JWT_SECRET_KEY
14
+ ALGORITHM = settings.JWT_ALGORITHM
15
+ ACCESS_EXPIRE_MINUTES_DEFAULT = settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES
16
+ REFRESH_EXPIRE_DAYS_DEFAULT = settings.JWT_REFRESH_TOKEN_EXPIRE_DAYS
17
+ TEMP_EXPIRE_MINUTES_DEFAULT = settings.JWT_TEMP_TOKEN_EXPIRE_MINUTES
18
+
19
+ # Remember me settings
20
+ REMEMBER_ME_REFRESH_EXPIRE_DAYS = settings.JWT_REMEMBER_ME_EXPIRE_DAYS
21
+
22
+ # Security scheme
23
+ security = HTTPBearer()
24
+
25
+ # Module logger (app-level logging config applies)
26
+ logger = logging.getLogger(__name__)
27
+
28
+ def create_access_token(data: dict, expires_minutes: int = ACCESS_EXPIRE_MINUTES_DEFAULT):
29
+ to_encode = data.copy()
30
+ expire = datetime.utcnow() + timedelta(minutes=expires_minutes)
31
+ to_encode.update({"exp": expire})
32
+
33
+ # Avoid logging sensitive payload; log minimal context
34
+ logger.info(
35
+ "Creating access token",
36
+ )
37
+ logger.info(
38
+ "Access token claims keys=%s expires_at=%s",
39
+ list(to_encode.keys()),
40
+ expire.isoformat(),
41
+ )
42
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
43
+
44
+ def create_refresh_token(
45
+ data: dict,
46
+ expires_days: int = REFRESH_EXPIRE_DAYS_DEFAULT,
47
+ remember_me: bool = False,
48
+ family_id: Optional[str] = None
49
+ ):
50
+ """Create refresh token with rotation support"""
51
+ to_encode = data.copy()
52
+
53
+ # Use longer expiry for remember me
54
+ if remember_me:
55
+ expires_days = REMEMBER_ME_REFRESH_EXPIRE_DAYS
56
+
57
+ expire = datetime.utcnow() + timedelta(days=expires_days)
58
+
59
+ # Generate unique token ID for tracking
60
+ token_id = str(uuid.uuid4())
61
+
62
+ to_encode.update({
63
+ "exp": expire,
64
+ "type": "refresh",
65
+ "jti": token_id, # JWT ID for token tracking
66
+ "remember_me": remember_me
67
+ })
68
+
69
+ # Add family ID for rotation tracking
70
+ if family_id:
71
+ to_encode["family_id"] = family_id
72
+
73
+ logger.info("Creating refresh token")
74
+ logger.info(
75
+ "Refresh token claims keys=%s expires_at=%s remember_me=%s",
76
+ list(to_encode.keys()),
77
+ expire.isoformat(),
78
+ remember_me
79
+ )
80
+
81
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM), token_id, expire
82
+
83
+ def create_temp_token(data: dict, expires_minutes: int = TEMP_EXPIRE_MINUTES_DEFAULT):
84
+ logger.info("Creating temporary access token with short expiry")
85
+ return create_access_token(data, expires_minutes=expires_minutes)
86
+
87
+ def decode_token(token: str) -> dict:
88
+ try:
89
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
90
+ logger.info("Token decoded successfully")
91
+ logger.info("Decoded claims keys=%s", list(payload.keys()))
92
+ return payload
93
+ except JWTError as e:
94
+ logger.warning("Token decode failed: %s", str(e))
95
+ return {}
96
+
97
+ def verify_token(token: str) -> dict:
98
+ """
99
+ Verify and decode JWT token, raise HTTPException if invalid.
100
+ """
101
+ credentials_exception = HTTPException(
102
+ status_code=status.HTTP_401_UNAUTHORIZED,
103
+ detail="Could not validate credentials",
104
+ headers={"WWW-Authenticate": "Bearer"},
105
+ )
106
+
107
+ try:
108
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
109
+ customer_id: str = payload.get("sub")
110
+ if customer_id is None:
111
+ logger.warning("Verified token missing 'sub' claim")
112
+ raise credentials_exception
113
+ logger.info("Token verified for subject")
114
+ logger.info("Verified claims keys=%s", list(payload.keys()))
115
+ return payload
116
+ except JWTError as e:
117
+ logger.error("Token verification failed: %s", str(e))
118
+ raise credentials_exception
119
+
120
+ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> dict:
121
+ """
122
+ Dependency to get current authenticated user from JWT token.
123
+ """
124
+ token = credentials.credentials
125
+ logger.info("Authenticating request with Bearer token")
126
+ # Don't log raw tokens; log minimal metadata
127
+ logger.info("Bearer token length=%d", len(token) if token else 0)
128
+ return verify_token(token)
129
+
130
+ async def get_current_customer_id(current_user: dict = Depends(get_current_user)) -> str:
131
+ """
132
+ Dependency to get current user ID.
133
+ """
134
+ customer_id = current_user.get("sub")
135
+ logger.info("Resolved current customer id")
136
+ logger.info("Current customer id=%s", customer_id)
137
+ return customer_id
app/utils/logger.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import sys
3
+
4
+ def setup_logger(name):
5
+ """
6
+ Set up a logger with consistent formatting and settings
7
+
8
+ Args:
9
+ name (str): The name for the logger, typically __name__
10
+
11
+ Returns:
12
+ logging.Logger: Configured logger instance
13
+ """
14
+ logger = logging.getLogger(name)
15
+
16
+ # Only configure handlers if they don't exist
17
+ if not logger.handlers:
18
+ # Console handler
19
+ console_handler = logging.StreamHandler(sys.stdout)
20
+ console_format = logging.Formatter(
21
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
22
+ )
23
+ console_handler.setFormatter(console_format)
24
+ logger.addHandler(console_handler)
25
+
26
+ # Set level - could be read from environment variables
27
+ logger.setLevel(logging.INFO)
28
+
29
+ # Prevent propagation to root logger to avoid duplicate logs
30
+ logger.propagate = False
31
+
32
+ return logger
app/utils/sms_utils.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from twilio.rest import Client
2
+ from twilio.http.http_client import TwilioHttpClient
3
+ from app.core.config import settings
4
+
5
+ def send_sms_otp(phone: str, otp: str) -> str:
6
+ http_client = TwilioHttpClient(timeout=10) # 10 seconds timeout
7
+ client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN, http_client=http_client)
8
+
9
+ message = client.messages.create(
10
+ from_=settings.TWILIO_SMS_FROM,
11
+ body=f"Your OTP is {otp}",
12
+ to=phone
13
+ )
14
+
15
+ return message.sid