MukeshKapoor25 commited on
Commit
2b62410
·
1 Parent(s): cfd9177

feat(staff): Implement PostgreSQL integration for staff management and add validation schemas

Browse files
app/constants/validation.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Validation constants and regex patterns.
3
+ """
4
+
5
+ # Merchant ID validation
6
+ MERCHANT_ID_REGEX = r"^[a-zA-Z0-9_-]{3,50}$"
7
+
8
+ # URL validation
9
+ HTTPS_URL_REGEX = r"^https://[^\s]+$"
10
+
11
+ # Phone validation
12
+ PHONE_E164_REGEX = r"^\+?[1-9]\d{7,14}$"
13
+
14
+ # Email validation
15
+ EMAIL_REGEX = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
16
+
17
+ # User ID patterns
18
+ USER_ID_REGEX = r"^usr_[a-zA-Z0-9_-]{10,50}$"
19
+
20
+ # General alphanumeric patterns
21
+ ALPHANUMERIC_REGEX = r"^[a-zA-Z0-9]+$"
22
+ ALPHANUMERIC_WITH_SPACES_REGEX = r"^[a-zA-Z0-9\s]+$"
23
+
24
+ # Name validation
25
+ NAME_REGEX = r"^[a-zA-Z\s'-]+$"
26
+
27
+ # Password strength (at least 8 chars, 1 uppercase, 1 lowercase, 1 number)
28
+ PASSWORD_STRONG_REGEX = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[a-zA-Z\d@$!%*?&]{8,}$"
29
+
30
+ # Validation messages
31
+ INVALID_MERCHANT_ID_MSG = "Invalid merchant ID format"
32
+ INVALID_URL_MSG = "Invalid HTTPS URL format"
33
+ INVALID_PHONE_MSG = "Invalid phone number format (E.164)"
34
+ INVALID_EMAIL_MSG = "Invalid email format"
35
+ INVALID_USER_ID_MSG = "Invalid user ID format"
36
+ INVALID_PASSWORD_MSG = "Password must be at least 8 characters with uppercase, lowercase, and number"
app/core/config.py CHANGED
@@ -19,6 +19,15 @@ class Settings(BaseSettings):
19
  MONGODB_URI: str = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
20
  MONGODB_DB_NAME: str = os.getenv("MONGODB_DB_NAME", "pos_db")
21
 
 
 
 
 
 
 
 
 
 
22
  # Redis Configuration
23
  REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
24
  REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
@@ -65,6 +74,14 @@ class Settings(BaseSettings):
65
  extra="allow", # allows extra environment variables without error
66
  )
67
 
 
 
 
 
 
 
 
 
68
 
69
  # Global settings instance
70
  settings = Settings()
 
19
  MONGODB_URI: str = os.getenv("MONGODB_URI", "mongodb://localhost:27017")
20
  MONGODB_DB_NAME: str = os.getenv("MONGODB_DB_NAME", "pos_db")
21
 
22
+ # PostgreSQL Configuration (for trans schema sync)
23
+ POSTGRES_HOST: str = os.getenv("DB_HOST", "ep-sweet-surf-a1qeduoy.ap-southeast-1.aws.neon.tech")
24
+ POSTGRES_PORT: int = int(os.getenv("DB_PORT", "5432"))
25
+ POSTGRES_DB: str = os.getenv("DB_NAME", "cuatrolabs")
26
+ POSTGRES_USER: str = os.getenv("DB_USER", "trans_owner")
27
+ POSTGRES_PASSWORD: str = os.getenv("DB_PASSWORD", "BookMyService7")
28
+ POSTGRES_SSL_MODE: str = os.getenv("DB_SSLMODE", "disable")
29
+ POSTGRES_URI: Optional[str] = None
30
+
31
  # Redis Configuration
32
  REDIS_HOST: str = os.getenv("REDIS_HOST", "localhost")
33
  REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379"))
 
74
  extra="allow", # allows extra environment variables without error
75
  )
76
 
77
+ def __init__(self, **kwargs):
78
+ super().__init__(**kwargs)
79
+ # Build PostgreSQL URI from components
80
+ from urllib.parse import quote_plus
81
+ if all([self.POSTGRES_USER, self.POSTGRES_PASSWORD, self.POSTGRES_HOST, self.POSTGRES_DB]):
82
+ protocol = "postgresql+psycopg"
83
+ self.POSTGRES_URI = f"{protocol}://{self.POSTGRES_USER}:{quote_plus(self.POSTGRES_PASSWORD)}@{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}"
84
+
85
 
86
  # Global settings instance
87
  settings = Settings()
app/main.py CHANGED
@@ -9,6 +9,7 @@ import logging # TODO: Uncomment when package is available
9
  from app.core.config import settings
10
 
11
  from app.nosql import connect_to_mongo, close_mongo_connection
 
12
  from app.staff.controllers.router import router as staff_router
13
  from app.catalogues.controllers.router import router as catalogues_router
14
 
@@ -41,6 +42,7 @@ async def startup_event():
41
  """Initialize connections on startup"""
42
  logger.info("Starting POS Microservice")
43
  await connect_to_mongo()
 
44
  logger.info("POS Microservice started successfully")
45
 
46
 
@@ -49,6 +51,7 @@ async def shutdown_event():
49
  """Close connections on shutdown"""
50
  logger.info("Shutting down POS Microservice")
51
  await close_mongo_connection()
 
52
  logger.info("POS Microservice shut down successfully")
53
 
54
 
@@ -63,6 +66,8 @@ async def health_check():
63
  }
64
 
65
 
 
 
66
  app.include_router(staff_router, prefix="/api/v1")
67
  app.include_router(catalogues_router, prefix="/api/v1")
68
 
 
9
  from app.core.config import settings
10
 
11
  from app.nosql import connect_to_mongo, close_mongo_connection
12
+ from app.sql import connect_to_postgres, close_postgres_connection
13
  from app.staff.controllers.router import router as staff_router
14
  from app.catalogues.controllers.router import router as catalogues_router
15
 
 
42
  """Initialize connections on startup"""
43
  logger.info("Starting POS Microservice")
44
  await connect_to_mongo()
45
+ await connect_to_postgres()
46
  logger.info("POS Microservice started successfully")
47
 
48
 
 
51
  """Close connections on shutdown"""
52
  logger.info("Shutting down POS Microservice")
53
  await close_mongo_connection()
54
+ await close_postgres_connection()
55
  logger.info("POS Microservice shut down successfully")
56
 
57
 
 
66
  }
67
 
68
 
69
+ # Include routers
70
+ # Authentication is handled by auth-ms, POS just validates tokens
71
  app.include_router(staff_router, prefix="/api/v1")
72
  app.include_router(catalogues_router, prefix="/api/v1")
73
 
app/sql.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ PostgreSQL database connection management for POS microservice.
3
+ Used for syncing staff data to trans.pos_staff_ref table.
4
+ """
5
+ import logging
6
+ from typing import Optional
7
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine, AsyncSession
8
+ from sqlalchemy.orm import sessionmaker
9
+ from contextlib import asynccontextmanager
10
+ from sqlalchemy import text as sql_text
11
+
12
+ from app.core.config import settings
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Global engine instance
17
+ _engine: Optional[AsyncEngine] = None
18
+ _async_session_maker: Optional[sessionmaker] = None
19
+
20
+
21
+ async def connect_to_postgres():
22
+ """Initialize PostgreSQL connection pool."""
23
+ global _engine, _async_session_maker
24
+
25
+ if not settings.POSTGRES_URI:
26
+ logger.warning("PostgreSQL URI not configured. Staff sync to trans.pos_staff_ref will be disabled.")
27
+ return
28
+
29
+ try:
30
+ _engine = create_async_engine(
31
+ settings.POSTGRES_URI,
32
+ pool_pre_ping=True,
33
+ pool_size=10,
34
+ max_overflow=20,
35
+ echo=settings.DEBUG,
36
+ )
37
+
38
+ _async_session_maker = sessionmaker(
39
+ _engine,
40
+ class_=AsyncSession,
41
+ expire_on_commit=False,
42
+ )
43
+
44
+ # Test connection
45
+ async with _engine.begin() as conn:
46
+ await conn.execute("SELECT 1")
47
+
48
+ logger.info("PostgreSQL connection established successfully")
49
+
50
+ except Exception as e:
51
+ logger.error(f"Failed to connect to PostgreSQL: {e}")
52
+ _engine = None
53
+ _async_session_maker = None
54
+
55
+
56
+ async def close_postgres_connection():
57
+ """Close PostgreSQL connection pool."""
58
+ global _engine, _async_session_maker
59
+
60
+ if _engine:
61
+ try:
62
+ await _engine.dispose()
63
+ logger.info("PostgreSQL connection closed")
64
+ except Exception as e:
65
+ logger.error(f"Error closing PostgreSQL connection: {e}")
66
+ finally:
67
+ _engine = None
68
+ _async_session_maker = None
69
+
70
+
71
+ def get_postgres_engine() -> Optional[AsyncEngine]:
72
+ """Get PostgreSQL engine instance."""
73
+ return _engine
74
+
75
+
76
+ @asynccontextmanager
77
+ async def get_postgres_session():
78
+ """
79
+ Get PostgreSQL session context manager.
80
+
81
+ Usage:
82
+ async with get_postgres_session() as session:
83
+ await session.execute(...)
84
+ await session.commit()
85
+ """
86
+ if not _async_session_maker:
87
+ logger.warning("PostgreSQL session maker not initialized")
88
+ yield None
89
+ return
90
+
91
+ session = _async_session_maker()
92
+ try:
93
+ yield session
94
+ except Exception as e:
95
+ await session.rollback()
96
+ logger.error(f"Session error, rolled back: {e}")
97
+ raise
98
+ finally:
99
+ await session.close()
100
+
101
+
102
+ async def execute_postgres_query(query: str, params: dict = None):
103
+ """
104
+ Execute a raw SQL query.
105
+
106
+ Args:
107
+ query: SQL query string
108
+ params: Query parameters dict
109
+ """
110
+ if not _engine:
111
+ logger.warning("PostgreSQL engine not available, skipping query")
112
+ return None
113
+
114
+ async with get_postgres_session() as session:
115
+ if session is None:
116
+ return None
117
+ result = await session.execute(query, params or {})
118
+ await session.commit()
119
+ return result
app/staff/controllers/router.py CHANGED
@@ -1,69 +1,148 @@
1
  """
2
- Employee API router - FastAPI endpoints for employee operations.
 
3
  """
4
- from typing import Optional, List
5
- from fastapi import APIRouter, HTTPException, Query, Header, status
6
- # from insightfy_utils.logging import get_logger # TODO: Uncomment when package is available
7
  import logging
8
 
9
- from app.constants.employee_types import Designation, stafftatus
10
- from app.staff.schemas.schema import EmployeeCreate, EmployeeUpdate, EmployeeResponse
11
- from app.staff.services.service import staffervice
 
 
 
 
12
 
13
- # logger = get_logger(__name__) # TODO: Uncomment when insightfy_utils is available
14
  logger = logging.getLogger(__name__)
15
 
16
  router = APIRouter(
17
  prefix="/staff",
18
- tags=["staff"],
19
  responses={404: {"description": "Not found"}},
20
  )
21
 
22
 
23
  @router.post(
24
  "",
25
- response_model=EmployeeResponse,
26
  status_code=status.HTTP_201_CREATED,
27
- summary="Create a new employee",
28
- description="""
29
- Create a new employee with comprehensive validation:
30
- - Validates employee code uniqueness
31
- - Ensures email and phone uniqueness among active staff
32
- - Enforces manager hierarchy rules
33
- - Validates age requirements (minimum 18 years)
34
- - Validates DOB/DOJ consistency
35
- - Enforces 2FA for Admin/Finance/HR roles
36
- - Validates location tracking consent requirements
37
- """
38
  )
39
- async def create_employee(payload: EmployeeCreate) -> EmployeeResponse:
40
  """
41
- Create a new employee.
42
 
43
- **Business Rules:**
44
- - Employee code must be unique across all staff
45
- - Email must be unique among active staff
46
- - Phone must be unique among active staff
47
- - Employee must be at least 18 years old
48
- - DOJ cannot be more than 30 days in the future
49
- - ASM must have an RSM manager
50
- - BDE/Trainer must have ASM or RSM manager
51
- - RSM/ASM must have a region assigned
52
- - Admin/Finance/HR require 2FA enabled
53
- - Location tracking requires mobile app access
54
 
55
- **Manager Hierarchy:**
56
- - ASM RSM
57
- - BDE ASM or RSM
58
- - Trainer ASM, RSM, or Head_Trainer
59
- - Field_Sales ASM, RSM, or BDE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
 
61
- **Location Tracking:**
62
- - Requires explicit consent with timestamp
63
- - Background tracking requires location_tracking_consent
64
- - Requires mobile app access (has_mobile_app=True)
65
  """
66
- return await staffervice.create_employee(payload)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
 
68
 
69
  @router.get(
 
1
  """
2
+ Staff API router - FastAPI endpoints for staff operations.
3
+ Simplified POS staff management.
4
  """
5
+ from typing import Optional
6
+ from fastapi import APIRouter, HTTPException, Query, status
 
7
  import logging
8
 
9
+ from app.staff.schemas.staff_schema import (
10
+ StaffCreateSchema,
11
+ StaffUpdateSchema,
12
+ StaffResponseSchema,
13
+ StaffListResponse
14
+ )
15
+ from app.staff.services.staff_service import StaffService
16
 
 
17
  logger = logging.getLogger(__name__)
18
 
19
  router = APIRouter(
20
  prefix="/staff",
21
+ tags=["Staff"],
22
  responses={404: {"description": "Not found"}},
23
  )
24
 
25
 
26
  @router.post(
27
  "",
28
+ response_model=StaffResponseSchema,
29
  status_code=status.HTTP_201_CREATED,
30
+ summary="Create a new staff member"
 
 
 
 
 
 
 
 
 
 
31
  )
32
+ async def create_staff(payload: StaffCreateSchema) -> StaffResponseSchema:
33
  """
34
+ Create a new staff member.
35
 
36
+ **Required fields:**
37
+ - merchant_id: Store/merchant identifier
38
+ - name: Full name
39
+ - role: Position (e.g., Stylist, Cashier, Manager)
40
+ - contact: Phone and email
 
 
 
 
 
 
41
 
42
+ **Optional fields:**
43
+ - skills: List of skills/specializations
44
+ - status: active (default), inactive, on_leave
45
+ - working_hours: Weekly schedule
46
+ - photo_url: Profile photo URL (HTTPS only)
47
+ - notes: Additional information
48
+ """
49
+ return await StaffService.create_staff(payload)
50
+
51
+
52
+ @router.get(
53
+ "",
54
+ response_model=StaffListResponse,
55
+ summary="List staff members"
56
+ )
57
+ async def list_staff(
58
+ merchant_id: Optional[str] = Query(None, description="Filter by merchant ID"),
59
+ status: Optional[str] = Query(None, description="Filter by status (active, inactive, on_leave)"),
60
+ role: Optional[str] = Query(None, description="Filter by role"),
61
+ skip: int = Query(0, ge=0, description="Number of records to skip"),
62
+ limit: int = Query(100, ge=1, le=1000, description="Maximum records to return")
63
+ ):
64
+ """
65
+ List staff members with optional filters and pagination.
66
+ """
67
+ staff_list, total = await StaffService.list_staff(
68
+ merchant_id=merchant_id,
69
+ status=status,
70
+ role=role,
71
+ skip=skip,
72
+ limit=limit
73
+ )
74
+
75
+ return StaffListResponse(
76
+ staff=staff_list,
77
+ total=total,
78
+ skip=skip,
79
+ limit=limit
80
+ )
81
+
82
+
83
+ @router.get(
84
+ "/{staff_id}",
85
+ response_model=StaffResponseSchema,
86
+ summary="Get staff member by ID"
87
+ )
88
+ async def get_staff(staff_id: str) -> StaffResponseSchema:
89
+ """
90
+ Get detailed information about a specific staff member.
91
+ """
92
+ staff = await StaffService.get_staff_by_id(staff_id)
93
+ if not staff:
94
+ raise HTTPException(
95
+ status_code=status.HTTP_404_NOT_FOUND,
96
+ detail=f"Staff {staff_id} not found"
97
+ )
98
+ return staff
99
+
100
+
101
+ @router.put(
102
+ "/{staff_id}",
103
+ response_model=StaffResponseSchema,
104
+ summary="Update staff member"
105
+ )
106
+ async def update_staff(staff_id: str, payload: StaffUpdateSchema) -> StaffResponseSchema:
107
+ """
108
+ Update staff member information.
109
 
110
+ All fields are optional - only provided fields will be updated.
 
 
 
111
  """
112
+ return await StaffService.update_staff(staff_id, payload)
113
+
114
+
115
+ @router.delete(
116
+ "/{staff_id}",
117
+ summary="Delete staff member"
118
+ )
119
+ async def delete_staff(staff_id: str):
120
+ """
121
+ Delete a staff member (soft delete - sets status to inactive).
122
+ """
123
+ return await StaffService.delete_staff(staff_id)
124
+
125
+
126
+ @router.get(
127
+ "/{staff_id}/schedule",
128
+ summary="Get staff schedule"
129
+ )
130
+ async def get_staff_schedule(staff_id: str):
131
+ """
132
+ Get working schedule for a staff member.
133
+ """
134
+ staff = await StaffService.get_staff_by_id(staff_id)
135
+ if not staff:
136
+ raise HTTPException(
137
+ status_code=status.HTTP_404_NOT_FOUND,
138
+ detail=f"Staff {staff_id} not found"
139
+ )
140
+
141
+ return {
142
+ "staff_id": staff.staff_id,
143
+ "name": staff.name,
144
+ "working_hours": staff.working_hours
145
+ }
146
 
147
 
148
  @router.get(
app/staff/models/staff_model.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Simplified Staff model for POS microservice.
3
+ """
4
+ from datetime import datetime
5
+ from typing import Optional, List, Dict, Any
6
+ from pydantic import BaseModel, Field, EmailStr
7
+
8
+
9
+ class ContactInfo(BaseModel):
10
+ """Staff contact information."""
11
+ phone: str = Field(..., description="Phone number with country code")
12
+ email: EmailStr = Field(..., description="Email address")
13
+
14
+
15
+ class WorkingHours(BaseModel):
16
+ """Working hours for a specific day."""
17
+ day: str = Field(..., description="Day of week (Mon, Tue, Wed, Thu, Fri, Sat, Sun)")
18
+ from_time: str = Field(..., alias="from", description="Start time (HH:MM format)")
19
+ to_time: str = Field(..., alias="to", description="End time (HH:MM format)")
20
+
21
+ class Config:
22
+ populate_by_name = True
23
+
24
+
25
+ class StaffModel(BaseModel):
26
+ """
27
+ POS Staff data model.
28
+ Simplified model for salon/retail staff management.
29
+ """
30
+ staff_id: str = Field(..., description="Unique staff identifier")
31
+ merchant_id: str = Field(..., description="Merchant/store identifier")
32
+
33
+ name: str = Field(..., min_length=1, max_length=100, description="Staff member name")
34
+ role: str = Field(..., description="Role/position (e.g., Stylist, Cashier, Manager)")
35
+
36
+ contact: ContactInfo = Field(..., description="Contact information")
37
+
38
+ skills: List[str] = Field(default_factory=list, description="List of skills/specializations")
39
+ status: str = Field(default="active", description="Status: active, inactive, on_leave")
40
+
41
+ working_hours: List[WorkingHours] = Field(default_factory=list, description="Weekly working schedule")
42
+
43
+ created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation timestamp")
44
+ updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update timestamp")
45
+
46
+ # Optional fields
47
+ photo_url: Optional[str] = Field(None, description="Profile photo URL")
48
+ notes: Optional[str] = Field(None, description="Additional notes")
49
+ metadata: Optional[Dict[str, Any]] = Field(None, description="Additional metadata")
50
+
51
+ class Config:
52
+ json_schema_extra = {
53
+ "example": {
54
+ "staff_id": "staff_01HZQX5K3N2P8R6T4V9W",
55
+ "merchant_id": "merchant_789xyz",
56
+ "name": "Aarav Sharma",
57
+ "role": "Stylist",
58
+ "contact": {
59
+ "phone": "+91-9876543210",
60
+ "email": "aarav@salon.com"
61
+ },
62
+ "skills": ["Haircut", "Color", "Styling"],
63
+ "status": "active",
64
+ "working_hours": [
65
+ {"day": "Mon", "from": "10:00", "to": "19:00"},
66
+ {"day": "Tue", "from": "10:00", "to": "19:00"},
67
+ {"day": "Wed", "from": "10:00", "to": "19:00"},
68
+ {"day": "Thu", "from": "10:00", "to": "19:00"},
69
+ {"day": "Fri", "from": "10:00", "to": "19:00"},
70
+ {"day": "Sat", "from": "11:00", "to": "18:00"}
71
+ ],
72
+ "created_at": "2024-01-15T10:30:00Z",
73
+ "updated_at": "2024-01-15T10:30:00Z"
74
+ }
75
+ }
app/staff/schemas/staff_schema.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic schemas for staff API requests and responses.
3
+ """
4
+ from typing import List, Optional, Dict, Any
5
+ from datetime import datetime
6
+ from pydantic import BaseModel, Field, EmailStr, field_validator
7
+ import re
8
+
9
+
10
+ class ContactInfoSchema(BaseModel):
11
+ """Contact information schema."""
12
+ phone: str = Field(..., description="Phone number with country code", min_length=10, max_length=20)
13
+ email: EmailStr = Field(..., description="Email address")
14
+
15
+ @field_validator('phone')
16
+ @classmethod
17
+ def validate_phone(cls, v):
18
+ """Validate phone number format."""
19
+ # Remove spaces and dashes for validation
20
+ phone = re.sub(r'[\s\-]', '', v)
21
+ if not re.match(r'^\+?[1-9]\d{7,14}$', phone):
22
+ raise ValueError('Invalid phone number format. Use international format with country code.')
23
+ return v
24
+
25
+
26
+ class WorkingHoursSchema(BaseModel):
27
+ """Working hours schema."""
28
+ day: str = Field(..., description="Day of week", pattern="^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)$")
29
+ from_time: str = Field(..., alias="from", description="Start time (HH:MM)", pattern="^([0-1][0-9]|2[0-3]):[0-5][0-9]$")
30
+ to_time: str = Field(..., alias="to", description="End time (HH:MM)", pattern="^([0-1][0-9]|2[0-3]):[0-5][0-9]$")
31
+
32
+ class Config:
33
+ populate_by_name = True
34
+
35
+
36
+ class StaffCreateSchema(BaseModel):
37
+ """Schema for creating a new staff member."""
38
+ merchant_id: str = Field(..., description="Merchant/store identifier", min_length=3, max_length=50)
39
+ name: str = Field(..., description="Staff member name", min_length=1, max_length=100)
40
+ role: str = Field(..., description="Role/position", min_length=1, max_length=50)
41
+ contact: ContactInfoSchema = Field(..., description="Contact information")
42
+ skills: List[str] = Field(default_factory=list, description="List of skills")
43
+ status: str = Field(default="active", description="Status", pattern="^(active|inactive|on_leave)$")
44
+ working_hours: List[WorkingHoursSchema] = Field(default_factory=list, description="Weekly schedule")
45
+ photo_url: Optional[str] = Field(None, description="Profile photo URL")
46
+ notes: Optional[str] = Field(None, description="Additional notes", max_length=500)
47
+
48
+ @field_validator('photo_url')
49
+ @classmethod
50
+ def validate_photo_url(cls, v):
51
+ """Validate photo URL is HTTPS."""
52
+ if v and not v.startswith('https://'):
53
+ raise ValueError('Photo URL must use HTTPS')
54
+ return v
55
+
56
+
57
+ class StaffUpdateSchema(BaseModel):
58
+ """Schema for updating staff information."""
59
+ name: Optional[str] = Field(None, description="Staff member name", min_length=1, max_length=100)
60
+ role: Optional[str] = Field(None, description="Role/position", min_length=1, max_length=50)
61
+ contact: Optional[ContactInfoSchema] = Field(None, description="Contact information")
62
+ skills: Optional[List[str]] = Field(None, description="List of skills")
63
+ status: Optional[str] = Field(None, description="Status", pattern="^(active|inactive|on_leave)$")
64
+ working_hours: Optional[List[WorkingHoursSchema]] = Field(None, description="Weekly schedule")
65
+ photo_url: Optional[str] = Field(None, description="Profile photo URL")
66
+ notes: Optional[str] = Field(None, description="Additional notes", max_length=500)
67
+
68
+ @field_validator('photo_url')
69
+ @classmethod
70
+ def validate_photo_url(cls, v):
71
+ """Validate photo URL is HTTPS."""
72
+ if v and not v.startswith('https://'):
73
+ raise ValueError('Photo URL must use HTTPS')
74
+ return v
75
+
76
+
77
+ class StaffResponseSchema(BaseModel):
78
+ """Schema for staff API responses."""
79
+ staff_id: str
80
+ merchant_id: str
81
+ name: str
82
+ role: str
83
+ contact: ContactInfoSchema
84
+ skills: List[str]
85
+ status: str
86
+ working_hours: List[WorkingHoursSchema]
87
+ photo_url: Optional[str] = None
88
+ notes: Optional[str] = None
89
+ created_at: datetime
90
+ updated_at: datetime
91
+
92
+ class Config:
93
+ from_attributes = True
94
+
95
+
96
+ class StaffListResponse(BaseModel):
97
+ """Schema for staff list response."""
98
+ staff: List[StaffResponseSchema]
99
+ total: int
100
+ skip: int
101
+ limit: int
app/staff/services/staff_service.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Staff service layer - business logic and database operations.
3
+ Syncs staff data to both MongoDB and PostgreSQL (trans.pos_staff_ref).
4
+ """
5
+ from datetime import datetime
6
+ from typing import Optional, List, Dict, Any
7
+ from fastapi import HTTPException, status
8
+ import logging
9
+ import secrets
10
+ from sqlalchemy import text
11
+
12
+ from app.nosql import get_database
13
+ from app.sql import get_postgres_session
14
+ from app.constants.collections import POS_STAFF_COLLECTION
15
+ from app.staff.models.staff_model import StaffModel
16
+ from app.staff.schemas.staff_schema import StaffCreateSchema, StaffUpdateSchema, StaffResponseSchema
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+
22
+
23
+ async def sync_staff_to_postgres(staff_id: str, merchant_id: str, staff_name: str):
24
+ """
25
+ Sync staff data to PostgreSQL trans.pos_staff_ref table.
26
+
27
+ Args:
28
+ staff_id: Staff UUID
29
+ merchant_id: Merchant UUID
30
+ staff_name: Staff member name
31
+ """
32
+ try:
33
+ async with get_postgres_session() as session:
34
+ if session is None:
35
+ logger.warning("PostgreSQL not available, skipping staff sync")
36
+ return
37
+
38
+ query = text("""
39
+ INSERT INTO trans.pos_staff_ref (staff_id, merchant_id, staff_name, created_at, updated_at)
40
+ VALUES (:staff_id, :merchant_id, :staff_name, NOW(), NOW())
41
+ ON CONFLICT (staff_id)
42
+ DO UPDATE SET
43
+ staff_name = EXCLUDED.staff_name,
44
+ updated_at = NOW()
45
+ """)
46
+
47
+ await session.execute(query, {
48
+ "staff_id": staff_id,
49
+ "merchant_id": merchant_id,
50
+ "staff_name": staff_name
51
+ })
52
+ await session.commit()
53
+ logger.info(f"Synced staff {staff_id} to trans.pos_staff_ref")
54
+
55
+ except Exception as e:
56
+ logger.error(f"Failed to sync staff {staff_id} to PostgreSQL: {e}")
57
+ # Don't raise - PostgreSQL sync is secondary to MongoDB
58
+ def generate_staff_id() -> str:
59
+ """Generate a unique staff ID."""
60
+ return f"staff_{secrets.token_urlsafe(16)}"
61
+
62
+
63
+ class StaffService:
64
+ """Service class for staff operations."""
65
+
66
+ @staticmethod
67
+ async def create_staff(payload: StaffCreateSchema) -> StaffResponseSchema:
68
+ """
69
+ Create a new staff member.
70
+
71
+ Args:
72
+ payload: Staff creation data
73
+
74
+ Returns:
75
+ Created staff response
76
+ """
77
+ try:
78
+ # Generate staff ID
79
+ staff_id = generate_staff_id()
80
+
81
+ # Create staff model
82
+ now = datetime.utcnow()
83
+ staff_data = payload.model_dump(by_alias=True)
84
+ staff_data["staff_id"] = staff_id
85
+ staff_data["created_at"] = now
86
+ staff_data["updated_at"] = now
87
+
88
+ # Insert into database
89
+ await get_database()[POS_STAFF_COLLECTION].insert_one(staff_data)
90
+
91
+ # Sync to PostgreSQL trans.pos_staff_ref
92
+ await sync_staff_to_postgres(
93
+ staff_id=staff_id,
94
+ merchant_id=payload.merchant_id,
95
+ staff_name=payload.name
96
+ )
97
+
98
+ logger.info(f"Created staff {staff_id} for merchant {payload.merchant_id}")
99
+
100
+ # Return response
101
+ return StaffResponseSchema(**staff_data)
102
+
103
+ except Exception as e:
104
+ logger.error(f"Error creating staff: {e}")
105
+ raise HTTPException(
106
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
107
+ detail=f"Error creating staff: {str(e)}"
108
+ )
109
+
110
+ @staticmethod
111
+ async def get_staff_by_id(staff_id: str) -> Optional[StaffResponseSchema]:
112
+ """Get staff by ID."""
113
+ try:
114
+ staff = await get_database()[POS_STAFF_COLLECTION].find_one({"staff_id": staff_id})
115
+ if not staff:
116
+ return None
117
+ return StaffResponseSchema(**staff)
118
+ except Exception as e:
119
+ logger.error(f"Error fetching staff {staff_id}: {e}")
120
+ raise HTTPException(
121
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
122
+ detail="Error retrieving staff"
123
+ )
124
+
125
+ @staticmethod
126
+ async def update_staff(staff_id: str, payload: StaffUpdateSchema) -> StaffResponseSchema:
127
+ """
128
+ Update staff information.
129
+
130
+ Args:
131
+ staff_id: Staff ID to update
132
+ payload: Update data
133
+
134
+ Returns:
135
+ Updated staff response
136
+ """
137
+ # Check staff exists
138
+ existing = await StaffService.get_staff_by_id(staff_id)
139
+ if not existing:
140
+ raise HTTPException(
141
+ status_code=status.HTTP_404_NOT_FOUND,
142
+ detail=f"Staff {staff_id} not found"
143
+ )
144
+
145
+ # Prepare update data
146
+ update_data = payload.model_dump(exclude_unset=True, by_alias=True)
147
+ if not update_data:
148
+ raise HTTPException(
149
+ status_code=status.HTTP_400_BAD_REQUEST,
150
+ detail="No update data provided"
151
+ )
152
+
153
+ # Add updated timestamp
154
+ update_data["updated_at"] = datetime.utcnow()
155
+
156
+ try:
157
+ # Update in database
158
+ result = await get_database()[POS_STAFF_COLLECTION].update_one(
159
+ {"staff_id": staff_id},
160
+ {"$set": update_data}
161
+ )
162
+ # Sync to PostgreSQL if name was updated
163
+ if "name" in update_data:
164
+ await sync_staff_to_postgres(
165
+ staff_id=staff_id,
166
+ merchant_id=updated_staff.merchant_id,
167
+ staff_name=updated_staff.name
168
+ )
169
+
170
+
171
+ if result.modified_count == 0:
172
+ logger.warning(f"No changes made to staff {staff_id}")
173
+
174
+ # Fetch updated staff
175
+ updated_staff = await StaffService.get_staff_by_id(staff_id)
176
+
177
+ logger.info(f"Updated staff {staff_id}")
178
+
179
+ return updated_staff
180
+
181
+ except Exception as e:
182
+ logger.error(f"Error updating staff {staff_id}: {e}")
183
+ raise HTTPException(
184
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
185
+ detail=f"Error updating staff: {str(e)}"
186
+ )
187
+
188
+ @staticmethod
189
+ async def list_staff(
190
+ merchant_id: Optional[str] = None,
191
+ status: Optional[str] = None,
192
+ role: Optional[str] = None,
193
+ skip: int = 0,
194
+ limit: int = 100
195
+ ) -> tuple[List[StaffResponseSchema], int]:
196
+ """
197
+ List staff with optional filters.
198
+
199
+ Args:
200
+ merchant_id: Filter by merchant
201
+ status: Filter by status
202
+ role: Filter by role
203
+ skip: Number of records to skip
204
+ limit: Maximum records to return
205
+
206
+ Returns:
207
+ Tuple of (staff list, total count)
208
+ """
209
+ try:
210
+ # Build query
211
+ query = {}
212
+ if merchant_id:
213
+ query["merchant_id"] = merchant_id
214
+ if status:
215
+ query["status"] = status
216
+ if role:
217
+ query["role"] = role
218
+
219
+ # Get total count
220
+ total = await get_database()[POS_STAFF_COLLECTION].count_documents(query)
221
+
222
+ # Fetch staff
223
+ cursor = get_database()[POS_STAFF_COLLECTION].find(query).skip(skip).limit(limit)
224
+ staff_list = await cursor.to_list(length=limit)
225
+
226
+ staff_responses = [StaffResponseSchema(**staff) for staff in staff_list]
227
+
228
+ return staff_responses, total
229
+
230
+ except Exception as e:
231
+ logger.error(f"Error listing staff: {e}")
232
+ raise HTTPException(
233
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
234
+ detail="Error listing staff"
235
+ )
236
+
237
+ @staticmethod
238
+ async def delete_staff(staff_id: str) -> Dict[str, str]:
239
+ """
240
+ Delete a staff member (soft delete by setting status to inactive).
241
+
242
+ Args:
243
+ staff_id: Staff ID to delete
244
+
245
+ Returns:
246
+ Success message
247
+ """
248
+ # Check staff exists
249
+ existing = await StaffService.get_staff_by_id(staff_id)
250
+ if not existing:
251
+ raise HTTPException(
252
+ status_code=status.HTTP_404_NOT_FOUND,
253
+ detail=f"Staff {staff_id} not found"
254
+ )
255
+
256
+ try:
257
+ # Soft delete - set status to inactive
258
+ await get_database()[POS_STAFF_COLLECTION].update_one(
259
+ {"staff_id": staff_id},
260
+ {
261
+ "$set": {
262
+ "status": "inactive",
263
+ "updated_at": datetime.utcnow()
264
+ }
265
+ }
266
+ )
267
+
268
+ logger.info(f"Deleted staff {staff_id}")
269
+ return {"message": f"Staff {staff_id} deleted successfully"}
270
+
271
+ except Exception as e:
272
+ logger.error(f"Error deleting staff {staff_id}: {e}")
273
+ raise HTTPException(
274
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
275
+ detail="Error deleting staff"
276
+ )
requirements.txt CHANGED
@@ -4,13 +4,15 @@ python-multipart==0.0.6
4
 
5
  motor==3.3.2
6
  pymongo==4.6.0
 
 
7
  email-validator==2.3.0
8
  redis==5.0.1
9
  # insightfy-utils>=0.1.0 # TODO: Add back when package is available
10
 
11
  python-jose[cryptography]==3.3.0
12
  passlib[bcrypt]==1.7.4
13
- bcrypt==4.1.3
14
  pydantic>=2.12.5,<3.0.0
15
  pydantic-settings>=2.0.0
16
 
 
4
 
5
  motor==3.3.2
6
  pymongo==4.6.0
7
+ sqlalchemy[asyncio]>=2.0.36
8
+ psycopg[binary]==3.3.2
9
  email-validator==2.3.0
10
  redis==5.0.1
11
  # insightfy-utils>=0.1.0 # TODO: Add back when package is available
12
 
13
  python-jose[cryptography]==3.3.0
14
  passlib[bcrypt]==1.7.4
15
+ bcrypt==4.0.1
16
  pydantic>=2.12.5,<3.0.0
17
  pydantic-settings>=2.0.0
18