MukeshKapoor25 commited on
Commit
8a996cb
·
1 Parent(s): e8a6399

feat: Enhance audit trail with created_by and updated_by fields across services

Browse files

- Added `created_by_username` and `updated_by_username` fields to relevant schemas and models for tracking user actions.
- Implemented utility functions to extract and format audit fields for consistency across services.
- Updated service methods to include audit information during create and update operations.
- Refactored customer and staff controllers to pass user information for audit purposes.
- Introduced `MetaSchema` for standardized audit information in API responses.
- Enhanced documentation for new schemas and utility functions.

POS_META_SCHEMA_IMPLEMENTATION.md ADDED
@@ -0,0 +1,563 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # POS Microservice: Meta Schema Implementation
2
+
3
+ ## Overview
4
+
5
+ This document describes the implementation of the **Meta Schema Pattern** in the POS microservice. This pattern groups audit fields (created_by, created_at, updated_by, updated_at) under a single 'meta' object in API responses, providing consistent audit trail information across all entities.
6
+
7
+ ## Implementation Date
8
+
9
+ December 2024
10
+
11
+ ## Modules Updated
12
+
13
+ 1. **Customers Module** (`app/customers/`)
14
+ 2. **Staff Module** (`app/staff/`)
15
+
16
+ ## Core Infrastructure
17
+
18
+ ### 1. Core Schemas (`app/core/schemas.py`)
19
+
20
+ Added the following reusable schemas:
21
+
22
+ - **MetaSchema**: Defines the structure for audit information
23
+ - **PaginationMeta**: Pagination information for list endpoints
24
+ - **ErrorDetail**: Standard error detail structure
25
+ - **SuccessResponse**: Standard success response
26
+ - **StatusResponse**: Standard status response with optional data payload
27
+ - **BaseLazyFetchSchema**: Base schema for list requests with pagination
28
+
29
+ ### 2. Core Utilities (`app/core/utils.py`)
30
+
31
+ Three essential utility functions:
32
+
33
+ #### format_meta_field(document: dict) -> dict
34
+ Transforms database documents to API response format by:
35
+ - Extracting audit fields from document
36
+ - Creating 'meta' object with proper field mapping
37
+ - Removing audit fields from top level
38
+ - Returning formatted document
39
+
40
+ **Field Mapping:**
41
+ ```
42
+ Database Field → Response Field
43
+ ---------------- ----------------
44
+ created_by (UUID) → meta.created_by_id
45
+ created_by_username → meta.created_by
46
+ created_at → meta.created_at
47
+ updated_by (UUID) → meta.updated_by_id
48
+ updated_by_username → meta.updated_by
49
+ updated_at → meta.updated_at
50
+ ```
51
+
52
+ #### extract_audit_fields(user_id: str, username: str, is_update: bool) -> dict
53
+ Creates audit field dictionaries for database operations:
54
+ - For creation (`is_update=False`): Returns created_by, created_by_username, created_at
55
+ - For updates (`is_update=True`): Returns updated_by, updated_by_username, updated_at
56
+
57
+ #### normalize_uuid_fields(document: dict, fields: List[str]) -> dict
58
+ Converts UUID objects to strings for JSON serialization.
59
+
60
+ ### 3. Core Documentation (`app/core/README.md`)
61
+
62
+ Comprehensive documentation with:
63
+ - Usage examples for all entities
64
+ - Implementation guide for new modules
65
+ - Benefits and best practices
66
+ - Migration checklist
67
+
68
+ ## Customers Module Implementation
69
+
70
+ ### Changes Made
71
+
72
+ #### 1. Model (`app/customers/models/model.py`)
73
+
74
+ **Added Audit Fields:**
75
+ ```python
76
+ class CustomerModel(BaseModel):
77
+ # ... existing fields ...
78
+
79
+ # Audit fields
80
+ created_by: str = Field(..., description="User ID who created")
81
+ created_by_username: Optional[str] = Field(None, description="Username who created")
82
+ created_at: datetime = Field(default_factory=datetime.utcnow)
83
+ updated_by: Optional[str] = Field(None, description="User ID who updated")
84
+ updated_by_username: Optional[str] = Field(None, description="Username who updated")
85
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
86
+ ```
87
+
88
+ #### 2. Schemas (`app/customers/schemas/schema.py`)
89
+
90
+ **Updated CustomerCreate:**
91
+ ```python
92
+ class CustomerCreate(CustomerBase):
93
+ merchant_id: Optional[str] = Field(None, description="Merchant identifier (UUID) - will be overridden by token")
94
+ created_by_username: Optional[str] = Field(None, description="Username who created - will be set from token")
95
+ ```
96
+
97
+ **Updated CustomerUpdate:**
98
+ ```python
99
+ class CustomerUpdate(BaseModel):
100
+ # ... existing fields ...
101
+ updated_by_username: Optional[str] = Field(None, description="Username who updated - will be set from token")
102
+ ```
103
+
104
+ **Updated CustomerResponse:**
105
+ ```python
106
+ class CustomerResponse(CustomerBase):
107
+ customer_id: str = Field(..., description="Customer identifier (UUID)")
108
+ meta: Dict[str, Any] = Field(..., description="Audit information (created_by, created_at, updated_by, updated_at)")
109
+
110
+ class Config:
111
+ from_attributes = True
112
+ ```
113
+
114
+ #### 3. Service (`app/customers/services/service.py`)
115
+
116
+ **Added Imports:**
117
+ ```python
118
+ from app.core.utils import format_meta_field, extract_audit_fields
119
+ ```
120
+
121
+ **Updated create_customer:**
122
+ - Added `user_id` and `username` parameters
123
+ - Uses `extract_audit_fields(is_update=False)` to add audit fields
124
+ - Uses `format_meta_field()` before returning CustomerResponse
125
+
126
+ **Updated get_customer:**
127
+ - Uses `format_meta_field()` before returning CustomerResponse
128
+
129
+ **Updated list_customers:**
130
+ - Uses `format_meta_field()` for each document when not using projection
131
+ - Returns raw dicts when projection is used (for performance)
132
+
133
+ **Updated update_customer:**
134
+ - Added `user_id` and `username` parameters
135
+ - Uses `extract_audit_fields(is_update=True)` to add update audit fields
136
+ - Uses `format_meta_field()` before returning CustomerResponse
137
+
138
+ **Updated delete_customer:**
139
+ - Added `user_id` and `username` parameters
140
+ - Uses `extract_audit_fields(is_update=True)` for soft delete audit trail
141
+ - Uses `format_meta_field()` before returning CustomerResponse
142
+
143
+ #### 4. Controller (`app/customers/controllers/router.py`)
144
+
145
+ **Updated create_customer endpoint:**
146
+ ```python
147
+ async def create_customer(
148
+ payload: CustomerCreate,
149
+ current_user: TokenUser = Depends(require_pos_permission("customers", "create"))
150
+ ):
151
+ payload.created_by_username = current_user.username
152
+ customer = await CustomerService.create_customer(
153
+ payload,
154
+ current_user.merchant_id,
155
+ current_user.user_id,
156
+ current_user.username
157
+ )
158
+ ```
159
+
160
+ **Updated update_customer endpoint:**
161
+ ```python
162
+ async def update_customer(
163
+ customer_id: str,
164
+ payload: CustomerUpdate,
165
+ current_user: TokenUser = Depends(require_pos_permission("customers", "update"))
166
+ ):
167
+ payload.updated_by_username = current_user.username
168
+ customer = await CustomerService.update_customer(
169
+ customer_id=customer_id,
170
+ payload=payload,
171
+ merchant_id=current_user.merchant_id,
172
+ merchant_type=current_user.merchant_type,
173
+ user_id=current_user.user_id,
174
+ username=current_user.username
175
+ )
176
+ ```
177
+
178
+ **Updated delete_customer endpoint:**
179
+ ```python
180
+ async def delete_customer(
181
+ customer_id: str,
182
+ current_user: TokenUser = Depends(require_pos_permission("customers", "delete"))
183
+ ):
184
+ await CustomerService.delete_customer(
185
+ customer_id=customer_id,
186
+ merchant_id=current_user.merchant_id,
187
+ merchant_type=current_user.merchant_type,
188
+ user_id=current_user.user_id,
189
+ username=current_user.username
190
+ )
191
+ ```
192
+
193
+ ### API Response Example (Customers)
194
+
195
+ **Before (Old Format):**
196
+ ```json
197
+ {
198
+ "customer_id": "cus_123",
199
+ "name": "John Doe",
200
+ "phone": "+91-9988776655",
201
+ "email": "john@example.com",
202
+ "status": "active",
203
+ "created_at": "2023-01-10T08:00:00Z",
204
+ "updated_at": "2024-11-24T10:00:00Z"
205
+ }
206
+ ```
207
+
208
+ **After (Meta Format):**
209
+ ```json
210
+ {
211
+ "customer_id": "cus_123",
212
+ "name": "John Doe",
213
+ "phone": "+91-9988776655",
214
+ "email": "john@example.com",
215
+ "status": "active",
216
+ "meta": {
217
+ "created_by": "admin",
218
+ "created_by_id": "usr_01HZQX5K3N2P8R6T4V9W",
219
+ "created_at": "2023-01-10T08:00:00Z",
220
+ "updated_by": "manager",
221
+ "updated_by_id": "usr_01HZQX5K3N2P8R6T4V9X",
222
+ "updated_at": "2024-11-24T10:00:00Z"
223
+ }
224
+ }
225
+ ```
226
+
227
+ ## Staff Module Implementation
228
+
229
+ ### Changes Made
230
+
231
+ #### 1. Model (`app/staff/models/staff_model.py`)
232
+
233
+ **Added Audit Fields:**
234
+ ```python
235
+ class StaffModel(BaseModel):
236
+ # ... existing fields ...
237
+
238
+ # Audit fields
239
+ created_by: str = Field(..., description="User ID who created")
240
+ created_by_username: Optional[str] = Field(None, description="Username who created")
241
+ created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation timestamp")
242
+ updated_by: Optional[str] = Field(None, description="User ID who updated")
243
+ updated_by_username: Optional[str] = Field(None, description="Username who updated")
244
+ updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update timestamp")
245
+ ```
246
+
247
+ #### 2. Schemas (`app/staff/schemas/staff_schema.py`)
248
+
249
+ **Updated StaffCreateSchema:**
250
+ ```python
251
+ class StaffCreateSchema(BaseModel):
252
+ # ... existing fields ...
253
+ created_by_username: Optional[str] = Field(None, description="Username who created - will be set from token")
254
+ ```
255
+
256
+ **Updated StaffUpdateSchema:**
257
+ ```python
258
+ class StaffUpdateSchema(BaseModel):
259
+ # ... existing fields ...
260
+ updated_by_username: Optional[str] = Field(None, description="Username who updated - will be set from token")
261
+ ```
262
+
263
+ **Updated StaffResponseSchema:**
264
+ ```python
265
+ class StaffResponseSchema(BaseModel):
266
+ staff_id: UUID
267
+ merchant_id: UUID
268
+ name: str
269
+ role: str
270
+ # ... other fields ...
271
+ meta: dict = Field(..., description="Audit information (created_by, created_at, updated_by, updated_at)")
272
+
273
+ class Config:
274
+ from_attributes = True
275
+ ```
276
+
277
+ #### 3. Service (`app/staff/services/staff_service.py`)
278
+
279
+ **Added Imports:**
280
+ ```python
281
+ from app.core.utils import format_meta_field, extract_audit_fields
282
+ ```
283
+
284
+ **Updated create_staff:**
285
+ - Added `user_id` and `username` parameters
286
+ - Uses `extract_audit_fields(is_update=False)` to add audit fields
287
+ - Uses `format_meta_field()` before returning StaffResponseSchema
288
+
289
+ **Updated get_staff_by_id:**
290
+ - Uses `format_meta_field()` before returning StaffResponseSchema
291
+
292
+ **Updated update_staff:**
293
+ - Added `user_id` and `username` parameters
294
+ - Uses `extract_audit_fields(is_update=True)` to add update audit fields
295
+ - Uses `format_meta_field()` before returning StaffResponseSchema (via get_staff_by_id)
296
+
297
+ **Note:** list_staff returns raw dicts and doesn't need formatting (projection-based approach).
298
+
299
+ #### 4. Controller (`app/staff/controllers/router.py`)
300
+
301
+ **Updated create_staff endpoint:**
302
+ ```python
303
+ async def create_staff(
304
+ payload: StaffCreateSchema,
305
+ current_user: TokenUser = Depends(require_pos_permission("staff", "create"))
306
+ ):
307
+ payload.created_by_username = current_user.username
308
+ result = await StaffService.create_staff(
309
+ payload,
310
+ user_id=current_user.user_id,
311
+ username=current_user.username
312
+ )
313
+ ```
314
+
315
+ **Updated update_staff endpoint:**
316
+ ```python
317
+ async def update_staff(
318
+ staff_id: UUID,
319
+ payload: StaffUpdateSchema,
320
+ current_user: TokenUser = Depends(require_pos_permission("staff", "edit"))
321
+ ):
322
+ payload.updated_by_username = current_user.username
323
+ result = await StaffService.update_staff(
324
+ staff_id,
325
+ current_user.merchant_id,
326
+ payload,
327
+ user_id=current_user.user_id,
328
+ username=current_user.username
329
+ )
330
+ ```
331
+
332
+ **Updated update_staff_status endpoint:**
333
+ ```python
334
+ async def update_staff_status(
335
+ staff_id: UUID,
336
+ payload: UpdateStatusRequest,
337
+ current_user: TokenUser = Depends(require_pos_permission("staff", "edit"))
338
+ ):
339
+ update_payload = StaffUpdateSchema(
340
+ status=payload.status,
341
+ updated_by_username=current_user.username
342
+ )
343
+ result = await StaffService.update_staff(
344
+ staff_id,
345
+ current_user.merchant_id,
346
+ update_payload,
347
+ user_id=current_user.user_id,
348
+ username=current_user.username
349
+ )
350
+ ```
351
+
352
+ ### API Response Example (Staff)
353
+
354
+ **Before (Old Format):**
355
+ ```json
356
+ {
357
+ "staff_id": "staff_123",
358
+ "merchant_id": "merchant_789",
359
+ "name": "Aarav Sharma",
360
+ "role": "Stylist",
361
+ "phone": "+91-9876543210",
362
+ "email": "aarav@retail.com",
363
+ "status": "active",
364
+ "created_at": "2024-01-15T10:30:00Z",
365
+ "updated_at": "2024-01-15T10:30:00Z"
366
+ }
367
+ ```
368
+
369
+ **After (Meta Format):**
370
+ ```json
371
+ {
372
+ "staff_id": "staff_123",
373
+ "merchant_id": "merchant_789",
374
+ "name": "Aarav Sharma",
375
+ "role": "Stylist",
376
+ "phone": "+91-9876543210",
377
+ "email": "aarav@retail.com",
378
+ "status": "active",
379
+ "meta": {
380
+ "created_by": "admin",
381
+ "created_by_id": "usr_01HZQX5K3N2P8R6T4V9W",
382
+ "created_at": "2024-01-15T10:30:00Z",
383
+ "updated_by": "manager",
384
+ "updated_by_id": "usr_01HZQX5K3N2P8R6T4V9X",
385
+ "updated_at": "2024-12-01T14:20:00Z"
386
+ }
387
+ }
388
+ ```
389
+
390
+ ## Benefits
391
+
392
+ ### 1. Consistency
393
+ All entities (customers, staff, appointments, sales, etc.) use the same meta structure for audit information.
394
+
395
+ ### 2. Human-Readable Audit Trail
396
+ Response includes both username (human-readable) and user_id (for system operations):
397
+ - `meta.created_by`: "admin" (easy to understand)
398
+ - `meta.created_by_id`: "usr_01HZQX..." (for system lookups)
399
+
400
+ ### 3. Clean API Responses
401
+ Audit fields are grouped under 'meta', reducing clutter in the main response body.
402
+
403
+ ### 4. Reusability
404
+ Utilities are in `app/core/utils.py` and can be used across all modules without duplication.
405
+
406
+ ### 5. Type Safety
407
+ Pydantic schemas ensure correct structure and validation.
408
+
409
+ ### 6. Database Flexibility
410
+ Database documents maintain flat structure (good for querying), while API responses use nested structure (good for clarity).
411
+
412
+ ## Implementation Pattern Summary
413
+
414
+ For any new module or entity, follow this pattern:
415
+
416
+ ### Step 1: Update Model
417
+ ```python
418
+ # Add audit fields to MongoDB model
419
+ created_by: str
420
+ created_by_username: Optional[str]
421
+ created_at: datetime
422
+ updated_by: Optional[str]
423
+ updated_by_username: Optional[str]
424
+ updated_at: datetime
425
+ ```
426
+
427
+ ### Step 2: Update Schemas
428
+ ```python
429
+ # Create schema: add created_by_username
430
+ class EntityCreate(BaseModel):
431
+ created_by_username: Optional[str] = None
432
+
433
+ # Update schema: add updated_by_username
434
+ class EntityUpdate(BaseModel):
435
+ updated_by_username: Optional[str] = None
436
+
437
+ # Response schema: replace timestamps with meta
438
+ class EntityResponse(BaseModel):
439
+ meta: Dict[str, Any] # Instead of created_at, updated_at
440
+ ```
441
+
442
+ ### Step 3: Update Service
443
+ ```python
444
+ from app.core.utils import format_meta_field, extract_audit_fields
445
+
446
+ # In create method
447
+ async def create_entity(payload, user_id, username):
448
+ data = payload.dict()
449
+ audit_fields = extract_audit_fields(user_id, username, is_update=False)
450
+ data.update(audit_fields)
451
+ # ... insert into DB ...
452
+ formatted = format_meta_field(data)
453
+ return EntityResponse(**formatted)
454
+
455
+ # In update method
456
+ async def update_entity(entity_id, payload, user_id, username):
457
+ update_data = payload.dict(exclude_unset=True)
458
+ audit_fields = extract_audit_fields(user_id, username, is_update=True)
459
+ update_data.update(audit_fields)
460
+ # ... update in DB ...
461
+ doc = await get_entity(entity_id)
462
+ formatted = format_meta_field(doc)
463
+ return EntityResponse(**formatted)
464
+
465
+ # In get/list methods
466
+ async def get_entity(entity_id):
467
+ doc = await db.find_one({"entity_id": entity_id})
468
+ formatted = format_meta_field(doc)
469
+ return EntityResponse(**formatted)
470
+ ```
471
+
472
+ ### Step 4: Update Controller
473
+ ```python
474
+ # Pass user info from token to service
475
+ @router.post("/")
476
+ async def create_entity(
477
+ payload: EntityCreate,
478
+ current_user: TokenUser = Depends(get_current_user)
479
+ ):
480
+ payload.created_by_username = current_user.username
481
+ return await EntityService.create_entity(
482
+ payload,
483
+ user_id=current_user.user_id,
484
+ username=current_user.username
485
+ )
486
+
487
+ @router.put("/{entity_id}")
488
+ async def update_entity(
489
+ entity_id: str,
490
+ payload: EntityUpdate,
491
+ current_user: TokenUser = Depends(get_current_user)
492
+ ):
493
+ payload.updated_by_username = current_user.username
494
+ return await EntityService.update_entity(
495
+ entity_id,
496
+ payload,
497
+ user_id=current_user.user_id,
498
+ username=current_user.username
499
+ )
500
+ ```
501
+
502
+ ## Testing Checklist
503
+
504
+ For each updated module:
505
+
506
+ - [ ] Test POST (create) endpoint - verify meta.created_by and meta.created_at
507
+ - [ ] Test GET (retrieve) endpoint - verify meta structure
508
+ - [ ] Test PUT/PATCH (update) endpoint - verify meta.updated_by and meta.updated_at
509
+ - [ ] Test DELETE (soft delete) endpoint - verify meta.updated_by
510
+ - [ ] Test list endpoint without projection - verify meta in each item
511
+ - [ ] Test list endpoint with projection - verify raw fields returned
512
+ - [ ] Verify database documents maintain flat structure
513
+ - [ ] Verify API responses use nested meta structure
514
+
515
+ ## Migration Notes
516
+
517
+ ### Backward Compatibility
518
+
519
+ - **Database**: Existing documents without audit fields will still work (fields are optional in get operations)
520
+ - **API**: Responses now include 'meta' instead of top-level created_at/updated_at
521
+ - **Clients**: Frontend/mobile apps will need updates to read from meta object
522
+
523
+ ### Database Migration
524
+
525
+ No migration needed for existing documents. New documents will have audit fields, old documents will work without them (format_meta_field handles missing fields gracefully).
526
+
527
+ ### Rollback Plan
528
+
529
+ If needed, can revert by:
530
+ 1. Removing meta field from response schemas
531
+ 2. Adding back created_at/updated_at to response schemas
532
+ 3. Removing format_meta_field calls from services
533
+ 4. Removing extract_audit_fields calls from create/update operations
534
+
535
+ ## Future Enhancements
536
+
537
+ ### Potential Additions
538
+
539
+ 1. **Deletion Audit**: Add deleted_by, deleted_by_username, deleted_at for hard deletes
540
+ 2. **Version Tracking**: Add version number to meta for optimistic locking
541
+ 3. **Change History**: Store full change history in separate collection
542
+ 4. **IP Address Tracking**: Add request_ip to audit fields
543
+ 5. **Reason Tracking**: Add change_reason field for important updates
544
+
545
+ ### Other Modules to Update
546
+
547
+ - **Appointments Module**: Apply same pattern
548
+ - **Sales Module**: Apply same pattern
549
+ - **Wallet Module**: Apply same pattern
550
+ - **Inventory Module** (if exists): Apply same pattern
551
+
552
+ ## Related Documentation
553
+
554
+ - [Core README](/app/core/README.md) - Detailed usage guide for core schemas and utilities
555
+ - [SCM Meta Implementation](../../cuatrolabs-scm-ms/TRANSPORTS_META_IMPLEMENTATION.md) - Similar implementation in SCM microservice
556
+
557
+ ## Contact
558
+
559
+ For questions or issues with this implementation, contact the backend team.
560
+
561
+ ## Version History
562
+
563
+ - **v1.0** (December 2024): Initial implementation for Customers and Staff modules
app/catalogue_services/schemas/schema.py CHANGED
@@ -1,7 +1,7 @@
1
  """
2
  Pydantic schemas for Catalogue Services API.
3
  """
4
- from typing import Optional, List, Union
5
  from pydantic import BaseModel, Field, constr
6
  from datetime import datetime
7
 
@@ -23,6 +23,7 @@ class CreateServiceRequest(BaseModel):
23
  description: Optional[str] = None
24
  duration_mins: int = Field(..., ge=1)
25
  pricing: Pricing
 
26
 
27
  class UpdateServiceRequest(BaseModel):
28
  name: Optional[str] = Field(None, min_length=1, max_length=200)
@@ -32,6 +33,7 @@ class UpdateServiceRequest(BaseModel):
32
  description: Optional[str] = None
33
  duration_mins: Optional[int] = Field(None, ge=1)
34
  pricing: Optional[Pricing] = None
 
35
 
36
 
37
  class UpdateStatusRequest(BaseModel):
@@ -48,8 +50,7 @@ class ServiceResponse(BaseModel):
48
  pricing: dict
49
  status: str
50
  sort_order: int = 0
51
- created_at: datetime
52
- updated_at: datetime
53
 
54
  class ListServicesRequest(BaseModel):
55
  merchant_id: Optional[str] = None
 
1
  """
2
  Pydantic schemas for Catalogue Services API.
3
  """
4
+ from typing import Optional, List, Union, Dict, Any
5
  from pydantic import BaseModel, Field, constr
6
  from datetime import datetime
7
 
 
23
  description: Optional[str] = None
24
  duration_mins: int = Field(..., ge=1)
25
  pricing: Pricing
26
+ created_by_username: Optional[str] = Field(None, description="Username who created - will be set from token")
27
 
28
  class UpdateServiceRequest(BaseModel):
29
  name: Optional[str] = Field(None, min_length=1, max_length=200)
 
33
  description: Optional[str] = None
34
  duration_mins: Optional[int] = Field(None, ge=1)
35
  pricing: Optional[Pricing] = None
36
+ updated_by_username: Optional[str] = Field(None, description="Username who updated - will be set from token")
37
 
38
 
39
  class UpdateStatusRequest(BaseModel):
 
50
  pricing: dict
51
  status: str
52
  sort_order: int = 0
53
+ meta: Dict[str, Any] = Field(..., description="Audit information (created_by, created_at, updated_by, updated_at)")
 
54
 
55
  class ListServicesRequest(BaseModel):
56
  merchant_id: Optional[str] = None
app/core/README.md ADDED
@@ -0,0 +1,316 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core Schemas and Utilities
2
+
3
+ This directory contains reusable schemas and utility functions that can be used across all modules in the POS microservice.
4
+
5
+ ## Files
6
+
7
+ ### `schemas.py`
8
+ Contains common Pydantic schemas for consistent API responses.
9
+
10
+ ### `utils.py`
11
+ Contains utility functions for common operations like formatting meta fields.
12
+
13
+ ## Available Schemas
14
+
15
+ ### 1. MetaSchema
16
+
17
+ Groups audit information in a consistent structure across all entities.
18
+
19
+ **Usage in Response Schemas:**
20
+
21
+ ```python
22
+ from app.core.schemas import MetaSchema
23
+
24
+ class CustomerResponse(BaseModel):
25
+ customer_id: str
26
+ name: str
27
+ # ... other fields
28
+ meta: Dict[str, Any] # Will contain MetaSchema structure
29
+ ```
30
+
31
+ **Fields:**
32
+ - `created_by`: Username who created the record (human-readable)
33
+ - `created_by_id`: UUID who created the record
34
+ - `created_at`: Creation timestamp
35
+ - `updated_by`: Username who last updated (optional)
36
+ - `updated_by_id`: UUID who last updated (optional)
37
+ - `updated_at`: Last update timestamp (optional)
38
+
39
+ **Example Response:**
40
+ ```json
41
+ {
42
+ "customer_id": "cus_123",
43
+ "name": "John Doe",
44
+ "meta": {
45
+ "created_by": "admin",
46
+ "created_by_id": "usr_01HZQX5K3N2P8R6T4V9W",
47
+ "created_at": "2023-01-10T08:00:00Z",
48
+ "updated_by": "manager",
49
+ "updated_by_id": "usr_01HZQX5K3N2P8R6T4V9X",
50
+ "updated_at": "2024-11-24T10:00:00Z"
51
+ }
52
+ }
53
+ ```
54
+
55
+ ### 2. PaginationMeta
56
+
57
+ Provides pagination information for list endpoints.
58
+
59
+ ### 3. ErrorDetail
60
+
61
+ Standard error detail structure for validation errors.
62
+
63
+ ### 4. SuccessResponse
64
+
65
+ Standard success response for operations without data.
66
+
67
+ ### 5. StatusResponse
68
+
69
+ Standard status response for operations with optional data payload.
70
+
71
+ ### 6. BaseLazyFetchSchema
72
+
73
+ Base schema for lazy-fetch list requests with pagination and projection support.
74
+
75
+ ## Available Utilities
76
+
77
+ ### 1. format_meta_field()
78
+
79
+ Transforms database documents to API response format by grouping audit fields under 'meta'.
80
+
81
+ **Usage in Service Layer:**
82
+
83
+ ```python
84
+ from app.core.utils import format_meta_field
85
+
86
+ class CustomerService:
87
+ @staticmethod
88
+ async def get_customer(customer_id: str) -> CustomerResponse:
89
+ # Fetch from database
90
+ customer = await db.customers.find_one({"customer_id": customer_id})
91
+
92
+ # Format with meta field
93
+ formatted = format_meta_field(customer)
94
+
95
+ # Return response
96
+ return CustomerResponse(**formatted)
97
+ ```
98
+
99
+ **What it does:**
100
+ - Extracts audit fields from document
101
+ - Creates 'meta' object with proper field mapping
102
+ - Removes audit fields from top level
103
+ - Returns formatted document
104
+
105
+ **Field Mapping:**
106
+ ```
107
+ DB Field → Response Field
108
+ ---------------- ---------------
109
+ created_by (UUID) → meta.created_by_id
110
+ created_by_username → meta.created_by
111
+ created_at → meta.created_at
112
+ updated_by (UUID) → meta.updated_by_id
113
+ updated_by_username → meta.updated_by
114
+ updated_at → meta.updated_at
115
+ ```
116
+
117
+ ### 2. extract_audit_fields()
118
+
119
+ Creates audit field dictionaries for database operations.
120
+
121
+ **Usage for Creation:**
122
+
123
+ ```python
124
+ from app.core.utils import extract_audit_fields
125
+
126
+ # When creating a new record
127
+ audit_fields = extract_audit_fields(
128
+ user_id=current_user.user_id,
129
+ username=current_user.username,
130
+ is_update=False
131
+ )
132
+
133
+ customer_data = {
134
+ "customer_id": "cus_123",
135
+ "name": "John Doe",
136
+ **audit_fields # Adds created_by, created_by_username, created_at
137
+ }
138
+ ```
139
+
140
+ **Usage for Updates:**
141
+
142
+ ```python
143
+ # When updating a record
144
+ audit_fields = extract_audit_fields(
145
+ user_id=current_user.user_id,
146
+ username=current_user.username,
147
+ is_update=True
148
+ )
149
+
150
+ update_data = {
151
+ "name": "New Name",
152
+ **audit_fields # Adds updated_by, updated_by_username, updated_at
153
+ }
154
+ ```
155
+
156
+ ### 3. normalize_uuid_fields()
157
+
158
+ Converts UUID objects to strings for JSON serialization.
159
+
160
+ ## Implementation Guide for New Modules
161
+
162
+ ### Step 1: Update Model
163
+
164
+ Add audit fields to your MongoDB model:
165
+
166
+ ```python
167
+ from pydantic import BaseModel, Field
168
+ from datetime import datetime
169
+ from typing import Optional
170
+
171
+ class CustomerModel(BaseModel):
172
+ customer_id: str
173
+ name: str
174
+ # ... other fields
175
+
176
+ # Audit fields
177
+ created_by: str = Field(..., description="User ID who created")
178
+ created_by_username: Optional[str] = Field(None, description="Username who created")
179
+ created_at: datetime = Field(default_factory=datetime.utcnow)
180
+ updated_by: Optional[str] = Field(None, description="User ID who updated")
181
+ updated_by_username: Optional[str] = Field(None, description="Username who updated")
182
+ updated_at: Optional[datetime] = Field(None)
183
+ ```
184
+
185
+ ### Step 2: Update Response Schema
186
+
187
+ Import MetaSchema and use it in your response:
188
+
189
+ ```python
190
+ from typing import Dict, Any
191
+ from pydantic import BaseModel
192
+ from app.core.schemas import MetaSchema
193
+
194
+ class CustomerResponse(BaseModel):
195
+ customer_id: str
196
+ name: str
197
+ # ... other fields
198
+ meta: Dict[str, Any] # Contains MetaSchema structure
199
+ ```
200
+
201
+ ### Step 3: Update Service Layer
202
+
203
+ Use the utility functions in your service:
204
+
205
+ ```python
206
+ from app.core.utils import format_meta_field, extract_audit_fields
207
+
208
+ class CustomerService:
209
+ @staticmethod
210
+ async def create_customer(payload: CustomerCreate, current_user: TokenUser):
211
+ # Add audit fields for creation
212
+ audit_fields = extract_audit_fields(
213
+ user_id=current_user.user_id,
214
+ username=current_user.username,
215
+ is_update=False
216
+ )
217
+
218
+ customer_data = {
219
+ **payload.dict(),
220
+ **audit_fields
221
+ }
222
+
223
+ # Insert into database
224
+ await db.customers.insert_one(customer_data)
225
+
226
+ # Format response with meta
227
+ formatted = format_meta_field(customer_data)
228
+ return CustomerResponse(**formatted)
229
+
230
+ @staticmethod
231
+ async def update_customer(
232
+ customer_id: str,
233
+ payload: CustomerUpdate,
234
+ current_user: TokenUser
235
+ ):
236
+ # Add audit fields for update
237
+ audit_fields = extract_audit_fields(
238
+ user_id=current_user.user_id,
239
+ username=current_user.username,
240
+ is_update=True
241
+ )
242
+
243
+ update_data = {
244
+ **payload.dict(exclude_unset=True),
245
+ **audit_fields
246
+ }
247
+
248
+ # Update in database
249
+ await db.customers.update_one(
250
+ {"customer_id": customer_id},
251
+ {"$set": update_data}
252
+ )
253
+
254
+ # Fetch and format response
255
+ customer = await db.customers.find_one({"customer_id": customer_id})
256
+ formatted = format_meta_field(customer)
257
+ return CustomerResponse(**formatted)
258
+
259
+ @staticmethod
260
+ async def get_customer(customer_id: str):
261
+ customer = await db.customers.find_one({"customer_id": customer_id})
262
+ if not customer:
263
+ raise HTTPException(status_code=404, detail="Customer not found")
264
+
265
+ # Format with meta
266
+ formatted = format_meta_field(customer)
267
+ return CustomerResponse(**formatted)
268
+ ```
269
+
270
+ ### Step 4: Update Controller
271
+
272
+ Ensure controllers pass both user_id and username:
273
+
274
+ ```python
275
+ @router.post("/")
276
+ async def create_customer(
277
+ payload: CustomerCreate,
278
+ current_user: TokenUser = Depends(get_current_user)
279
+ ):
280
+ return await CustomerService.create_customer(payload, current_user)
281
+
282
+ @router.patch("/{customer_id}")
283
+ async def update_customer(
284
+ customer_id: str,
285
+ payload: CustomerUpdate,
286
+ current_user: TokenUser = Depends(get_current_user)
287
+ ):
288
+ return await CustomerService.update_customer(
289
+ customer_id,
290
+ payload,
291
+ current_user
292
+ )
293
+ ```
294
+
295
+ ## Benefits
296
+
297
+ 1. **Consistency**: All entities use the same meta structure
298
+ 2. **Reusability**: Write once, use everywhere
299
+ 3. **Maintainability**: Changes to meta structure happen in one place
300
+ 4. **Type Safety**: Pydantic validation ensures correct structure
301
+ 5. **Documentation**: Self-documenting with clear field descriptions
302
+
303
+ ## Migration Checklist
304
+
305
+ When migrating an existing module to use MetaSchema:
306
+
307
+ - [ ] Add audit fields to model (if not present)
308
+ - [ ] Import MetaSchema in response schema
309
+ - [ ] Update response schema to use `meta: Dict[str, Any]`
310
+ - [ ] Import utility functions in service
311
+ - [ ] Update create method to use `extract_audit_fields(is_update=False)`
312
+ - [ ] Update update method to use `extract_audit_fields(is_update=True)`
313
+ - [ ] Update all methods returning responses to use `format_meta_field()`
314
+ - [ ] Update controller to pass `current_user` to service methods
315
+ - [ ] Test all CRUD operations
316
+ - [ ] Update API documentation
app/core/schemas.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core/Common Pydantic schemas used across multiple modules.
3
+ These schemas provide reusable components for consistent API responses.
4
+ """
5
+ from typing import Optional, Any, Dict, List
6
+ from datetime import datetime
7
+ from pydantic import BaseModel, Field
8
+
9
+
10
+ class MetaSchema(BaseModel):
11
+ """
12
+ Audit and metadata information for tracking record lifecycle.
13
+
14
+ This schema groups all audit-related fields in a consistent structure
15
+ that can be used across all entities (customers, staff, appointments, etc.).
16
+
17
+ Fields:
18
+ created_by: Username of the user who created the record (human-readable)
19
+ created_by_id: UUID of the user who created the record
20
+ created_at: Timestamp when the record was created
21
+ updated_by: Username of the user who last updated the record (optional)
22
+ updated_by_id: UUID of the user who last updated the record (optional)
23
+ updated_at: Timestamp when the record was last updated (optional)
24
+ """
25
+ created_by: str = Field(..., description="Username who created this record")
26
+ created_by_id: str = Field(..., description="User ID (UUID) who created this record")
27
+ created_at: datetime = Field(..., description="Timestamp when record was created")
28
+ updated_by: Optional[str] = Field(None, description="Username who last updated this record")
29
+ updated_by_id: Optional[str] = Field(None, description="User ID (UUID) who last updated this record")
30
+ updated_at: Optional[datetime] = Field(None, description="Timestamp when record was last updated")
31
+
32
+ class Config:
33
+ json_schema_extra = {
34
+ "example": {
35
+ "created_by": "admin",
36
+ "created_by_id": "usr_01HZQX5K3N2P8R6T4V9W",
37
+ "created_at": "2023-01-10T08:00:00Z",
38
+ "updated_by": "manager",
39
+ "updated_by_id": "usr_01HZQX5K3N2P8R6T4V9X",
40
+ "updated_at": "2024-11-24T10:00:00Z"
41
+ }
42
+ }
43
+
44
+
45
+ class PaginationMeta(BaseModel):
46
+ """
47
+ Pagination metadata for list endpoints.
48
+
49
+ Provides information about the current page, total records, and pagination state.
50
+ """
51
+ total: int = Field(..., description="Total number of records matching the query")
52
+ skip: int = Field(..., description="Number of records skipped (offset)")
53
+ limit: int = Field(..., description="Maximum number of records returned")
54
+ has_more: bool = Field(..., description="Whether there are more records available")
55
+
56
+ class Config:
57
+ json_schema_extra = {
58
+ "example": {
59
+ "total": 150,
60
+ "skip": 0,
61
+ "limit": 100,
62
+ "has_more": True
63
+ }
64
+ }
65
+
66
+
67
+ class ErrorDetail(BaseModel):
68
+ """
69
+ Detailed error information for validation and business logic errors.
70
+ """
71
+ field: str = Field(..., description="Field name that caused the error")
72
+ message: str = Field(..., description="Human-readable error message")
73
+ code: Optional[str] = Field(None, description="Machine-readable error code")
74
+
75
+ class Config:
76
+ json_schema_extra = {
77
+ "example": {
78
+ "field": "email",
79
+ "message": "Email address is already registered",
80
+ "code": "DUPLICATE_EMAIL"
81
+ }
82
+ }
83
+
84
+
85
+ class SuccessResponse(BaseModel):
86
+ """
87
+ Standard success response for operations that don't return data.
88
+ """
89
+ success: bool = Field(True, description="Operation success status")
90
+ message: str = Field(..., description="Success message")
91
+
92
+ class Config:
93
+ json_schema_extra = {
94
+ "example": {
95
+ "success": True,
96
+ "message": "Operation completed successfully"
97
+ }
98
+ }
99
+
100
+
101
+ class StatusResponse(BaseModel):
102
+ """
103
+ Standard status response for operations with optional data payload.
104
+ Used for API endpoints that return status with additional data.
105
+ """
106
+ success: bool = Field(..., description="Operation success status")
107
+ message: str = Field(..., description="Status or success message")
108
+ data: Optional[Dict[str, Any]] = Field(None, description="Optional data payload")
109
+
110
+ class Config:
111
+ json_schema_extra = {
112
+ "example": {
113
+ "success": True,
114
+ "message": "Operation completed successfully",
115
+ "data": {
116
+ "count": 10,
117
+ "items": []
118
+ }
119
+ }
120
+ }
121
+
122
+
123
+ class BaseLazyFetchSchema(BaseModel):
124
+ """
125
+ Base schema for lazy-fetch list requests with pagination and projection support.
126
+ Provides common fields for listing endpoints with filtering, pagination, and field projection.
127
+ """
128
+ skip: int = Field(0, ge=0, description="Number of records to skip (pagination offset)")
129
+ limit: int = Field(100, ge=1, le=500, description="Maximum number of records to return")
130
+ projection_list: Optional[List[str]] = Field(
131
+ None,
132
+ description="List of fields to include in response (omit for all fields)"
133
+ )
134
+
135
+ class Config:
136
+ json_schema_extra = {
137
+ "example": {
138
+ "skip": 0,
139
+ "limit": 100,
140
+ "projection_list": ["id", "name", "status"]
141
+ }
142
+ }
app/core/utils.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Core utility functions for common operations across modules.
3
+ """
4
+ from typing import Dict, Any, Optional
5
+ from datetime import datetime
6
+
7
+
8
+ def format_meta_field(document: Dict[str, Any]) -> Dict[str, Any]:
9
+ """
10
+ Format a document by grouping audit fields under 'meta'.
11
+
12
+ This utility transforms database documents to API response format by:
13
+ 1. Extracting audit fields (created_by, created_by_username, etc.)
14
+ 2. Creating a 'meta' object with proper field mapping
15
+ 3. Removing audit fields from the top level
16
+
17
+ Field Mapping:
18
+ DB Field → Response Field
19
+ ---------------- ---------------
20
+ created_by (UUID) → meta.created_by_id
21
+ created_by_username → meta.created_by
22
+ created_at → meta.created_at
23
+ updated_by (UUID) → meta.updated_by_id
24
+ updated_by_username → meta.updated_by
25
+ updated_at → meta.updated_at
26
+
27
+ Args:
28
+ document: Raw document from database with audit fields
29
+
30
+ Returns:
31
+ Formatted document with 'meta' field containing audit information
32
+
33
+ Example:
34
+ >>> doc = {
35
+ ... "id": "123",
36
+ ... "name": "Test",
37
+ ... "created_by": "usr_admin",
38
+ ... "created_by_username": "admin",
39
+ ... "created_at": "2024-01-01T00:00:00Z"
40
+ ... }
41
+ >>> formatted = format_meta_field(doc)
42
+ >>> formatted
43
+ {
44
+ "id": "123",
45
+ "name": "Test",
46
+ "meta": {
47
+ "created_by": "admin",
48
+ "created_by_id": "usr_admin",
49
+ "created_at": "2024-01-01T00:00:00Z"
50
+ }
51
+ }
52
+ """
53
+ # Create meta object from audit fields
54
+ meta = {
55
+ "created_by": document.get("created_by_username") or "Unknown",
56
+ "created_by_id": str(document.get("created_by", "")),
57
+ "created_at": document.get("created_at"),
58
+ }
59
+
60
+ # Add optional updated fields if they exist
61
+ if "updated_by" in document and document["updated_by"]:
62
+ meta["updated_by_id"] = str(document["updated_by"])
63
+ meta["updated_by"] = document.get("updated_by_username") or "Unknown"
64
+
65
+ if "updated_at" in document and document["updated_at"]:
66
+ meta["updated_at"] = document["updated_at"]
67
+
68
+ # Create a copy of document without the audit fields
69
+ audit_fields = [
70
+ "created_by",
71
+ "created_at",
72
+ "created_by_username",
73
+ "updated_by",
74
+ "updated_by_username",
75
+ "updated_at"
76
+ ]
77
+ formatted = {k: v for k, v in document.items() if k not in audit_fields}
78
+
79
+ # Add the meta field
80
+ formatted["meta"] = meta
81
+
82
+ return formatted
83
+
84
+
85
+ def extract_audit_fields(
86
+ user_id: str,
87
+ username: Optional[str] = None,
88
+ is_update: bool = False
89
+ ) -> Dict[str, Any]:
90
+ """
91
+ Create audit field dictionaries for database operations.
92
+
93
+ Generates the appropriate audit fields for create or update operations,
94
+ including both user ID (for queries) and username (for display).
95
+
96
+ Args:
97
+ user_id: User UUID performing the operation
98
+ username: Username for display (optional, defaults to user_id if not provided)
99
+ is_update: True for updates, False for creation
100
+
101
+ Returns:
102
+ Dictionary with audit fields ready for database insertion
103
+
104
+ Example:
105
+ >>> # For creation
106
+ >>> audit = extract_audit_fields("usr_123", "admin", is_update=False)
107
+ >>> audit
108
+ {
109
+ "created_by": "usr_123",
110
+ "created_by_username": "admin",
111
+ "created_at": datetime(...)
112
+ }
113
+
114
+ >>> # For updates
115
+ >>> audit = extract_audit_fields("usr_456", "manager", is_update=True)
116
+ >>> audit
117
+ {
118
+ "updated_by": "usr_456",
119
+ "updated_by_username": "manager",
120
+ "updated_at": datetime(...)
121
+ }
122
+ """
123
+ now = datetime.utcnow()
124
+
125
+ if is_update:
126
+ return {
127
+ "updated_by": user_id,
128
+ "updated_by_username": username or user_id,
129
+ "updated_at": now
130
+ }
131
+ else:
132
+ return {
133
+ "created_by": user_id,
134
+ "created_by_username": username or user_id,
135
+ "created_at": now
136
+ }
137
+
138
+
139
+ def normalize_uuid_fields(document: Dict[str, Any], fields: list[str]) -> Dict[str, Any]:
140
+ """
141
+ Convert UUID objects to strings for JSON serialization.
142
+
143
+ Args:
144
+ document: Document with potential UUID fields
145
+ fields: List of field names that might contain UUIDs
146
+
147
+ Returns:
148
+ Document with UUID fields converted to strings
149
+
150
+ Example:
151
+ >>> from uuid import UUID
152
+ >>> doc = {"user_id": UUID("123e4567-e89b-12d3-a456-426614174000")}
153
+ >>> normalized = normalize_uuid_fields(doc, ["user_id"])
154
+ >>> normalized["user_id"]
155
+ '123e4567-e89b-12d3-a456-426614174000'
156
+ """
157
+ for field in fields:
158
+ if field in document and document[field] is not None:
159
+ document[field] = str(document[field])
160
+ return document
app/customers/controllers/router.py CHANGED
@@ -40,7 +40,15 @@ async def create_customer(
40
  if not current_user.merchant_id:
41
  raise HTTPException(status_code=400, detail="merchant_id must be available in token")
42
 
43
- customer = await CustomerService.create_customer(payload, current_user.merchant_id)
 
 
 
 
 
 
 
 
44
 
45
  logger.info(
46
  "Customer created successfully",
@@ -200,11 +208,16 @@ async def update_customer(
200
  current_user: TokenUser = Depends(require_pos_permission("customers", "update"))
201
  ) -> CustomerResponse:
202
  try:
 
 
 
203
  customer = await CustomerService.update_customer(
204
  customer_id=customer_id,
205
  payload=payload,
206
  merchant_id=current_user.merchant_id,
207
- merchant_type=current_user.merchant_type
 
 
208
  )
209
 
210
  logger.info(
@@ -251,7 +264,9 @@ async def delete_customer(
251
  await CustomerService.delete_customer(
252
  customer_id=customer_id,
253
  merchant_id=current_user.merchant_id,
254
- merchant_type=current_user.merchant_type
 
 
255
  )
256
 
257
  logger.info(
 
40
  if not current_user.merchant_id:
41
  raise HTTPException(status_code=400, detail="merchant_id must be available in token")
42
 
43
+ # Pass username to service for audit trail
44
+ payload.created_by_username = current_user.username
45
+
46
+ customer = await CustomerService.create_customer(
47
+ payload,
48
+ current_user.merchant_id,
49
+ current_user.user_id,
50
+ current_user.username
51
+ )
52
 
53
  logger.info(
54
  "Customer created successfully",
 
208
  current_user: TokenUser = Depends(require_pos_permission("customers", "update"))
209
  ) -> CustomerResponse:
210
  try:
211
+ # Set username for audit trail
212
+ payload.updated_by_username = current_user.username
213
+
214
  customer = await CustomerService.update_customer(
215
  customer_id=customer_id,
216
  payload=payload,
217
  merchant_id=current_user.merchant_id,
218
+ merchant_type=current_user.merchant_type,
219
+ user_id=current_user.user_id,
220
+ username=current_user.username
221
  )
222
 
223
  logger.info(
 
264
  await CustomerService.delete_customer(
265
  customer_id=customer_id,
266
  merchant_id=current_user.merchant_id,
267
+ merchant_type=current_user.merchant_type,
268
+ user_id=current_user.user_id,
269
+ username=current_user.username
270
  )
271
 
272
  logger.info(
app/customers/models/model.py CHANGED
@@ -14,7 +14,13 @@ class CustomerModel(BaseModel):
14
  email: Optional[EmailStr] = None
15
  notes: Optional[str] = Field(None, max_length=500)
16
  status: str = Field(default="active")
 
 
 
 
17
  created_at: datetime = Field(default_factory=datetime.utcnow)
 
 
18
  updated_at: datetime = Field(default_factory=datetime.utcnow)
19
 
20
  class Config:
 
14
  email: Optional[EmailStr] = None
15
  notes: Optional[str] = Field(None, max_length=500)
16
  status: str = Field(default="active")
17
+
18
+ # Audit fields
19
+ created_by: str = Field(..., description="User ID who created")
20
+ created_by_username: Optional[str] = Field(None, description="Username who created")
21
  created_at: datetime = Field(default_factory=datetime.utcnow)
22
+ updated_by: Optional[str] = Field(None, description="User ID who updated")
23
+ updated_by_username: Optional[str] = Field(None, description="Username who updated")
24
  updated_at: datetime = Field(default_factory=datetime.utcnow)
25
 
26
  class Config:
app/customers/schemas/schema.py CHANGED
@@ -2,7 +2,7 @@
2
  Pydantic schemas for POS customers.
3
  """
4
  from datetime import date, datetime
5
- from typing import Optional, List, Union
6
  from pydantic import BaseModel, Field, EmailStr, field_validator
7
  import re
8
 
@@ -76,6 +76,7 @@ class CustomerBase(BaseModel):
76
  class CustomerCreate(CustomerBase):
77
  """Schema for creating a customer."""
78
  merchant_id: Optional[str] = Field(None, description="Merchant identifier (UUID) - will be overridden by token")
 
79
 
80
  @field_validator("phone")
81
  @classmethod
@@ -122,6 +123,8 @@ class CustomerUpdate(BaseModel):
122
  referral_discount_used: Optional[float] = None
123
  referred_by: Optional[str] = None
124
 
 
 
125
  @field_validator("phone")
126
  @classmethod
127
  def validate_phone(cls, v: Optional[str]) -> Optional[str]:
@@ -151,8 +154,7 @@ class CustomerUpdate(BaseModel):
151
 
152
  class CustomerResponse(CustomerBase):
153
  customer_id: str = Field(..., description="Customer identifier (UUID)")
154
- created_at: datetime
155
- updated_at: datetime
156
 
157
  class Config:
158
  from_attributes = True
 
2
  Pydantic schemas for POS customers.
3
  """
4
  from datetime import date, datetime
5
+ from typing import Optional, List, Union, Dict, Any
6
  from pydantic import BaseModel, Field, EmailStr, field_validator
7
  import re
8
 
 
76
  class CustomerCreate(CustomerBase):
77
  """Schema for creating a customer."""
78
  merchant_id: Optional[str] = Field(None, description="Merchant identifier (UUID) - will be overridden by token")
79
+ created_by_username: Optional[str] = Field(None, description="Username who created - will be set from token")
80
 
81
  @field_validator("phone")
82
  @classmethod
 
123
  referral_discount_used: Optional[float] = None
124
  referred_by: Optional[str] = None
125
 
126
+ updated_by_username: Optional[str] = Field(None, description="Username who updated - will be set from token")
127
+
128
  @field_validator("phone")
129
  @classmethod
130
  def validate_phone(cls, v: Optional[str]) -> Optional[str]:
 
154
 
155
  class CustomerResponse(CustomerBase):
156
  customer_id: str = Field(..., description="Customer identifier (UUID)")
157
+ meta: Dict[str, Any] = Field(..., description="Audit information (created_by, created_at, updated_by, updated_at)")
 
158
 
159
  class Config:
160
  from_attributes = True
app/customers/services/service.py CHANGED
@@ -10,6 +10,7 @@ from app.utils.utils import normalize_dates
10
  from sqlalchemy import text
11
 
12
  from app.core.logging import get_logger
 
13
  from app.nosql import get_database
14
  from app.constants.collections import POS_CUSTOMERS_COLLECTION
15
  from app.sql import get_postgres_session
@@ -92,22 +93,31 @@ class CustomerService:
92
  )
93
 
94
  @classmethod
95
- async def create_customer(cls, payload: CustomerCreate, token_merchant_id: Optional[str] = None) -> CustomerResponse:
96
  now = datetime.utcnow()
97
  customer_id = cls._generate_customer_id()
98
 
99
  doc = payload.model_dump()
100
  doc["customer_id"] = customer_id
101
- doc["created_at"] = now
102
- doc["updated_at"] = now
103
 
104
  # Override merchant_id with UUID format from token if provided
105
  if token_merchant_id:
106
  doc["merchant_id"] = token_merchant_id
 
 
 
 
 
 
 
 
107
 
108
  data = normalize_dates(doc)
109
  await cls._collection().insert_one(data)
110
- customer = CustomerResponse(**data)
 
 
 
111
 
112
  try:
113
  await cls._sync_to_postgres(customer, token_merchant_id)
@@ -142,7 +152,10 @@ class CustomerService:
142
  doc = await cls._collection().find_one(query)
143
  if not doc:
144
  return None
145
- return CustomerResponse(**doc)
 
 
 
146
 
147
  @classmethod
148
  async def list_customers(
@@ -187,10 +200,11 @@ class CustomerService:
187
  docs = await cursor.to_list(length=limit)
188
  return docs, total
189
  else:
190
- # Return CustomerResponse objects when no projection
191
  items: List[CustomerResponse] = []
192
  async for doc in cursor:
193
- items.append(CustomerResponse(**doc))
 
194
  return items, total
195
 
196
  @classmethod
@@ -199,7 +213,9 @@ class CustomerService:
199
  customer_id: str,
200
  payload: CustomerUpdate,
201
  merchant_id: Optional[str] = None,
202
- merchant_type: Optional[str] = None
 
 
203
  ) -> CustomerResponse:
204
  """Update customer with merchant access control."""
205
  query = {"customer_id": customer_id}
@@ -216,7 +232,13 @@ class CustomerService:
216
  if not update_data:
217
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields to update")
218
 
219
- update_data["updated_at"] = datetime.utcnow()
 
 
 
 
 
 
220
 
221
  await cls._collection().update_one(
222
  {"customer_id": customer_id},
@@ -224,7 +246,8 @@ class CustomerService:
224
  )
225
 
226
  updated = await cls._collection().find_one({"customer_id": customer_id})
227
- customer = CustomerResponse(**updated)
 
228
 
229
  try:
230
  await cls._sync_to_postgres(customer, merchant_id)
@@ -247,7 +270,9 @@ class CustomerService:
247
  cls,
248
  customer_id: str,
249
  merchant_id: Optional[str] = None,
250
- merchant_type: Optional[str] = None
 
 
251
  ) -> None:
252
  """Delete (soft) customer with merchant access control."""
253
  query = {"customer_id": customer_id}
@@ -260,14 +285,25 @@ class CustomerService:
260
  if not existing:
261
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
262
 
263
- # Soft delete: set status inactive and updated_at
 
 
 
 
 
 
 
 
 
 
264
  await cls._collection().update_one(
265
  {"customer_id": customer_id},
266
- {"$set": {"status": "inactive", "updated_at": datetime.utcnow()}}
267
  )
268
 
269
  updated = await cls._collection().find_one({"customer_id": customer_id})
270
- customer = CustomerResponse(**updated)
 
271
 
272
  try:
273
  await cls._sync_to_postgres(customer, merchant_id)
 
10
  from sqlalchemy import text
11
 
12
  from app.core.logging import get_logger
13
+ from app.core.utils import format_meta_field, extract_audit_fields
14
  from app.nosql import get_database
15
  from app.constants.collections import POS_CUSTOMERS_COLLECTION
16
  from app.sql import get_postgres_session
 
93
  )
94
 
95
  @classmethod
96
+ async def create_customer(cls, payload: CustomerCreate, token_merchant_id: Optional[str] = None, user_id: Optional[str] = None, username: Optional[str] = None) -> CustomerResponse:
97
  now = datetime.utcnow()
98
  customer_id = cls._generate_customer_id()
99
 
100
  doc = payload.model_dump()
101
  doc["customer_id"] = customer_id
 
 
102
 
103
  # Override merchant_id with UUID format from token if provided
104
  if token_merchant_id:
105
  doc["merchant_id"] = token_merchant_id
106
+
107
+ # Add audit fields
108
+ audit_fields = extract_audit_fields(
109
+ user_id=user_id or "system",
110
+ username=username or "system",
111
+ is_update=False
112
+ )
113
+ doc.update(audit_fields)
114
 
115
  data = normalize_dates(doc)
116
  await cls._collection().insert_one(data)
117
+
118
+ # Format response with meta
119
+ formatted = format_meta_field(data)
120
+ customer = CustomerResponse(**formatted)
121
 
122
  try:
123
  await cls._sync_to_postgres(customer, token_merchant_id)
 
152
  doc = await cls._collection().find_one(query)
153
  if not doc:
154
  return None
155
+
156
+ # Format with meta
157
+ formatted = format_meta_field(doc)
158
+ return CustomerResponse(**formatted)
159
 
160
  @classmethod
161
  async def list_customers(
 
200
  docs = await cursor.to_list(length=limit)
201
  return docs, total
202
  else:
203
+ # Return CustomerResponse objects when no projection, formatted with meta
204
  items: List[CustomerResponse] = []
205
  async for doc in cursor:
206
+ formatted = format_meta_field(doc)
207
+ items.append(CustomerResponse(**formatted))
208
  return items, total
209
 
210
  @classmethod
 
213
  customer_id: str,
214
  payload: CustomerUpdate,
215
  merchant_id: Optional[str] = None,
216
+ merchant_type: Optional[str] = None,
217
+ user_id: Optional[str] = None,
218
+ username: Optional[str] = None
219
  ) -> CustomerResponse:
220
  """Update customer with merchant access control."""
221
  query = {"customer_id": customer_id}
 
232
  if not update_data:
233
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields to update")
234
 
235
+ # Add audit fields for update
236
+ audit_fields = extract_audit_fields(
237
+ user_id=user_id or "system",
238
+ username=username or "system",
239
+ is_update=True
240
+ )
241
+ update_data.update(audit_fields)
242
 
243
  await cls._collection().update_one(
244
  {"customer_id": customer_id},
 
246
  )
247
 
248
  updated = await cls._collection().find_one({"customer_id": customer_id})
249
+ formatted = format_meta_field(updated)
250
+ customer = CustomerResponse(**formatted)
251
 
252
  try:
253
  await cls._sync_to_postgres(customer, merchant_id)
 
270
  cls,
271
  customer_id: str,
272
  merchant_id: Optional[str] = None,
273
+ merchant_type: Optional[str] = None,
274
+ user_id: Optional[str] = None,
275
+ username: Optional[str] = None
276
  ) -> None:
277
  """Delete (soft) customer with merchant access control."""
278
  query = {"customer_id": customer_id}
 
285
  if not existing:
286
  raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Customer not found")
287
 
288
+ # Soft delete: set status inactive and add audit fields
289
+ audit_fields = extract_audit_fields(
290
+ user_id=user_id or "system",
291
+ username=username or "system",
292
+ is_update=True
293
+ )
294
+ update_data = {
295
+ "status": "inactive",
296
+ **audit_fields
297
+ }
298
+
299
  await cls._collection().update_one(
300
  {"customer_id": customer_id},
301
+ {"$set": update_data}
302
  )
303
 
304
  updated = await cls._collection().find_one({"customer_id": customer_id})
305
+ formatted = format_meta_field(updated)
306
+ customer = CustomerResponse(**formatted)
307
 
308
  try:
309
  await cls._sync_to_postgres(customer, merchant_id)
app/staff/controllers/router.py CHANGED
@@ -55,8 +55,13 @@ async def create_staff(
55
  raise HTTPException(status_code=400, detail="merchant_id not found in token")
56
 
57
  payload.merchant_id = current_user.merchant_id
 
58
 
59
- result = await StaffService.create_staff(payload)
 
 
 
 
60
 
61
  logger.info(
62
  "Staff member created",
@@ -222,8 +227,17 @@ async def update_staff(
222
  try:
223
  if not current_user.merchant_id:
224
  raise HTTPException(status_code=400, detail="merchant_id not found in token")
225
-
226
- result = await StaffService.update_staff(staff_id, current_user.merchant_id, payload)
 
 
 
 
 
 
 
 
 
227
 
228
  logger.info(
229
  "Staff member updated",
@@ -274,8 +288,17 @@ async def update_staff_status(
274
  if not current_user.merchant_id:
275
  raise HTTPException(status_code=400, detail="merchant_id not found in token")
276
 
277
- update_payload = StaffUpdateSchema(status=payload.status)
278
- result = await StaffService.update_staff(staff_id, current_user.merchant_id, update_payload)
 
 
 
 
 
 
 
 
 
279
 
280
  logger.info(
281
  "Staff status updated",
 
55
  raise HTTPException(status_code=400, detail="merchant_id not found in token")
56
 
57
  payload.merchant_id = current_user.merchant_id
58
+ payload.created_by_username = current_user.username
59
 
60
+ result = await StaffService.create_staff(
61
+ payload,
62
+ user_id=current_user.user_id,
63
+ username=current_user.username
64
+ )
65
 
66
  logger.info(
67
  "Staff member created",
 
227
  try:
228
  if not current_user.merchant_id:
229
  raise HTTPException(status_code=400, detail="merchant_id not found in token")
230
+
231
+ # Set username for audit trail
232
+ payload.updated_by_username = current_user.username
233
+
234
+ result = await StaffService.update_staff(
235
+ staff_id,
236
+ current_user.merchant_id,
237
+ payload,
238
+ user_id=current_user.user_id,
239
+ username=current_user.username
240
+ )
241
 
242
  logger.info(
243
  "Staff member updated",
 
288
  if not current_user.merchant_id:
289
  raise HTTPException(status_code=400, detail="merchant_id not found in token")
290
 
291
+ update_payload = StaffUpdateSchema(
292
+ status=payload.status,
293
+ updated_by_username=current_user.username
294
+ )
295
+ result = await StaffService.update_staff(
296
+ staff_id,
297
+ current_user.merchant_id,
298
+ update_payload,
299
+ user_id=current_user.user_id,
300
+ username=current_user.username
301
+ )
302
 
303
  logger.info(
304
  "Staff status updated",
app/staff/models/staff_model.py CHANGED
@@ -42,7 +42,12 @@ class StaffModel(BaseModel):
42
 
43
  working_hours: List[WorkingHours] = Field(default_factory=list, description="Weekly working schedule")
44
 
 
 
 
45
  created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation timestamp")
 
 
46
  updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update timestamp")
47
 
48
  # Optional fields
 
42
 
43
  working_hours: List[WorkingHours] = Field(default_factory=list, description="Weekly working schedule")
44
 
45
+ # Audit fields
46
+ created_by: str = Field(..., description="User ID who created")
47
+ created_by_username: Optional[str] = Field(None, description="Username who created")
48
  created_at: datetime = Field(default_factory=datetime.utcnow, description="Creation timestamp")
49
+ updated_by: Optional[str] = Field(None, description="User ID who updated")
50
+ updated_by_username: Optional[str] = Field(None, description="Username who updated")
51
  updated_at: datetime = Field(default_factory=datetime.utcnow, description="Last update timestamp")
52
 
53
  # Optional fields
app/staff/schemas/staff_schema.py CHANGED
@@ -30,6 +30,7 @@ class StaffCreateSchema(BaseModel):
30
  working_hours: Optional[List[dict]] = None
31
  category: Optional[str] = None
32
  is_deleted: bool = Field(default=False)
 
33
  @field_validator('phone')
34
  @classmethod
35
  def validate_phone(cls, v):
@@ -63,7 +64,8 @@ class StaffUpdateSchema(BaseModel):
63
  status: Optional[str] = Field(None, pattern="^(active|inactive|on_leave|suspended|terminated)$")
64
  skills: Optional[List[str]] = None
65
  working_hours: Optional[List[dict]] = None
66
- category: Optional[str] = None
 
67
  @field_validator('phone')
68
  @classmethod
69
  def validate_phone(cls, v):
@@ -103,8 +105,7 @@ class StaffResponseSchema(BaseModel):
103
  status: str
104
  working_hours: Optional[List[dict]] = None
105
  category: Optional[str] = None
106
- created_at: Optional[datetime] = None
107
- updated_at: Optional[datetime] = None
108
 
109
  class Config:
110
  from_attributes = True
 
30
  working_hours: Optional[List[dict]] = None
31
  category: Optional[str] = None
32
  is_deleted: bool = Field(default=False)
33
+ created_by_username: Optional[str] = Field(None, description="Username who created - will be set from token")
34
  @field_validator('phone')
35
  @classmethod
36
  def validate_phone(cls, v):
 
64
  status: Optional[str] = Field(None, pattern="^(active|inactive|on_leave|suspended|terminated)$")
65
  skills: Optional[List[str]] = None
66
  working_hours: Optional[List[dict]] = None
67
+ category: Optional[str] = None
68
+ updated_by_username: Optional[str] = Field(None, description="Username who updated - will be set from token")
69
  @field_validator('phone')
70
  @classmethod
71
  def validate_phone(cls, v):
 
105
  status: str
106
  working_hours: Optional[List[dict]] = None
107
  category: Optional[str] = None
108
+ meta: dict = Field(..., description="Audit information (created_by, created_at, updated_by, updated_at)")
 
109
 
110
  class Config:
111
  from_attributes = True
app/staff/services/staff_service.py CHANGED
@@ -7,6 +7,7 @@ from typing import Optional, List, Dict, Any, Union
7
  from uuid import UUID, uuid4
8
  from fastapi import HTTPException, status
9
  from app.core.logging import get_logger
 
10
  import secrets
11
  from sqlalchemy import text
12
 
@@ -93,12 +94,14 @@ class StaffService:
93
  """Service class for staff operations."""
94
 
95
  @staticmethod
96
- async def create_staff(payload: StaffCreateSchema) -> StaffResponseSchema:
97
  """
98
  Create a new staff member.
99
 
100
  Args:
101
  payload: Staff creation data
 
 
102
 
103
  Returns:
104
  Created staff response
@@ -141,15 +144,20 @@ class StaffService:
141
 
142
  # Generate staff ID
143
  staff_id = generate_uuid()
144
- user_id = generate_uuid()
145
 
146
  # Create staff model
147
- now = datetime.utcnow()
148
  staff_data = payload.model_dump(by_alias=True)
149
  staff_data["staff_id"] = staff_id
150
- staff_data["user_id"] = user_id
151
- staff_data["created_at"] = now
152
- staff_data["updated_at"] = now
 
 
 
 
 
 
153
 
154
  # Insert into database
155
  await get_database()[POS_STAFF_COLLECTION].insert_one(staff_data)
@@ -177,7 +185,7 @@ class StaffService:
177
  user_payload = SystemUserCreateSchema(
178
  merchant_id=payload.merchant_id,
179
  staff_id=staff_id,
180
- user_id=user_id,
181
  name=payload.name,
182
  email=payload.email,
183
  phone=payload.phone,
@@ -196,8 +204,10 @@ class StaffService:
196
  },
197
  exc_info=True
198
  )
199
- # Return response
200
- return StaffResponseSchema(**staff_data)
 
 
201
 
202
  except HTTPException:
203
  raise
@@ -221,10 +231,12 @@ class StaffService:
221
  """Get staff by ID."""
222
  try:
223
  staff = await get_database()[POS_STAFF_COLLECTION].find_one({"staff_id": str(staff_id), "merchant_id": str(merchant_id)})
224
- print(staff)
225
  if not staff:
226
  return None
227
- return StaffResponseSchema(**staff)
 
 
 
228
  except Exception as e:
229
  logger.error(
230
  f"Error fetching staff {staff_id}",
@@ -242,7 +254,7 @@ class StaffService:
242
  )
243
 
244
  @staticmethod
245
- async def update_staff(staff_id: UUID, merchant_id: UUID, payload: StaffUpdateSchema) -> StaffResponseSchema:
246
  """
247
  Update staff information.
248
 
@@ -250,6 +262,8 @@ class StaffService:
250
  staff_id: Staff ID to update
251
  merchant_id: Merchant ID
252
  payload: Update data
 
 
253
 
254
  Returns:
255
  Updated staff response
@@ -284,8 +298,13 @@ class StaffService:
284
  detail="No update data provided"
285
  )
286
 
287
- # Add updated timestamp
288
- update_data["updated_at"] = datetime.utcnow()
 
 
 
 
 
289
 
290
  try:
291
  # Update in database
 
7
  from uuid import UUID, uuid4
8
  from fastapi import HTTPException, status
9
  from app.core.logging import get_logger
10
+ from app.core.utils import format_meta_field, extract_audit_fields
11
  import secrets
12
  from sqlalchemy import text
13
 
 
94
  """Service class for staff operations."""
95
 
96
  @staticmethod
97
+ async def create_staff(payload: StaffCreateSchema, user_id: Optional[str] = None, username: Optional[str] = None) -> StaffResponseSchema:
98
  """
99
  Create a new staff member.
100
 
101
  Args:
102
  payload: Staff creation data
103
+ user_id: User ID from token for audit trail
104
+ username: Username from token for audit trail
105
 
106
  Returns:
107
  Created staff response
 
144
 
145
  # Generate staff ID
146
  staff_id = generate_uuid()
147
+ user_id_gen = generate_uuid()
148
 
149
  # Create staff model
 
150
  staff_data = payload.model_dump(by_alias=True)
151
  staff_data["staff_id"] = staff_id
152
+ staff_data["user_id"] = user_id_gen
153
+
154
+ # Add audit fields
155
+ audit_fields = extract_audit_fields(
156
+ user_id=user_id or "system",
157
+ username=username or "system",
158
+ is_update=False
159
+ )
160
+ staff_data.update(audit_fields)
161
 
162
  # Insert into database
163
  await get_database()[POS_STAFF_COLLECTION].insert_one(staff_data)
 
185
  user_payload = SystemUserCreateSchema(
186
  merchant_id=payload.merchant_id,
187
  staff_id=staff_id,
188
+ user_id=user_id_gen,
189
  name=payload.name,
190
  email=payload.email,
191
  phone=payload.phone,
 
204
  },
205
  exc_info=True
206
  )
207
+
208
+ # Format response with meta
209
+ formatted = format_meta_field(staff_data)
210
+ return StaffResponseSchema(**formatted)
211
 
212
  except HTTPException:
213
  raise
 
231
  """Get staff by ID."""
232
  try:
233
  staff = await get_database()[POS_STAFF_COLLECTION].find_one({"staff_id": str(staff_id), "merchant_id": str(merchant_id)})
 
234
  if not staff:
235
  return None
236
+
237
+ # Format with meta
238
+ formatted = format_meta_field(staff)
239
+ return StaffResponseSchema(**formatted)
240
  except Exception as e:
241
  logger.error(
242
  f"Error fetching staff {staff_id}",
 
254
  )
255
 
256
  @staticmethod
257
+ async def update_staff(staff_id: UUID, merchant_id: UUID, payload: StaffUpdateSchema, user_id: Optional[str] = None, username: Optional[str] = None) -> StaffResponseSchema:
258
  """
259
  Update staff information.
260
 
 
262
  staff_id: Staff ID to update
263
  merchant_id: Merchant ID
264
  payload: Update data
265
+ user_id: User ID from token for audit trail
266
+ username: Username from token for audit trail
267
 
268
  Returns:
269
  Updated staff response
 
298
  detail="No update data provided"
299
  )
300
 
301
+ # Add audit fields for update
302
+ audit_fields = extract_audit_fields(
303
+ user_id=user_id or "system",
304
+ username=username or "system",
305
+ is_update=True
306
+ )
307
+ update_data.update(audit_fields)
308
 
309
  try:
310
  # Update in database