@blackcode_sa/metaestetics-api 1.13.0 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -370,6 +370,308 @@ export class ProcedureService extends BaseService {
370
370
  return savedDoc.data() as Procedure;
371
371
  }
372
372
 
373
+ /**
374
+ * Validates if a practitioner can perform a procedure based on certification requirements.
375
+ *
376
+ * @param procedure - The procedure to check
377
+ * @param practitioner - The practitioner to validate
378
+ * @returns true if practitioner can perform the procedure, false otherwise
379
+ */
380
+ canPractitionerPerformProcedure(procedure: Procedure, practitioner: Practitioner): boolean {
381
+ if (!practitioner.certification) {
382
+ return false;
383
+ }
384
+
385
+ const requiredCert = procedure.certificationRequirement;
386
+ const practitionerCert = practitioner.certification;
387
+
388
+ // Check certification level
389
+ const levelOrder = [
390
+ 'aesthetician',
391
+ 'nurse_assistant',
392
+ 'nurse',
393
+ 'nurse_practitioner',
394
+ 'physician_assistant',
395
+ 'doctor',
396
+ 'specialist',
397
+ 'plastic_surgeon',
398
+ ];
399
+
400
+ const practitionerLevelIndex = levelOrder.indexOf(practitionerCert.level);
401
+ const requiredLevelIndex = levelOrder.indexOf(requiredCert.minimumLevel);
402
+
403
+ if (practitionerLevelIndex < requiredLevelIndex) {
404
+ return false;
405
+ }
406
+
407
+ // Check required specialties
408
+ const requiredSpecialties = requiredCert.requiredSpecialties || [];
409
+ if (requiredSpecialties.length > 0) {
410
+ const practitionerSpecialties = practitionerCert.specialties || [];
411
+ const hasAllRequired = requiredSpecialties.every(specialty =>
412
+ practitionerSpecialties.includes(specialty)
413
+ );
414
+ if (!hasAllRequired) {
415
+ return false;
416
+ }
417
+ }
418
+
419
+ return true;
420
+ }
421
+
422
+ /**
423
+ * Clones an existing procedure for a target practitioner.
424
+ * This creates a new procedure document with the same data as the source procedure,
425
+ * but linked to the target practitioner.
426
+ *
427
+ * @param sourceProcedureId - The ID of the procedure to clone
428
+ * @param targetPractitionerId - The ID of the practitioner to assign the cloned procedure to
429
+ * @param overrides - Optional overrides for the new procedure (e.g. price, duration, isActive)
430
+ * @returns The newly created procedure
431
+ */
432
+ async cloneProcedureForPractitioner(
433
+ sourceProcedureId: string,
434
+ targetPractitionerId: string,
435
+ overrides?: Partial<CreateProcedureData> & { isActive?: boolean }
436
+ ): Promise<Procedure> {
437
+ // 1. Fetch source procedure
438
+ const sourceProcedure = await this.getProcedure(sourceProcedureId);
439
+ if (!sourceProcedure) {
440
+ throw new Error(`Source procedure with ID ${sourceProcedureId} not found`);
441
+ }
442
+
443
+ // 2. Fetch target practitioner
444
+ const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, targetPractitionerId);
445
+ const practitionerSnapshot = await getDoc(practitionerRef);
446
+ if (!practitionerSnapshot.exists()) {
447
+ throw new Error(`Target practitioner with ID ${targetPractitionerId} not found`);
448
+ }
449
+ const practitioner = practitionerSnapshot.data() as Practitioner;
450
+
451
+ // 3. Validate certification
452
+ if (!this.canPractitionerPerformProcedure(sourceProcedure, practitioner)) {
453
+ throw new Error(
454
+ `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} does not meet the certification requirements for this procedure`
455
+ );
456
+ }
457
+
458
+ // 4. Check if practitioner already has a procedure with the same technology ID in this clinic branch
459
+ const existingProceduresQuery = query(
460
+ collection(this.db, PROCEDURES_COLLECTION),
461
+ where('practitionerId', '==', targetPractitionerId),
462
+ where('clinicBranchId', '==', sourceProcedure.clinicBranchId),
463
+ where('isActive', '==', true)
464
+ );
465
+ const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
466
+ const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
467
+
468
+ const hasSameTechnology = existingProcedures.some(
469
+ proc => proc.technology?.id === sourceProcedure.technology?.id
470
+ );
471
+ if (hasSameTechnology) {
472
+ throw new Error(
473
+ `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} already has a procedure with technology "${sourceProcedure.technology?.name || sourceProcedure.technology?.id}" in this clinic branch`
474
+ );
475
+ }
476
+
477
+ // 5. Prepare data for new procedure
478
+ const newProcedureId = this.generateId();
479
+
480
+ // Create aggregated doctor info for the new procedure
481
+ const doctorInfo = {
482
+ id: practitioner.id,
483
+ name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
484
+ description: practitioner.basicInfo.bio || '',
485
+ photo:
486
+ typeof practitioner.basicInfo.profileImageUrl === 'string'
487
+ ? practitioner.basicInfo.profileImageUrl
488
+ : '',
489
+ rating: practitioner.reviewInfo?.averageRating || 0,
490
+ services: practitioner.procedures || [],
491
+ };
492
+
493
+ // Construct the new procedure object
494
+ // We copy everything from source, but override specific fields
495
+ const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
496
+ ...sourceProcedure,
497
+ id: newProcedureId,
498
+ practitionerId: targetPractitionerId,
499
+ doctorInfo, // Link to new doctor
500
+
501
+ // Reset review info for the new procedure
502
+ reviewInfo: {
503
+ totalReviews: 0,
504
+ averageRating: 0,
505
+ effectivenessOfTreatment: 0,
506
+ outcomeExplanation: 0,
507
+ painManagement: 0,
508
+ followUpCare: 0,
509
+ valueForMoney: 0,
510
+ recommendationPercentage: 0,
511
+ },
512
+
513
+ // Apply any overrides if provided
514
+ ...(overrides?.price !== undefined && { price: overrides.price }),
515
+ ...(overrides?.duration !== undefined && { duration: overrides.duration }),
516
+ ...(overrides?.description !== undefined && { description: overrides.description }),
517
+
518
+ // Ensure it's active by default unless specified otherwise
519
+ isActive: overrides?.isActive !== undefined ? overrides.isActive : true,
520
+ };
521
+
522
+ // 6. Save to Firestore
523
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, newProcedureId);
524
+ await setDoc(procedureRef, {
525
+ ...newProcedure,
526
+ createdAt: serverTimestamp(),
527
+ updatedAt: serverTimestamp(),
528
+ });
529
+
530
+ // 7. Return the new procedure
531
+ const savedDoc = await getDoc(procedureRef);
532
+ return savedDoc.data() as Procedure;
533
+ }
534
+
535
+ /**
536
+ * Clones an existing procedure for multiple target practitioners.
537
+ * This creates new procedure documents with the same data as the source procedure,
538
+ * but linked to each target practitioner.
539
+ *
540
+ * @param sourceProcedureId - The ID of the procedure to clone
541
+ * @param targetPractitionerIds - Array of practitioner IDs to assign the cloned procedure to
542
+ * @param overrides - Optional overrides for the new procedures (e.g. price, duration, isActive)
543
+ * @returns Array of newly created procedures
544
+ */
545
+ async bulkCloneProcedureForPractitioners(
546
+ sourceProcedureId: string,
547
+ targetPractitionerIds: string[],
548
+ overrides?: Partial<CreateProcedureData> & { isActive?: boolean }
549
+ ): Promise<Procedure[]> {
550
+ if (!targetPractitionerIds || targetPractitionerIds.length === 0) {
551
+ throw new Error('At least one target practitioner ID is required');
552
+ }
553
+
554
+ // 1. Fetch source procedure
555
+ const sourceProcedure = await this.getProcedure(sourceProcedureId);
556
+ if (!sourceProcedure) {
557
+ throw new Error(`Source procedure with ID ${sourceProcedureId} not found`);
558
+ }
559
+
560
+ // 2. Fetch all target practitioners
561
+ const practitionerPromises = targetPractitionerIds.map(id =>
562
+ getDoc(doc(this.db, PRACTITIONERS_COLLECTION, id))
563
+ );
564
+ const practitionerSnapshots = await Promise.all(practitionerPromises);
565
+
566
+ // 3. Validate all practitioners exist, can perform the procedure, and don't already have the same technology
567
+ const practitioners: Practitioner[] = [];
568
+ const sourceTechnologyId = sourceProcedure.technology?.id;
569
+
570
+ for (let i = 0; i < practitionerSnapshots.length; i++) {
571
+ const snapshot = practitionerSnapshots[i];
572
+ if (!snapshot.exists()) {
573
+ throw new Error(`Target practitioner with ID ${targetPractitionerIds[i]} not found`);
574
+ }
575
+ const practitioner = snapshot.data() as Practitioner;
576
+
577
+ if (!this.canPractitionerPerformProcedure(sourceProcedure, practitioner)) {
578
+ throw new Error(
579
+ `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} does not meet the certification requirements for this procedure`
580
+ );
581
+ }
582
+
583
+ // Check if practitioner already has a procedure with the same technology ID in this clinic branch
584
+ const existingProceduresQuery = query(
585
+ collection(this.db, PROCEDURES_COLLECTION),
586
+ where('practitionerId', '==', practitioner.id),
587
+ where('clinicBranchId', '==', sourceProcedure.clinicBranchId),
588
+ where('isActive', '==', true)
589
+ );
590
+ const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
591
+ const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
592
+
593
+ const hasSameTechnology = existingProcedures.some(
594
+ proc => proc.technology?.id === sourceTechnologyId
595
+ );
596
+ if (hasSameTechnology) {
597
+ throw new Error(
598
+ `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} already has a procedure with technology "${sourceProcedure.technology?.name || sourceTechnologyId}" in this clinic branch`
599
+ );
600
+ }
601
+
602
+ practitioners.push(practitioner);
603
+ }
604
+
605
+ // 4. Create procedures in batch
606
+ const batch = writeBatch(this.db);
607
+ const newProcedures: Omit<Procedure, 'createdAt' | 'updatedAt'>[] = [];
608
+
609
+ for (const practitioner of practitioners) {
610
+ const newProcedureId = this.generateId();
611
+
612
+ // Create aggregated doctor info for the new procedure
613
+ const doctorInfo = {
614
+ id: practitioner.id,
615
+ name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
616
+ description: practitioner.basicInfo.bio || '',
617
+ photo:
618
+ typeof practitioner.basicInfo.profileImageUrl === 'string'
619
+ ? practitioner.basicInfo.profileImageUrl
620
+ : '',
621
+ rating: practitioner.reviewInfo?.averageRating || 0,
622
+ services: practitioner.procedures || [],
623
+ };
624
+
625
+ // Construct the new procedure object
626
+ const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
627
+ ...sourceProcedure,
628
+ id: newProcedureId,
629
+ practitionerId: practitioner.id,
630
+ doctorInfo,
631
+
632
+ // Reset review info for the new procedure
633
+ reviewInfo: {
634
+ totalReviews: 0,
635
+ averageRating: 0,
636
+ effectivenessOfTreatment: 0,
637
+ outcomeExplanation: 0,
638
+ painManagement: 0,
639
+ followUpCare: 0,
640
+ valueForMoney: 0,
641
+ recommendationPercentage: 0,
642
+ },
643
+
644
+ // Apply any overrides if provided
645
+ ...(overrides?.price !== undefined && { price: overrides.price }),
646
+ ...(overrides?.duration !== undefined && { duration: overrides.duration }),
647
+ ...(overrides?.description !== undefined && { description: overrides.description }),
648
+
649
+ // Ensure it's active by default unless specified otherwise
650
+ isActive: overrides?.isActive !== undefined ? overrides.isActive : true,
651
+ };
652
+
653
+ newProcedures.push(newProcedure);
654
+
655
+ // Add to batch
656
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, newProcedureId);
657
+ batch.set(procedureRef, {
658
+ ...newProcedure,
659
+ createdAt: serverTimestamp(),
660
+ updatedAt: serverTimestamp(),
661
+ });
662
+ }
663
+
664
+ // 5. Commit batch
665
+ await batch.commit();
666
+
667
+ // 6. Fetch and return the created procedures
668
+ const createdProcedures = await Promise.all(
669
+ newProcedures.map(p => this.getProcedure(p.id))
670
+ );
671
+
672
+ return createdProcedures.filter((p): p is Procedure => p !== null);
673
+ }
674
+
373
675
  /**
374
676
  * Creates multiple procedures for a list of practitioners based on common data.
375
677
  * This method is optimized for bulk creation to reduce database reads and writes.
@@ -1088,6 +1390,14 @@ export class ProcedureService extends BaseService {
1088
1390
  console.log('[PROCEDURE_SERVICE] Strategy 1: Trying nameLower search');
1089
1391
  const searchTerm = filters.nameSearch.trim().toLowerCase();
1090
1392
  const constraints = getBaseConstraints();
1393
+
1394
+ // Check if we have nested field filters that might conflict with orderBy
1395
+ const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1396
+
1397
+ if (hasNestedFilters) {
1398
+ console.log('[PROCEDURE_SERVICE] Strategy 1: Has nested filters, will apply client-side after query');
1399
+ }
1400
+
1091
1401
  constraints.push(where('nameLower', '>=', searchTerm));
1092
1402
  constraints.push(where('nameLower', '<=', searchTerm + '\uf8ff'));
1093
1403
  constraints.push(orderBy('nameLower'));
@@ -1105,9 +1415,15 @@ export class ProcedureService extends BaseService {
1105
1415
 
1106
1416
  const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1107
1417
  const querySnapshot = await getDocs(q);
1108
- const procedures = querySnapshot.docs.map(
1418
+ let procedures = querySnapshot.docs.map(
1109
1419
  doc => ({ ...doc.data(), id: doc.id } as Procedure),
1110
1420
  );
1421
+
1422
+ // Apply client-side filters for nested fields if needed
1423
+ if (hasNestedFilters) {
1424
+ procedures = this.applyInMemoryFilters(procedures, filters);
1425
+ }
1426
+
1111
1427
  const lastDoc =
1112
1428
  querySnapshot.docs.length > 0
1113
1429
  ? querySnapshot.docs[querySnapshot.docs.length - 1]
@@ -1131,6 +1447,14 @@ export class ProcedureService extends BaseService {
1131
1447
  console.log('[PROCEDURE_SERVICE] Strategy 2: Trying name field search');
1132
1448
  const searchTerm = filters.nameSearch.trim().toLowerCase();
1133
1449
  const constraints = getBaseConstraints();
1450
+
1451
+ // Check if we have nested field filters that might conflict with orderBy
1452
+ const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1453
+
1454
+ if (hasNestedFilters) {
1455
+ console.log('[PROCEDURE_SERVICE] Strategy 2: Has nested filters, will apply client-side after query');
1456
+ }
1457
+
1134
1458
  constraints.push(where('name', '>=', searchTerm));
1135
1459
  constraints.push(where('name', '<=', searchTerm + '\uf8ff'));
1136
1460
  constraints.push(orderBy('name'));
@@ -1148,9 +1472,15 @@ export class ProcedureService extends BaseService {
1148
1472
 
1149
1473
  const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1150
1474
  const querySnapshot = await getDocs(q);
1151
- const procedures = querySnapshot.docs.map(
1475
+ let procedures = querySnapshot.docs.map(
1152
1476
  doc => ({ ...doc.data(), id: doc.id } as Procedure),
1153
1477
  );
1478
+
1479
+ // Apply client-side filters for nested fields if needed
1480
+ if (hasNestedFilters) {
1481
+ procedures = this.applyInMemoryFilters(procedures, filters);
1482
+ }
1483
+
1154
1484
  const lastDoc =
1155
1485
  querySnapshot.docs.length > 0
1156
1486
  ? querySnapshot.docs[querySnapshot.docs.length - 1]
@@ -1169,11 +1499,66 @@ export class ProcedureService extends BaseService {
1169
1499
  }
1170
1500
 
1171
1501
  // Strategy 3: orderBy createdAt with client-side filtering
1502
+ // NOTE: This strategy excludes nested field filters (technology.id, category.id, subcategory.id)
1503
+ // from Firestore query because Firestore doesn't support orderBy on different field
1504
+ // when using where on nested fields without a composite index.
1505
+ // These filters are applied client-side instead.
1172
1506
  try {
1173
1507
  console.log(
1174
1508
  '[PROCEDURE_SERVICE] Strategy 3: Using createdAt orderBy with client-side filtering',
1509
+ {
1510
+ procedureTechnology: filters.procedureTechnology,
1511
+ hasTechnologyFilter: !!filters.procedureTechnology,
1512
+ },
1513
+ );
1514
+
1515
+ // Build constraints WITHOUT nested field filters (these will be applied client-side)
1516
+ const constraints: QueryConstraint[] = [];
1517
+
1518
+ // Active status filter
1519
+ if (filters.isActive !== undefined) {
1520
+ constraints.push(where('isActive', '==', filters.isActive));
1521
+ } else {
1522
+ constraints.push(where('isActive', '==', true));
1523
+ }
1524
+
1525
+ // Only include non-nested field filters in Firestore query
1526
+ if (filters.procedureFamily) {
1527
+ constraints.push(where('family', '==', filters.procedureFamily));
1528
+ }
1529
+ if (filters.practitionerId) {
1530
+ constraints.push(where('practitionerId', '==', filters.practitionerId));
1531
+ }
1532
+ if (filters.clinicId) {
1533
+ constraints.push(where('clinicBranchId', '==', filters.clinicId));
1534
+ }
1535
+ if (filters.minPrice !== undefined) {
1536
+ constraints.push(where('price', '>=', filters.minPrice));
1537
+ }
1538
+ if (filters.maxPrice !== undefined) {
1539
+ constraints.push(where('price', '<=', filters.maxPrice));
1540
+ }
1541
+ if (filters.minRating !== undefined) {
1542
+ constraints.push(where('reviewInfo.averageRating', '>=', filters.minRating));
1543
+ }
1544
+ if (filters.maxRating !== undefined) {
1545
+ constraints.push(where('reviewInfo.averageRating', '<=', filters.maxRating));
1546
+ }
1547
+ if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
1548
+ const benefitIdsToMatch = filters.treatmentBenefits;
1549
+ constraints.push(where('treatmentBenefitIds', 'array-contains-any', benefitIdsToMatch));
1550
+ }
1551
+
1552
+ // NOTE: We intentionally EXCLUDE these nested field filters from Firestore query:
1553
+ // - filters.procedureTechnology (technology.id)
1554
+ // - filters.procedureCategory (category.id)
1555
+ // - filters.procedureSubcategory (subcategory.id)
1556
+ // These will be applied client-side in applyInMemoryFilters
1557
+
1558
+ console.log(
1559
+ '[PROCEDURE_SERVICE] Strategy 3 Firestore constraints (nested filters excluded):',
1560
+ constraints.map(c => (c as any).fieldPath || 'unknown'),
1175
1561
  );
1176
- const constraints = getBaseConstraints();
1177
1562
  constraints.push(orderBy('createdAt', 'desc'));
1178
1563
 
1179
1564
  if (filters.lastDoc) {
@@ -1194,7 +1579,20 @@ export class ProcedureService extends BaseService {
1194
1579
  );
1195
1580
 
1196
1581
  // Apply all client-side filters using centralized function
1582
+ console.log('[PROCEDURE_SERVICE] Before applyInMemoryFilters (Strategy 3):', {
1583
+ procedureCount: procedures.length,
1584
+ procedureTechnology: filters.procedureTechnology,
1585
+ filtersObject: {
1586
+ procedureTechnology: filters.procedureTechnology,
1587
+ procedureFamily: filters.procedureFamily,
1588
+ procedureCategory: filters.procedureCategory,
1589
+ procedureSubcategory: filters.procedureSubcategory,
1590
+ },
1591
+ });
1197
1592
  procedures = this.applyInMemoryFilters(procedures, filters);
1593
+ console.log('[PROCEDURE_SERVICE] After applyInMemoryFilters (Strategy 3):', {
1594
+ procedureCount: procedures.length,
1595
+ });
1198
1596
 
1199
1597
  const lastDoc =
1200
1598
  querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
@@ -1265,6 +1663,14 @@ export class ProcedureService extends BaseService {
1265
1663
  ): (Procedure & { distance?: number })[] {
1266
1664
  let filteredProcedures = [...procedures]; // Create copy to avoid mutating original
1267
1665
 
1666
+ // Debug: Log what filters we received
1667
+ console.log('[PROCEDURE_SERVICE] applyInMemoryFilters called:', {
1668
+ procedureCount: procedures.length,
1669
+ procedureTechnology: filters.procedureTechnology,
1670
+ hasTechnologyFilter: !!filters.procedureTechnology,
1671
+ allFilterKeys: Object.keys(filters).filter(k => filters[k] !== undefined && filters[k] !== null),
1672
+ });
1673
+
1268
1674
  // Name search filter
1269
1675
  if (filters.nameSearch && filters.nameSearch.trim()) {
1270
1676
  const searchTerm = filters.nameSearch.trim().toLowerCase();
@@ -1348,12 +1754,21 @@ export class ProcedureService extends BaseService {
1348
1754
 
1349
1755
  // Technology filtering
1350
1756
  if (filters.procedureTechnology) {
1757
+ const beforeCount = filteredProcedures.length;
1351
1758
  filteredProcedures = filteredProcedures.filter(
1352
1759
  procedure => procedure.technology?.id === filters.procedureTechnology,
1353
1760
  );
1354
1761
  console.log(
1355
- `[PROCEDURE_SERVICE] Applied technology filter, results: ${filteredProcedures.length}`,
1762
+ `[PROCEDURE_SERVICE] Applied technology filter (${filters.procedureTechnology}), before: ${beforeCount}, after: ${filteredProcedures.length}`,
1356
1763
  );
1764
+ // Log sample technology IDs for debugging
1765
+ if (beforeCount > filteredProcedures.length) {
1766
+ const filteredOut = procedures
1767
+ .filter(p => p.technology?.id !== filters.procedureTechnology)
1768
+ .slice(0, 3)
1769
+ .map(p => ({ id: p.id, techId: p.technology?.id, name: p.name }));
1770
+ console.log('[PROCEDURE_SERVICE] Filtered out sample procedures:', filteredOut);
1771
+ }
1357
1772
  }
1358
1773
 
1359
1774
  // Practitioner filtering
@@ -29,6 +29,57 @@ export class ReviewService extends BaseService {
29
29
  super(db, auth, app);
30
30
  }
31
31
 
32
+ /**
33
+ * Helper function to convert Firestore Timestamps to Date objects
34
+ * @param timestamp The timestamp to convert
35
+ * @returns A JavaScript Date object or null
36
+ */
37
+ private convertTimestamp(timestamp: any): Date {
38
+ if (!timestamp) {
39
+ return new Date();
40
+ }
41
+
42
+ // Firebase Timestamp object with __isTimestamp
43
+ if (timestamp && timestamp.__isTimestamp === true && typeof timestamp.seconds === 'number') {
44
+ return new Date(timestamp.seconds * 1000 + (timestamp.nanoseconds || 0) / 1000000);
45
+ }
46
+
47
+ // Firebase Firestore Timestamp with toDate method
48
+ if (timestamp && timestamp.toDate && typeof timestamp.toDate === 'function') {
49
+ return timestamp.toDate();
50
+ }
51
+
52
+ // Already a Date object
53
+ if (timestamp instanceof Date) {
54
+ return timestamp;
55
+ }
56
+
57
+ // String or number
58
+ if (typeof timestamp === 'string' || typeof timestamp === 'number') {
59
+ const date = new Date(timestamp);
60
+ if (!isNaN(date.getTime())) {
61
+ return date;
62
+ }
63
+ }
64
+
65
+ return new Date();
66
+ }
67
+
68
+ /**
69
+ * Converts a Firestore document to a Review object with proper date handling
70
+ * @param docData The raw Firestore document data
71
+ * @returns A Review object with properly converted dates
72
+ */
73
+ private convertDocToReview(docData: any): Review {
74
+ const review = docData as Review;
75
+
76
+ // Convert main review timestamps (all sub-reviews share the same creation date)
77
+ review.createdAt = this.convertTimestamp(docData.createdAt);
78
+ review.updatedAt = this.convertTimestamp(docData.updatedAt);
79
+
80
+ return review;
81
+ }
82
+
32
83
  /**
33
84
  * Creates a new review
34
85
  * @param data - The review data to create
@@ -206,7 +257,7 @@ export class ReviewService extends BaseService {
206
257
  return null;
207
258
  }
208
259
 
209
- const review = { ...docSnap.data(), id: reviewId } as Review;
260
+ const review = { ...this.convertDocToReview(docSnap.data()), id: reviewId };
210
261
 
211
262
  try {
212
263
  // Fetch the associated appointment to enhance with entity names
@@ -293,7 +344,7 @@ export class ReviewService extends BaseService {
293
344
  async getReviewsByPatient(patientId: string): Promise<Review[]> {
294
345
  const q = query(collection(this.db, REVIEWS_COLLECTION), where('patientId', '==', patientId));
295
346
  const snapshot = await getDocs(q);
296
- const reviews = snapshot.docs.map(doc => doc.data() as Review);
347
+ const reviews = snapshot.docs.map(doc => this.convertDocToReview(doc.data()));
297
348
 
298
349
  // Enhance reviews with entity names from appointments
299
350
  const enhancedReviews = await Promise.all(
@@ -364,8 +415,8 @@ export class ReviewService extends BaseService {
364
415
  );
365
416
  const snapshot = await getDocs(q);
366
417
  const reviews = snapshot.docs.map(doc => {
367
- const data = doc.data() as Review;
368
- return { ...data, id: doc.id };
418
+ const review = this.convertDocToReview(doc.data());
419
+ return { ...review, id: doc.id };
369
420
  });
370
421
 
371
422
  console.log('🔍 ReviewService.getReviewsByClinic - Found reviews before enhancement:', {
@@ -459,8 +510,8 @@ export class ReviewService extends BaseService {
459
510
  );
460
511
  const snapshot = await getDocs(q);
461
512
  const reviews = snapshot.docs.map(doc => {
462
- const data = doc.data() as Review;
463
- return { ...data, id: doc.id };
513
+ const review = this.convertDocToReview(doc.data());
514
+ return { ...review, id: doc.id };
464
515
  });
465
516
 
466
517
  console.log('🔍 ReviewService.getReviewsByPractitioner - Found reviews before enhancement:', {
@@ -644,7 +695,7 @@ export class ReviewService extends BaseService {
644
695
  return null;
645
696
  }
646
697
 
647
- return snapshot.docs[0].data() as Review;
698
+ return this.convertDocToReview(snapshot.docs[0].data());
648
699
  }
649
700
 
650
701
  /**