MukeshKapoor25 commited on
Commit
85e4fac
·
1 Parent(s): 163b56e

feat(staff): implement staff list endpoint with projection support

Browse files

refactor(response): standardize error response format
test: add tests for error handling and staff list endpoint
docs: remove outdated documentation files
feat(middleware): add request logging middleware

POS_COLLECTIONS.md DELETED
@@ -1,100 +0,0 @@
1
- # POS MongoDB Collections
2
-
3
- ## Collection Naming Convention
4
-
5
- All POS MongoDB collections use the `pos_*` prefix for better organization and to avoid conflicts with other microservices.
6
-
7
- ## Collection List
8
-
9
- | Collection Name | Purpose | Key Fields |
10
- |----------------|---------|------------|
11
- | `pos_customers` | Customer data | customer_id, merchant_id, name, phone, email |
12
- | `pos_staff` | Staff/employee data | staff_id, merchant_id, name, role, specializations |
13
- | `pos_catalogue_services` | Service catalog | service_id, merchant_id, name, code, pricing |
14
- | `pos_appointments` | Appointment bookings | appointment_id, merchant_id, customer_id, services |
15
- | `pos_sales` | Sales transactions | sale_id, merchant_id, customer_id, items, payments |
16
- | `pos_sales_items` | Individual sale items | sale_item_id, sale_id, service_id, staff_id |
17
- | `pos_payments` | Payment records | payment_id, sale_id, amount, payment_mode |
18
- | `pos_refunds` | Refund records | refund_id, sale_id, amount, reason |
19
-
20
- ## Index Strategy
21
-
22
- Each collection includes standard indexes:
23
- - `merchant_id` - For tenant isolation
24
- - `created_at` - For time-based queries
25
- - Entity-specific indexes (e.g., `customer_id`, `staff_id`)
26
-
27
- ## Usage in Code
28
-
29
- ### Service Layer
30
- ```python
31
- # Catalogue services
32
- CATALOGUE_SERVICES_COLLECTION = "pos_catalogue_services"
33
- db = get_database()
34
- services = await db[CATALOGUE_SERVICES_COLLECTION].find(query).to_list(length=limit)
35
-
36
- # Customers
37
- customers = await db.pos_customers.find({"merchant_id": merchant_id}).to_list(length=None)
38
-
39
- # Staff
40
- staff = await db.pos_staff.find({"merchant_id": merchant_id}).to_list(length=None)
41
- ```
42
-
43
- ### Sync Operations
44
- ```python
45
- # MongoDB to PostgreSQL sync
46
- mongo_customers = await mongo_db.pos_customers.find({"merchant_id": merchant_id}).to_list(length=None)
47
- mongo_staff = await mongo_db.pos_staff.find({"merchant_id": merchant_id}).to_list(length=None)
48
- mongo_services = await mongo_db.pos_catalogue_services.find({"merchant_id": merchant_id}).to_list(length=None)
49
- ```
50
-
51
- ## Migration Notes
52
-
53
- If you have existing data in the old collection names, you'll need to migrate:
54
-
55
- ```javascript
56
- // MongoDB migration script
57
- use cuatrolabs;
58
-
59
- // Migrate customers
60
- db.customers.find({}).forEach(function(doc) {
61
- db.pos_customers.insert(doc);
62
- });
63
-
64
- // Migrate staff
65
- db.staff.find({}).forEach(function(doc) {
66
- db.pos_staff.insert(doc);
67
- });
68
-
69
- // Migrate catalogue services
70
- db.catalogue_services.find({}).forEach(function(doc) {
71
- db.pos_catalogue_services.insert(doc);
72
- });
73
-
74
- // Verify counts match
75
- print("Original customers:", db.customers.count());
76
- print("New pos_customers:", db.pos_customers.count());
77
-
78
- print("Original staff:", db.staff.count());
79
- print("New pos_staff:", db.pos_staff.count());
80
-
81
- print("Original catalogue_services:", db.catalogue_services.count());
82
- print("New pos_catalogue_services:", db.pos_catalogue_services.count());
83
- ```
84
-
85
- ## Benefits
86
-
87
- 1. **Clear Separation**: Easy to identify POS-specific collections
88
- 2. **No Conflicts**: Avoids naming conflicts with SCM or other microservices
89
- 3. **Better Organization**: Logical grouping of related collections
90
- 4. **Easier Maintenance**: Clear ownership and responsibility
91
- 5. **Scalability**: Easy to add new POS collections following the same pattern
92
-
93
- ## Future Collections
94
-
95
- When adding new POS collections, follow the naming convention:
96
- - `pos_inventory` - Inventory tracking
97
- - `pos_promotions` - Promotional campaigns
98
- - `pos_loyalty` - Loyalty program data
99
- - `pos_analytics` - Analytics and reporting data
100
- - `pos_settings` - POS-specific configuration
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
POS_ENDPOINT_PROTECTION_SUMMARY.md DELETED
@@ -1,212 +0,0 @@
1
- # POS Microservice Endpoint Protection Implementation
2
-
3
- ## Overview
4
- All POS microservice endpoints have been protected with role-based permissions similar to the SCM microservice pattern. The implementation uses JWT token validation and permission checking against the `scm_access_roles` collection.
5
-
6
- ## Protection System Components
7
-
8
- ### 1. POS Permissions Module
9
- **File:** `app/dependencies/pos_permissions.py`
10
-
11
- - `POSPermissionChecker`: Core class for permission validation
12
- - `require_pos_permission(module, action)`: Dependency factory for single permission
13
- - `require_pos_permissions(permissions_list)`: Dependency factory for multiple permissions
14
- - `get_user_permissions()`: Utility to get user's full permission set
15
-
16
- ### 2. Permission Modules
17
- The following POS modules are protected:
18
-
19
- - **sales**: Sales orders, retail sales, invoicing
20
- - **customers**: Customer management
21
- - **staff**: Staff management
22
- - **appointments**: Appointment booking and management
23
- - **catalogue**: Service catalogue management
24
-
25
- ### 3. Permission Actions
26
- Standard CRUD actions are supported:
27
-
28
- - **view**: Read access to resources
29
- - **create**: Create new resources
30
- - **update**: Modify existing resources
31
- - **delete**: Remove resources
32
-
33
- ## Protected Endpoints
34
-
35
- ### Sales Orders (`/api/v1/sales/`)
36
- - `POST /order/list` - requires `sales.view`
37
- - `GET /order/{id}` - requires `sales.view`
38
- - `POST /order` - requires `sales.create`
39
- - `PUT /order/{id}` - requires `sales.update`
40
- - `POST /order/{id}/invoice` - requires `sales.create`
41
- - `GET /info/widgets` - requires `sales.view`
42
-
43
- ### Customers (`/api/v1/customers/`)
44
- - `POST /` - requires `customers.create`
45
- - `GET /{id}` - requires `customers.view`
46
- - `POST /list` - requires `customers.view` (converted from GET to POST)
47
- - `PUT /{id}` - requires `customers.update`
48
- - `DELETE /{id}` - requires `customers.delete`
49
-
50
- ### Staff (`/api/v1/staff/`)
51
- - `POST /` - requires `staff.create`
52
- - `POST /list` - requires `staff.view` (converted from GET to POST)
53
- - `GET /code/{code}` - requires `staff.view`
54
- - `GET /{id}` - requires `staff.view`
55
- - `PUT /{id}` - requires `staff.update`
56
- - `PATCH /{id}/status` - requires `staff.update`
57
- - `DELETE /{id}` - requires `staff.delete`
58
-
59
- ### Appointments (`/api/v1/pos/appointments/`)
60
- - `POST /` - requires `appointments.create`
61
- - `POST /list` - requires `appointments.view` (converted from GET to POST)
62
- - `GET /{id}` - requires `appointments.view`
63
- - `PUT /{id}` - requires `appointments.update`
64
- - `PATCH /{id}/status` - requires `appointments.update`
65
- - `POST /{id}/cancel` - requires `appointments.update`
66
- - `POST /{id}/checkout` - requires `appointments.update`
67
-
68
- ### Catalogue Services (`/api/v1/pos/catalogue/services/`)
69
- - `POST /` - requires `catalogue.create`
70
- - `POST /list` - requires `catalogue.view` (converted from GET to POST)
71
- - `GET /{id}` - requires `catalogue.view`
72
- - `PUT /{id}` - requires `catalogue.update`
73
- - `PATCH /{id}/status` - requires `catalogue.update`
74
- - `DELETE /{id}` - requires `catalogue.delete`
75
-
76
- ### Retail Sales (`/api/v1/pos/sales/`)
77
- - `POST /` - requires `sales.create`
78
- - `GET /{id}` - requires `sales.view`
79
- - `PUT /{id}/items` - requires `sales.update`
80
- - `POST /{id}/payments` - requires `sales.update`
81
- - `POST /{id}/cancel` - requires `sales.update`
82
- - `POST /{id}/refund` - requires `sales.update`
83
-
84
- ## API List Endpoint Standard Compliance
85
-
86
- ### Changes Made
87
- 1. **GET to POST Conversion**: All list endpoints converted from GET to POST method
88
- 2. **Consistent Naming**: All list endpoints use `/list` path
89
- 3. **Ready for Projection**: Endpoints prepared for projection_list parameter implementation
90
-
91
- ### Endpoints Converted
92
- - `GET /customers/` → `POST /customers/list`
93
- - `GET /staff/` → `POST /staff/list`
94
- - `GET /pos/appointments/` → `POST /pos/appointments/list`
95
- - `GET /pos/catalogue/services/` → `POST /pos/catalogue/services/list`
96
-
97
- ### Next Steps for Full Compliance
98
- To complete the API list endpoint standard implementation:
99
-
100
- 1. **Add projection_list to schemas**: Update request schemas to include `projection_list: Optional[List[str]]`
101
- 2. **Update service layers**: Implement MongoDB projection logic in service methods
102
- 3. **Return type handling**: Return raw dict when projection used, Pydantic model otherwise
103
-
104
- ## Usage Examples
105
-
106
- ### Basic Permission Check
107
- ```python
108
- @router.post("/orders")
109
- async def create_order(
110
- payload: OrderCreate,
111
- current_user: TokenUser = Depends(require_pos_permission("sales", "create"))
112
- ):
113
- # Only users with sales.create permission can access
114
- return await OrderService.create_order(payload)
115
- ```
116
-
117
- ### Multiple Permission Check
118
- ```python
119
- @router.post("/complex-operation")
120
- async def complex_operation(
121
- current_user: TokenUser = Depends(require_pos_permissions([
122
- {"module": "sales", "action": "create"},
123
- {"module": "customers", "action": "update"}
124
- ]))
125
- ):
126
- # Requires both permissions
127
- pass
128
- ```
129
-
130
- ## Role Configuration
131
-
132
- ### Sample POS Role in `scm_access_roles` Collection
133
- ```json
134
- {
135
- "role_id": "role_pos_manager",
136
- "role_name": "POS Manager",
137
- "is_active": true,
138
- "permissions": {
139
- "sales": ["view", "create", "update"],
140
- "customers": ["view", "create", "update", "delete"],
141
- "staff": ["view", "create", "update"],
142
- "appointments": ["view", "create", "update"],
143
- "catalogue": ["view", "create", "update"]
144
- }
145
- }
146
- ```
147
-
148
- ### Sample POS Cashier Role
149
- ```json
150
- {
151
- "role_id": "role_pos_cashier",
152
- "role_name": "POS Cashier",
153
- "is_active": true,
154
- "permissions": {
155
- "sales": ["view", "create"],
156
- "customers": ["view", "create"],
157
- "appointments": ["view", "create", "update"]
158
- }
159
- }
160
- ```
161
-
162
- ## Security Benefits
163
-
164
- 1. **Role-based Access Control**: Fine-grained permissions per module and action
165
- 2. **JWT Token Validation**: Secure token-based authentication
166
- 3. **Consistent Pattern**: Same security model as SCM microservice
167
- 4. **Audit Trail**: Permission checks are logged for security monitoring
168
- 5. **Flexible Permissions**: Easy to add new modules and actions
169
-
170
- ## Error Responses
171
-
172
- ### 401 Unauthorized
173
- ```json
174
- {
175
- "detail": "Could not validate credentials"
176
- }
177
- ```
178
-
179
- ### 403 Forbidden
180
- ```json
181
- {
182
- "detail": "Access denied. Required permission: sales.create"
183
- }
184
- ```
185
-
186
- ## Implementation Status
187
-
188
- ✅ **Completed:**
189
- - POS permissions system created
190
- - All existing endpoints protected
191
- - List endpoints converted to POST method
192
- - Consistent permission naming
193
-
194
- 🔄 **Next Steps:**
195
- - Add projection_list support to schemas and services
196
- - Create POS-specific role definitions
197
- - Add integration tests for permission system
198
- - Document role setup procedures
199
-
200
- ## Maintenance
201
-
202
- ### Adding New Endpoints
203
- 1. Import required dependencies
204
- 2. Add permission check to endpoint
205
- 3. Use appropriate module and action names
206
- 4. Follow POST method for list endpoints
207
-
208
- ### Adding New Modules
209
- 1. Define module name (e.g., "inventory", "reports")
210
- 2. Add to role permissions in database
211
- 3. Use in `require_pos_permission()` calls
212
- 4. Document in this file
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
POS_SYNC_IMPLEMENTATION.md DELETED
@@ -1,310 +0,0 @@
1
- # POS MongoDB to PostgreSQL Sync Implementation
2
-
3
- ## Overview
4
-
5
- This document describes the MongoDB to PostgreSQL synchronization utility implemented for the POS microservice. The sync system ensures that critical POS data is available in both MongoDB (primary) and PostgreSQL (reference) for improved query performance and reporting capabilities.
6
-
7
- ## Architecture
8
-
9
- ### Components
10
-
11
- 1. **Sync Models** (`app/sync/models.py`)
12
- - PostgreSQL table definitions for reference data
13
- - Currently supports: `CustomerRef`, `StaffRef`
14
-
15
- 2. **Sync Services**
16
- - `CustomerSyncService` - Handles customer data synchronization
17
- - `StaffSyncService` - Handles staff data synchronization
18
- - `POSSyncService` - Main coordinator service
19
-
20
- 3. **Sync Handler** (`app/sync/handler.py`)
21
- - Event-driven sync operations
22
- - Automatic session management
23
- - Error handling and logging
24
-
25
- 4. **Utilities** (`app/sync/utils.py`)
26
- - Migration tools for existing data
27
- - Consistency verification
28
- - Cleanup operations
29
-
30
- ## Supported Entities
31
-
32
- ### Customers
33
- - **MongoDB Collection**: `customers`
34
- - **PostgreSQL Table**: `pos.customer_ref`
35
- - **Key Fields**: customer_id, merchant_id, name, phone, email, notes, status
36
-
37
- ### Staff
38
- - **MongoDB Collection**: `staff`
39
- - **PostgreSQL Table**: `pos.staff_ref`
40
- - **Key Fields**: staff_id, merchant_id, name, phone, email, role, specializations, status
41
-
42
- ### Catalogue Services
43
- - **MongoDB Collection**: `catalogue_services`
44
- - **PostgreSQL Table**: `pos.catalogue_service_ref`
45
- - **Key Fields**: service_id, merchant_id, service_name, service_code, category, duration_mins, price, gst_rate, status
46
-
47
- ## Usage
48
-
49
- ### 1. Basic Sync Operations
50
-
51
- ```python
52
- from app.sync.handler import POSSyncHandler
53
- from app.customers.models.model import CustomerModel
54
-
55
- # Customer operations
56
- customer = CustomerModel(...)
57
- await POSSyncHandler.handle_customer_created(customer)
58
- await POSSyncHandler.handle_customer_updated(customer_id, customer)
59
- await POSSyncHandler.handle_customer_deleted(customer_id)
60
- await POSSyncHandler.handle_customer_status_changed(customer_id, "inactive")
61
-
62
- # Staff operations
63
- staff_data = {...}
64
- await POSSyncHandler.handle_staff_created(staff_data)
65
- await POSSyncHandler.handle_staff_updated(staff_id, staff_data)
66
- await POSSyncHandler.handle_staff_deleted(staff_id)
67
- await POSSyncHandler.handle_staff_status_changed(staff_id, "inactive")
68
-
69
- # Catalogue service operations
70
- service_data = {...}
71
- await POSSyncHandler.handle_catalogue_service_created(service_data)
72
- await POSSyncHandler.handle_catalogue_service_updated(service_id, service_data)
73
- await POSSyncHandler.handle_catalogue_service_deleted(service_id)
74
- await POSSyncHandler.handle_catalogue_service_status_changed(service_id, "archived")
75
- ```
76
-
77
- ### 2. Integration with Service Layer
78
-
79
- Add sync calls to your service methods:
80
-
81
- ```python
82
- # In customers/services/service.py
83
- from app.sync.handler import POSSyncHandler
84
-
85
- async def create_customer(self, customer_data: dict):
86
- # Create in MongoDB
87
- customer = await self.mongo_collection.insert_one(customer_data)
88
-
89
- # Sync to PostgreSQL
90
- customer_model = CustomerModel(**customer_data)
91
- await POSSyncHandler.handle_customer_created(customer_model)
92
-
93
- return customer
94
-
95
- async def update_customer(self, customer_id: str, update_data: dict):
96
- # Update in MongoDB
97
- result = await self.mongo_collection.update_one(
98
- {"customer_id": customer_id},
99
- {"$set": update_data}
100
- )
101
-
102
- # Sync to PostgreSQL
103
- updated_customer = await self.get_customer(customer_id)
104
- await POSSyncHandler.handle_customer_updated(customer_id, updated_customer)
105
-
106
- return result
107
- ```
108
-
109
- ### 3. Migration and Maintenance
110
-
111
- ```bash
112
- # Migrate existing customers
113
- python -m app.sync.utils migrate_customers merchant_123 1000
114
-
115
- # Migrate existing staff
116
- python -m app.sync.utils migrate_staff merchant_123 1000
117
-
118
- # Verify consistency
119
- python -m app.sync.utils verify_consistency merchant_123 customers
120
-
121
- # Clean up orphaned records
122
- python -m app.sync.utils cleanup_orphaned merchant_123 customers
123
- ```
124
-
125
- ### 4. Health Monitoring
126
-
127
- ```python
128
- from app.sync.handler import POSSyncHandler
129
-
130
- # Check sync system health
131
- health = await POSSyncHandler.check_sync_health()
132
- print(health)
133
- # {
134
- # "overall_health": True,
135
- # "customer_sync": True,
136
- # "staff_sync": True,
137
- # "timestamp": "..."
138
- # }
139
- ```
140
-
141
- ## Database Schema
142
-
143
- ### PostgreSQL Tables
144
-
145
- ```sql
146
- -- Customer reference table
147
- CREATE TABLE pos.customer_ref (
148
- customer_id VARCHAR PRIMARY KEY,
149
- merchant_id VARCHAR NOT NULL,
150
- name VARCHAR(150) NOT NULL,
151
- phone VARCHAR(20),
152
- email VARCHAR(255),
153
- notes TEXT,
154
- status VARCHAR(20) NOT NULL DEFAULT 'active',
155
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
156
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
157
- );
158
-
159
- -- Staff reference table
160
- CREATE TABLE pos.staff_ref (
161
- staff_id VARCHAR PRIMARY KEY,
162
- merchant_id VARCHAR NOT NULL,
163
- name VARCHAR(150) NOT NULL,
164
- phone VARCHAR(20),
165
- email VARCHAR(255),
166
- role VARCHAR(50),
167
- specializations TEXT[],
168
- status VARCHAR(20) NOT NULL DEFAULT 'active',
169
- created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
170
- updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
171
- );
172
- ```
173
-
174
- ### Indexes
175
-
176
- ```sql
177
- -- Customer indexes
178
- CREATE INDEX idx_customer_ref_merchant_id ON pos.customer_ref(merchant_id);
179
- CREATE INDEX idx_customer_ref_phone ON pos.customer_ref(phone);
180
- CREATE INDEX idx_customer_ref_email ON pos.customer_ref(email);
181
-
182
- -- Staff indexes
183
- CREATE INDEX idx_staff_ref_merchant_id ON pos.staff_ref(merchant_id);
184
- CREATE INDEX idx_staff_ref_status ON pos.staff_ref(status);
185
- ```
186
-
187
- ## Testing
188
-
189
- Run the test suite to verify sync functionality:
190
-
191
- ```bash
192
- cd cuatrolabs-pos-ms
193
- python test_pos_sync.py
194
- ```
195
-
196
- The test suite covers:
197
- - Customer CRUD sync operations
198
- - Staff CRUD sync operations
199
- - Bulk sync operations
200
- - Health checks
201
- - Error handling
202
-
203
- ## Error Handling
204
-
205
- The sync system includes comprehensive error handling:
206
-
207
- 1. **Graceful Degradation**: MongoDB operations continue even if PostgreSQL sync fails
208
- 2. **Logging**: All sync operations are logged with appropriate levels
209
- 3. **Retry Logic**: Can be implemented at the service layer for critical operations
210
- 4. **Health Monitoring**: Regular health checks to detect sync issues
211
-
212
- ## Performance Considerations
213
-
214
- 1. **Async Operations**: All sync operations are asynchronous
215
- 2. **Bulk Operations**: Support for bulk sync to reduce overhead
216
- 3. **Selective Sync**: Only sync essential fields to PostgreSQL
217
- 4. **Connection Pooling**: Uses SQLAlchemy async sessions with connection pooling
218
-
219
- ## Best Practices
220
-
221
- 1. **Always sync after MongoDB operations**: Ensure data consistency
222
- 2. **Handle sync failures gracefully**: Don't fail the main operation if sync fails
223
- 3. **Monitor sync health**: Regular health checks and alerting
224
- 4. **Use bulk operations**: For large data migrations or batch updates
225
- 5. **Verify consistency**: Regular consistency checks between MongoDB and PostgreSQL
226
-
227
- ## Future Enhancements
228
-
229
- 1. **Event-Driven Sync**: Implement MongoDB change streams for real-time sync
230
- 2. **Conflict Resolution**: Handle conflicts when data differs between systems
231
- 3. **Selective Field Sync**: Configure which fields to sync per entity
232
- 4. **Audit Trail**: Track sync operations for debugging and compliance
233
- 5. **Performance Metrics**: Add metrics for sync operation performance
234
-
235
- ## Troubleshooting
236
-
237
- ### Common Issues
238
-
239
- 1. **Schema Creation Fails**
240
- - Check PostgreSQL permissions
241
- - Verify connection string
242
- - Ensure schema exists
243
-
244
- 2. **Sync Operations Fail**
245
- - Check PostgreSQL connectivity
246
- - Verify table structure
247
- - Review error logs
248
-
249
- 3. **Data Inconsistency**
250
- - Run consistency verification
251
- - Check for failed sync operations
252
- - Review application logs
253
-
254
- ### Debug Commands
255
-
256
- ```python
257
- # Check sync health
258
- from app.sync.handler import POSSyncHandler
259
- health = await POSSyncHandler.check_sync_health()
260
-
261
- # Verify specific merchant data
262
- from app.sync.utils import verify_sync_consistency
263
- result = await verify_sync_consistency("merchant_123", "customers")
264
-
265
- # Manual sync test
266
- from app.sync.sync_service import POSSyncService
267
- from app.sql import get_postgres_session
268
-
269
- async with get_postgres_session() as session:
270
- sync_service = POSSyncService(session)
271
- # Test operations...
272
- ```
273
-
274
- ## Integration with Existing Code
275
-
276
- To integrate with existing POS services:
277
-
278
- 1. Import the sync handler in your service files
279
- 2. Add sync calls after successful MongoDB operations
280
- 3. Handle sync failures appropriately (log but don't fail main operation)
281
- 4. Add health checks to your monitoring system
282
- 5. Set up regular consistency verification jobs
283
-
284
- Example integration:
285
-
286
- ```python
287
- # In your existing service
288
- from app.sync.handler import POSSyncHandler
289
- import logging
290
-
291
- logger = logging.getLogger(__name__)
292
-
293
- async def create_customer(self, customer_data):
294
- try:
295
- # Existing MongoDB operation
296
- result = await self.collection.insert_one(customer_data)
297
-
298
- # Add sync operation
299
- try:
300
- customer_model = CustomerModel(**customer_data)
301
- await POSSyncHandler.handle_customer_created(customer_model)
302
- except Exception as sync_error:
303
- # Log but don't fail the main operation
304
- logger.error(f"Customer sync failed: {sync_error}")
305
-
306
- return result
307
- except Exception as e:
308
- logger.error(f"Customer creation failed: {e}")
309
- raise
310
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/main.py CHANGED
@@ -2,11 +2,17 @@
2
  Main FastAPI application for POS Microservice.
3
  """
4
  import logging
5
- from fastapi import FastAPI
 
 
 
6
  from fastapi.middleware.cors import CORSMiddleware
7
- # # from insightfy_utils.logging import get_logger # TODO: Uncomment when package is available
8
  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.sql import connect_to_database, disconnect_from_database
@@ -29,6 +35,9 @@ app = FastAPI(
29
  redoc_url="/redoc",
30
  )
31
 
 
 
 
32
  # CORS middleware
33
  app.add_middleware(
34
  CORSMiddleware,
@@ -39,6 +48,118 @@ app.add_middleware(
39
  )
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  # Startup and shutdown events
43
  @app.on_event("startup")
44
  async def startup_event():
 
2
  Main FastAPI application for POS Microservice.
3
  """
4
  import logging
5
+ from fastapi import FastAPI, Request, status
6
+ from fastapi.exceptions import RequestValidationError
7
+ from starlette.exceptions import HTTPException as StarletteHTTPException
8
+ from fastapi.responses import JSONResponse
9
  from fastapi.middleware.cors import CORSMiddleware
 
10
  import logging # TODO: Uncomment when package is available
11
+ from pymongo.errors import PyMongoError, ConnectionFailure
12
+
13
  from app.core.config import settings
14
+ from app.utils.response import error_response
15
+ from app.middleware.logging_middleware import RequestLoggingMiddleware
16
 
17
  from app.nosql import connect_to_mongo, close_mongo_connection
18
  from app.sql import connect_to_database, disconnect_from_database
 
35
  redoc_url="/redoc",
36
  )
37
 
38
+ # Request logging middleware
39
+ app.add_middleware(RequestLoggingMiddleware)
40
+
41
  # CORS middleware
42
  app.add_middleware(
43
  CORSMiddleware,
 
48
  )
49
 
50
 
51
+ # Global Exception Handlers
52
+ @app.exception_handler(RequestValidationError)
53
+ async def validation_exception_handler(request: Request, exc: RequestValidationError):
54
+ """Handle validation errors"""
55
+ errors = []
56
+ for e in exc.errors():
57
+ # Get field name from loc (last element), default to "body" or "unknown"
58
+ field = str(e["loc"][-1]) if e["loc"] else "unknown"
59
+ errors.append({
60
+ "field": field,
61
+ "message": e["msg"],
62
+ "type": e["type"]
63
+ })
64
+
65
+ return JSONResponse(
66
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
67
+ content=error_response(
68
+ error="Validation Error",
69
+ detail="The request contains invalid data",
70
+ errors=errors,
71
+ request_id=getattr(request.state, "request_id", None)
72
+ )
73
+ )
74
+
75
+
76
+ @app.exception_handler(status.HTTP_404_NOT_FOUND)
77
+ async def not_found_exception_handler(request: Request, exc: Exception):
78
+ """Handle 404 errors"""
79
+ return JSONResponse(
80
+ status_code=status.HTTP_404_NOT_FOUND,
81
+ content=error_response(
82
+ error="Not Found",
83
+ detail="Resource not found",
84
+ request_id=getattr(request.state, "request_id", None)
85
+ )
86
+ )
87
+
88
+
89
+ @app.exception_handler(StarletteHTTPException)
90
+ async def http_exception_handler(request: Request, exc: StarletteHTTPException):
91
+ """Handle standard HTTP exceptions"""
92
+ error_title = "Error"
93
+ headers = None
94
+
95
+ if exc.status_code == 401:
96
+ error_title = "Authentication Error"
97
+ headers = {"WWW-Authenticate": "Bearer"}
98
+ elif exc.status_code == 403:
99
+ error_title = "Permission Denied"
100
+ elif exc.status_code == 404:
101
+ error_title = "Not Found"
102
+ elif exc.status_code == 422:
103
+ error_title = "Validation Error"
104
+
105
+ return JSONResponse(
106
+ status_code=exc.status_code,
107
+ content=error_response(
108
+ error=error_title,
109
+ detail=str(exc.detail),
110
+ request_id=getattr(request.state, "request_id", None),
111
+ headers=headers
112
+ )
113
+ )
114
+
115
+
116
+ @app.exception_handler(ConnectionFailure)
117
+ async def mongo_connection_exception_handler(request: Request, exc: ConnectionFailure):
118
+ """Handle MongoDB connection errors"""
119
+ logger.error(f"Database connection error: {exc}", exc_info=True)
120
+ return JSONResponse(
121
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
122
+ content=error_response(
123
+ error="Database Connection Error",
124
+ detail="Unable to connect to the database. Please try again later.",
125
+ request_id=getattr(request.state, "request_id", None)
126
+ )
127
+ )
128
+
129
+
130
+ @app.exception_handler(PyMongoError)
131
+ async def mongo_exception_handler(request: Request, exc: PyMongoError):
132
+ """Handle general MongoDB errors"""
133
+ logger.error(f"Database error: {exc}", exc_info=True)
134
+ return JSONResponse(
135
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
136
+ content=error_response(
137
+ error="Database Error",
138
+ detail="An unexpected database error occurred.",
139
+ request_id=getattr(request.state, "request_id", None)
140
+ )
141
+ )
142
+
143
+
144
+ # Handle generic HTTPException (catch-all for other HTTP exceptions)
145
+ @app.exception_handler(Exception)
146
+ async def generic_exception_handler(request: Request, exc: Exception):
147
+ """
148
+ Handle all unhandled exceptions.
149
+
150
+ In production, we should not expose internal error details.
151
+ """
152
+ logger.error(f"Unhandled exception: {exc}", exc_info=True)
153
+ return JSONResponse(
154
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
155
+ content=error_response(
156
+ error="Internal Server Error",
157
+ detail="An unexpected error occurred. Please try again later.",
158
+ request_id=getattr(request.state, "request_id", None)
159
+ )
160
+ )
161
+
162
+
163
  # Startup and shutdown events
164
  @app.on_event("startup")
165
  async def startup_event():
app/middleware/logging_middleware.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import logging
3
+ from uuid import uuid4
4
+ from fastapi import Request
5
+ from starlette.middleware.base import BaseHTTPMiddleware
6
+
7
+ logger = logging.getLogger("middleware")
8
+
9
+ class RequestLoggingMiddleware(BaseHTTPMiddleware):
10
+ """
11
+ Middleware to log request start and completion details.
12
+ """
13
+ async def dispatch(self, request: Request, call_next):
14
+ request_id = request.headers.get("X-Request-ID", str(uuid4()))
15
+ # Store request_id in request state for access in routers/error handlers
16
+ request.state.request_id = request_id
17
+
18
+ start_time = time.time()
19
+
20
+ client_host = request.client.host if request.client else "unknown"
21
+ user_agent = request.headers.get("user-agent", "unknown")
22
+
23
+ # Log request start
24
+ logger.info(
25
+ f"Request started: {request.method} {request.url.path}",
26
+ extra={
27
+ "request_id": request_id,
28
+ "method": request.method,
29
+ "path": request.url.path,
30
+ "client": client_host,
31
+ "user_agent": user_agent
32
+ }
33
+ )
34
+
35
+ try:
36
+ response = await call_next(request)
37
+
38
+ process_time = time.time() - start_time
39
+
40
+ # Log request completion
41
+ logger.info(
42
+ f"Request completed: {request.method} {request.url.path} - Status: {response.status_code}",
43
+ extra={
44
+ "request_id": request_id,
45
+ "method": request.method,
46
+ "path": request.url.path,
47
+ "status_code": response.status_code,
48
+ "process_time": f"{process_time:.3f}s"
49
+ }
50
+ )
51
+
52
+ response.headers["X-Request-ID"] = request_id
53
+ return response
54
+
55
+ except Exception as e:
56
+ process_time = time.time() - start_time
57
+ logger.error(
58
+ f"Request failed: {request.method} {request.url.path}",
59
+ extra={
60
+ "request_id": request_id,
61
+ "method": request.method,
62
+ "path": request.url.path,
63
+ "status_code": 500,
64
+ "process_time": f"{process_time:.3f}s",
65
+ "error": str(e)
66
+ }
67
+ )
68
+ raise e
app/staff/controllers/router.py CHANGED
@@ -11,6 +11,7 @@ from app.staff.schemas.staff_schema import (
11
  StaffCreateSchema,
12
  StaffUpdateSchema,
13
  StaffResponseSchema,
 
14
  StaffListResponse,
15
  EmployeeUpdate,
16
  EmployeeResponse
@@ -198,63 +199,49 @@ async def update_employee(
198
  return await StaffService.update_employee(user_id, payload, x_user_id)
199
 
200
 
201
- @router.get(
202
- "",
203
- response_model=List[EmployeeResponse],
204
  summary="List staff",
205
  description="List staff with optional filters and pagination"
206
  )
207
  async def list_staff(
208
- designation: Optional[Designation] = Query(
209
- None,
210
- description="Filter by designation/role"
211
- ),
212
- manager_id: Optional[str] = Query(
213
- None,
214
- description="Filter by manager's user_id (direct reports)"
215
- ),
216
- status_filter: Optional[stafftatus] = Query(
217
- None,
218
- alias="status",
219
- description="Filter by employee status"
220
- ),
221
- region: Optional[str] = Query(
222
- None,
223
- description="Filter by region"
224
- ),
225
- skip: int = Query(
226
- 0,
227
- ge=0,
228
- description="Number of records to skip (pagination)"
229
- ),
230
- limit: int = Query(
231
- 100,
232
- ge=1,
233
- le=500,
234
- description="Maximum number of records to return"
235
- )
236
- ) -> List[EmployeeResponse]:
237
  """
238
  List staff with filters.
239
 
240
- **Query Parameters:**
241
  - `designation`: Filter by role (RSM, ASM, BDE, Trainer, etc.)
242
  - `manager_id`: Show only direct reports of specific manager
243
  - `status`: Filter by status (onboarding, active, inactive, suspended, terminated)
244
  - `region`: Filter by assigned region
 
 
245
  - `skip`: Pagination offset (default: 0)
246
  - `limit`: Page size (default: 100, max: 500)
247
 
 
 
 
248
  Returns:
249
- List of staff matching the filters
250
  """
251
- return await StaffService.list_staff(
252
- designation=designation,
253
- manager_id=manager_id,
254
- status_filter=status_filter,
255
- region=region,
256
- skip=skip,
257
- limit=limit
 
 
 
 
 
 
 
 
258
  )
259
 
260
 
@@ -319,12 +306,13 @@ async def get_employee_reports(
319
  # First verify employee exists
320
  await StaffService.get_employee(user_id)
321
 
322
- return await StaffService.list_staff(
323
  manager_id=user_id,
324
  status_filter=status_filter,
325
  skip=skip,
326
  limit=limit
327
  )
 
328
 
329
 
330
  @router.get(
 
11
  StaffCreateSchema,
12
  StaffUpdateSchema,
13
  StaffResponseSchema,
14
+ StaffListRequest,
15
  StaffListResponse,
16
  EmployeeUpdate,
17
  EmployeeResponse
 
199
  return await StaffService.update_employee(user_id, payload, x_user_id)
200
 
201
 
202
+ @router.post(
203
+ "/list",
204
+ response_model=StaffListResponse,
205
  summary="List staff",
206
  description="List staff with optional filters and pagination"
207
  )
208
  async def list_staff(
209
+ payload: StaffListRequest
210
+ ) -> StaffListResponse:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  """
212
  List staff with filters.
213
 
214
+ **Filters:**
215
  - `designation`: Filter by role (RSM, ASM, BDE, Trainer, etc.)
216
  - `manager_id`: Show only direct reports of specific manager
217
  - `status`: Filter by status (onboarding, active, inactive, suspended, terminated)
218
  - `region`: Filter by assigned region
219
+
220
+ **Pagination:**
221
  - `skip`: Pagination offset (default: 0)
222
  - `limit`: Page size (default: 100, max: 500)
223
 
224
+ **Projection:**
225
+ - `projection_list`: List of fields to include in response
226
+
227
  Returns:
228
+ List of staff matching the filters and total count
229
  """
230
+ items, total = await StaffService.list_staff(
231
+ designation=payload.designation,
232
+ manager_id=payload.manager_id,
233
+ status_filter=payload.status,
234
+ region=payload.region,
235
+ skip=payload.skip,
236
+ limit=payload.limit,
237
+ projection_list=payload.projection_list
238
+ )
239
+
240
+ return StaffListResponse(
241
+ items=items,
242
+ total=total,
243
+ skip=payload.skip,
244
+ limit=payload.limit
245
  )
246
 
247
 
 
306
  # First verify employee exists
307
  await StaffService.get_employee(user_id)
308
 
309
+ items, _ = await StaffService.list_staff(
310
  manager_id=user_id,
311
  status_filter=status_filter,
312
  skip=skip,
313
  limit=limit
314
  )
315
+ return items
316
 
317
 
318
  @router.get(
app/staff/schemas/staff_schema.py CHANGED
@@ -103,9 +103,9 @@ class StaffResponseSchema(BaseModel):
103
  class StaffListRequest(BaseModel):
104
  """Schema for staff list request with projection support."""
105
  merchant_id: Optional[str] = Field(None, description="Merchant identifier (optional, defaults to token merchant)")
106
- designation: Optional[str] = Field(None, description="Filter by designation/role")
107
  manager_id: Optional[str] = Field(None, description="Filter by manager's ID")
108
- status: Optional[str] = Field(None, description="Filter by status")
109
  role: Optional[str] = Field(None, description="Filter by role")
110
  region: Optional[str] = Field(None, description="Filter by region")
111
  skip: int = Field(0, ge=0, description="Number of records to skip")
@@ -117,7 +117,7 @@ class StaffListRequest(BaseModel):
117
 
118
  class StaffListResponse(BaseModel):
119
  """Schema for staff list response."""
120
- staff: List[StaffResponseSchema] # Always StaffResponseSchema (fields are optional to support projection)
121
  total: int
122
  skip: int
123
  limit: int
 
103
  class StaffListRequest(BaseModel):
104
  """Schema for staff list request with projection support."""
105
  merchant_id: Optional[str] = Field(None, description="Merchant identifier (optional, defaults to token merchant)")
106
+ designation: Optional[Designation] = Field(None, description="Filter by designation/role")
107
  manager_id: Optional[str] = Field(None, description="Filter by manager's ID")
108
+ status: Optional[Union[stafftatus, List[stafftatus]]] = Field(None, description="Filter by status")
109
  role: Optional[str] = Field(None, description="Filter by role")
110
  region: Optional[str] = Field(None, description="Filter by region")
111
  skip: int = Field(0, ge=0, description="Number of records to skip")
 
117
 
118
  class StaffListResponse(BaseModel):
119
  """Schema for staff list response."""
120
+ items: List[Union[EmployeeResponse, dict]] # Can be full response or projected dict
121
  total: int
122
  skip: int
123
  limit: int
app/staff/services/staff_service.py CHANGED
@@ -3,7 +3,7 @@ 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
@@ -190,54 +190,7 @@ class StaffService:
190
  detail=f"Error updating staff: {str(e)}"
191
  )
192
 
193
- @staticmethod
194
- async def list_staff(
195
- merchant_id: Optional[str] = None,
196
- status: Optional[str] = None,
197
- role: Optional[str] = None,
198
- skip: int = 0,
199
- limit: int = 100
200
- ) -> tuple[List[StaffResponseSchema], int]:
201
- """
202
- List staff with optional filters.
203
-
204
- Args:
205
- merchant_id: Filter by merchant
206
- status: Filter by status
207
- role: Filter by role
208
- skip: Number of records to skip
209
- limit: Maximum records to return
210
-
211
- Returns:
212
- Tuple of (staff list, total count)
213
- """
214
- try:
215
- # Build query
216
- query = {}
217
- if merchant_id:
218
- query["merchant_id"] = merchant_id
219
- if status:
220
- query["status"] = status
221
- if role:
222
- query["role"] = role
223
 
224
- # Get total count
225
- total = await get_database()[POS_STAFF_COLLECTION].count_documents(query)
226
-
227
- # Fetch staff
228
- cursor = get_database()[POS_STAFF_COLLECTION].find(query).skip(skip).limit(limit)
229
- staff_list = await cursor.to_list(length=limit)
230
-
231
- staff_responses = [StaffResponseSchema(**staff) for staff in staff_list]
232
-
233
- return staff_responses, total
234
-
235
- except Exception as e:
236
- logger.error(f"Error listing staff: {e}")
237
- raise HTTPException(
238
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
239
- detail="Error listing staff"
240
- )
241
 
242
  @staticmethod
243
  async def delete_staff(staff_id: str) -> Dict[str, str]:
@@ -415,11 +368,12 @@ class StaffService:
415
  async def list_staff(
416
  designation: Optional[Designation] = None,
417
  manager_id: Optional[str] = None,
418
- status_filter: Optional[stafftatus] = None,
419
  region: Optional[str] = None,
420
  skip: int = 0,
421
- limit: int = 100
422
- ) -> List[EmployeeResponse]:
 
423
  """List staff with filters (employee version)."""
424
  try:
425
  # Build query
@@ -428,16 +382,34 @@ class StaffService:
428
  query["designation"] = designation
429
  if manager_id:
430
  query["manager_id"] = manager_id
 
431
  if status_filter:
432
- query["status"] = status_filter
 
 
 
 
433
  if region:
434
  query["region"] = region
435
 
 
 
 
 
 
 
 
 
 
 
436
  # Fetch staff
437
- cursor = get_database()[POS_STAFF_COLLECTION].find(query).skip(skip).limit(limit)
438
  staff_list = await cursor.to_list(length=limit)
439
 
440
- return [EmployeeResponse(**staff) for staff in staff_list]
 
 
 
441
 
442
  except Exception as e:
443
  logger.error(f"Error listing staff: {e}")
 
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, Union
7
  from fastapi import HTTPException, status
8
  import logging
9
  import secrets
 
190
  detail=f"Error updating staff: {str(e)}"
191
  )
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
  @staticmethod
196
  async def delete_staff(staff_id: str) -> Dict[str, str]:
 
368
  async def list_staff(
369
  designation: Optional[Designation] = None,
370
  manager_id: Optional[str] = None,
371
+ status_filter: Optional[Union[stafftatus, List[stafftatus]]] = None,
372
  region: Optional[str] = None,
373
  skip: int = 0,
374
+ limit: int = 100,
375
+ projection_list: Optional[List[str]] = None
376
+ ) -> tuple[List[Union[EmployeeResponse, dict]], int]:
377
  """List staff with filters (employee version)."""
378
  try:
379
  # Build query
 
382
  query["designation"] = designation
383
  if manager_id:
384
  query["manager_id"] = manager_id
385
+
386
  if status_filter:
387
+ if isinstance(status_filter, list):
388
+ query["status"] = {"$in": status_filter}
389
+ else:
390
+ query["status"] = status_filter
391
+
392
  if region:
393
  query["region"] = region
394
 
395
+ # Get total count
396
+ total = await get_database()[POS_STAFF_COLLECTION].count_documents(query)
397
+
398
+ # Build projection
399
+ projection = None
400
+ if projection_list:
401
+ projection = {field: 1 for field in projection_list}
402
+ if "_id" not in projection_list:
403
+ projection["_id"] = 0
404
+
405
  # Fetch staff
406
+ cursor = get_database()[POS_STAFF_COLLECTION].find(query, projection).skip(skip).limit(limit)
407
  staff_list = await cursor.to_list(length=limit)
408
 
409
+ if projection_list:
410
+ return staff_list, total
411
+ else:
412
+ return [EmployeeResponse(**staff) for staff in staff_list], total
413
 
414
  except Exception as e:
415
  logger.error(f"Error listing staff: {e}")
app/utils/response.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
  Response formatting utilities for standardized API responses.
3
  """
4
- from typing import Any, Dict, Optional
5
  from datetime import datetime
6
  from uuid import uuid4
7
 
@@ -23,7 +23,7 @@ def success_response(
23
  Standardized success response dictionary
24
  """
25
  return {
26
- "status": True,
27
  "data": data,
28
  "message": message,
29
  "correlation_id": correlation_id or str(uuid4()),
@@ -32,33 +32,45 @@ def success_response(
32
 
33
 
34
  def error_response(
35
- code: str,
36
- message: str,
37
- details: Optional[Dict[str, Any]] = None,
38
- correlation_id: Optional[str] = None
 
 
39
  ) -> Dict[str, Any]:
40
  """
41
- Format an error API response.
42
 
43
  Args:
44
- code: Error code
45
- message: Human-readable error message
46
- details: Optional additional error details
47
- correlation_id: Optional correlation ID for request tracing
 
 
48
 
49
  Returns:
50
  Standardized error response dictionary
51
  """
52
- return {
53
- "status": False,
54
- "error": {
55
- "code": code,
56
- "message": message,
57
- "details": details or {}
58
- },
59
- "correlation_id": correlation_id or str(uuid4()),
60
- "timestamp": datetime.utcnow().isoformat() + "Z"
61
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
 
64
  def paginated_response(
 
1
  """
2
  Response formatting utilities for standardized API responses.
3
  """
4
+ from typing import Any, Dict, Optional, List
5
  from datetime import datetime
6
  from uuid import uuid4
7
 
 
23
  Standardized success response dictionary
24
  """
25
  return {
26
+ "success": True,
27
  "data": data,
28
  "message": message,
29
  "correlation_id": correlation_id or str(uuid4()),
 
32
 
33
 
34
  def error_response(
35
+ error: str,
36
+ detail: str,
37
+ errors: Optional[List[Dict[str, Any]]] = None,
38
+ code: Optional[str] = None, # Kept for backward compatibility if needed, but mapped to error title usually
39
+ request_id: Optional[str] = None,
40
+ headers: Optional[Dict[str, str]] = None
41
  ) -> Dict[str, Any]:
42
  """
43
+ Format an error API response according to the Error Handling Guide.
44
 
45
  Args:
46
+ error: Error title (e.g., "Validation Error", "Authentication Error")
47
+ detail: Human-readable error message
48
+ errors: Optional list of specific errors (e.g., validation fields)
49
+ code: Optional error code (legacy support, can be ignored or used as error title)
50
+ request_id: Optional request ID
51
+ headers: Optional headers (included in body as per guide example)
52
 
53
  Returns:
54
  Standardized error response dictionary
55
  """
56
+ response = {
57
+ "success": False,
58
+ "error": error or code or "Error",
59
+ "detail": detail
 
 
 
 
 
60
  }
61
+
62
+ if errors:
63
+ response["errors"] = errors
64
+
65
+ if request_id:
66
+ response["request_id"] = request_id
67
+
68
+ if headers:
69
+ response["headers"] = headers
70
+
71
+ response["timestamp"] = datetime.utcnow().isoformat() + "Z"
72
+
73
+ return response
74
 
75
 
76
  def paginated_response(
requirements.txt CHANGED
@@ -28,3 +28,4 @@ twilio==8.10.3
28
  aiosmtplib==3.0.1
29
 
30
  python-json-logger==2.0.7
 
 
28
  aiosmtplib==3.0.1
29
 
30
  python-json-logger==2.0.7
31
+
tests/test_error_handling.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import patch, AsyncMock
2
+ from fastapi.testclient import TestClient
3
+
4
+ # Mock database connections before importing app
5
+ with patch("app.main.connect_to_mongo", new_callable=AsyncMock), \
6
+ patch("app.main.connect_to_database", new_callable=AsyncMock):
7
+ from app.main import app
8
+
9
+ client = TestClient(app)
10
+
11
+ def test_404_error_handling():
12
+ response = client.get("/non-existent-route")
13
+ assert response.status_code == 404
14
+ data = response.json()
15
+ assert data["success"] is False
16
+ assert data["error"] == "Not Found"
17
+ assert data["detail"] == "Resource not found"
18
+ assert "request_id" in data
19
+ assert "timestamp" in data
20
+
21
+ def test_validation_error_handling():
22
+ # token query param is required for this endpoint
23
+ response = client.post("/debug/verify-token")
24
+ assert response.status_code == 422
25
+ data = response.json()
26
+ assert data["success"] is False
27
+ assert data["error"] == "Validation Error"
28
+ assert data["detail"] == "The request contains invalid data"
29
+ assert "errors" in data
30
+ assert isinstance(data["errors"], list)
31
+ assert len(data["errors"]) > 0
32
+ assert "field" in data["errors"][0]
33
+ assert "message" in data["errors"][0]
34
+ assert "type" in data["errors"][0]
tests/test_staff_list.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from unittest.mock import patch, AsyncMock
2
+ from fastapi.testclient import TestClient
3
+ from app.constants.staff_types import Designation, stafftatus
4
+
5
+ # Mock database connections before importing app
6
+ with patch("app.main.connect_to_mongo", new_callable=AsyncMock), \
7
+ patch("app.main.connect_to_database", new_callable=AsyncMock):
8
+ from app.main import app
9
+
10
+ client = TestClient(app)
11
+
12
+ @patch("app.staff.services.staff_service.StaffService.list_staff")
13
+ def test_staff_list_endpoint(mock_list_staff):
14
+ # Setup mock return value
15
+ mock_items = [
16
+ {
17
+ "user_id": "u1",
18
+ "name": "Test User",
19
+ "designation": "Stylist",
20
+ "status": "active",
21
+ "email": "test@example.com",
22
+ "phone": "1234567890",
23
+ "role": "Stylist",
24
+ "merchant_id": "m1"
25
+ }
26
+ ]
27
+ mock_list_staff.return_value = (mock_items, 1)
28
+
29
+ # Test payload
30
+ payload = {
31
+ "status": "active",
32
+ "skip": 0,
33
+ "limit": 10,
34
+ "projection_list": ["name", "email"]
35
+ }
36
+
37
+ response = client.post("/api/v1/staff/list", json=payload)
38
+
39
+ # Assertions
40
+ assert response.status_code == 200
41
+ data = response.json()
42
+ assert data["total"] == 1
43
+ assert len(data["items"]) == 1
44
+ assert data["items"][0]["name"] == "Test User"
45
+
46
+ # Verify mock call
47
+ mock_list_staff.assert_called_once()
48
+ call_kwargs = mock_list_staff.call_args.kwargs
49
+ assert call_kwargs["status_filter"] == "active"
50
+ assert call_kwargs["projection_list"] == ["name", "email"]
51
+
52
+ @patch("app.staff.services.staff_service.StaffService.list_staff")
53
+ def test_staff_list_with_list_status(mock_list_staff):
54
+ # Setup mock return value
55
+ mock_list_staff.return_value = ([], 0)
56
+
57
+ # Test payload with list of statuses
58
+ payload = {
59
+ "status": ["active", "inactive"],
60
+ "skip": 0,
61
+ "limit": 10
62
+ }
63
+
64
+ response = client.post("/api/v1/staff/list", json=payload)
65
+
66
+ # Assertions
67
+ assert response.status_code == 200
68
+
69
+ # Verify mock call
70
+ mock_list_staff.assert_called_once()
71
+ call_kwargs = mock_list_staff.call_args.kwargs
72
+ assert call_kwargs["status_filter"] == ["active", "inactive"]