@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.
- package/dist/admin/index.d.mts +106 -1
- package/dist/admin/index.d.ts +106 -1
- package/dist/admin/index.js +1303 -130
- package/dist/admin/index.mjs +1303 -130
- package/dist/index.d.mts +360 -2
- package/dist/index.d.ts +360 -2
- package/dist/index.js +3422 -1888
- package/dist/index.mjs +3121 -1588
- package/package.json +1 -1
- package/src/services/analytics/README.md +17 -0
- package/src/services/analytics/TRENDS.md +380 -0
- package/src/services/analytics/analytics.service.ts +540 -30
- package/src/services/analytics/index.ts +1 -0
- package/src/services/analytics/review-analytics.service.ts +941 -0
- package/src/services/analytics/utils/cost-calculation.utils.ts +32 -4
- package/src/services/analytics/utils/grouping.utils.ts +40 -0
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -0
- package/src/services/appointment/appointment.service.ts +9 -0
- package/src/services/procedure/procedure.service.ts +419 -4
- package/src/services/reviews/reviews.service.ts +58 -7
- package/src/types/analytics/analytics.types.ts +98 -1
- package/src/types/analytics/grouped-analytics.types.ts +25 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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 }
|
|
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()
|
|
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
|
|
368
|
-
return { ...
|
|
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
|
|
463
|
-
return { ...
|
|
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()
|
|
698
|
+
return this.convertDocToReview(snapshot.docs[0].data());
|
|
648
699
|
}
|
|
649
700
|
|
|
650
701
|
/**
|