@blackcode_sa/metaestetics-api 1.5.29 → 1.5.31

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.
Files changed (30) hide show
  1. package/dist/admin/index.d.mts +126 -1
  2. package/dist/admin/index.d.ts +126 -1
  3. package/dist/admin/index.js +347 -10
  4. package/dist/admin/index.mjs +345 -10
  5. package/dist/index.d.mts +64 -71
  6. package/dist/index.d.ts +64 -71
  7. package/dist/index.js +327 -710
  8. package/dist/index.mjs +363 -750
  9. package/package.json +2 -1
  10. package/src/admin/aggregation/README.md +79 -0
  11. package/src/admin/aggregation/clinic/README.md +52 -0
  12. package/src/admin/aggregation/patient/README.md +27 -0
  13. package/src/admin/aggregation/practitioner/README.md +42 -0
  14. package/src/admin/aggregation/procedure/README.md +43 -0
  15. package/src/admin/index.ts +17 -2
  16. package/src/admin/mailing/README.md +95 -0
  17. package/src/admin/mailing/base.mailing.service.ts +131 -0
  18. package/src/admin/mailing/index.ts +2 -0
  19. package/src/admin/mailing/practitionerInvite/index.ts +1 -0
  20. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +256 -0
  21. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -0
  22. package/src/services/README.md +106 -0
  23. package/src/services/calendar/utils/appointment.utils.ts +42 -91
  24. package/src/services/clinic/README.md +87 -0
  25. package/src/services/clinic/clinic.service.ts +3 -126
  26. package/src/services/clinic/utils/clinic.utils.ts +2 -2
  27. package/src/services/practitioner/README.md +145 -0
  28. package/src/services/practitioner/practitioner.service.ts +119 -395
  29. package/src/services/procedure/README.md +88 -0
  30. package/src/services/procedure/procedure.service.ts +332 -369
@@ -16,6 +16,10 @@ import {
16
16
  arrayRemove,
17
17
  FieldValue,
18
18
  orderBy,
19
+ limit,
20
+ startAfter,
21
+ QueryConstraint,
22
+ documentId,
19
23
  } from "firebase/firestore";
20
24
  import { BaseService } from "../base.service";
21
25
  import {
@@ -64,6 +68,8 @@ import {
64
68
  } from "../../backoffice/types";
65
69
  import { Clinic, CLINICS_COLLECTION } from "../../types/clinic";
66
70
  import { ProcedureReviewInfo } from "../../types/reviews";
71
+ import { distanceBetween, geohashQueryBounds } from "geofire-common";
72
+ import { TreatmentBenefit } from "../../backoffice/types/static/treatment-benefit.types";
67
73
 
68
74
  export class ProcedureService extends BaseService {
69
75
  private categoryService: CategoryService;
@@ -87,143 +93,8 @@ export class ProcedureService extends BaseService {
87
93
  this.productService = productService;
88
94
  }
89
95
 
90
- // Helper function to create ProcedureSummaryInfo
91
- private _createProcedureSummaryInfo(
92
- procedure: Procedure
93
- ): ProcedureSummaryInfo {
94
- // Ensure nested objects and names exist before accessing them
95
- const categoryName = procedure.category?.name || "N/A";
96
- const subcategoryName = procedure.subcategory?.name || "N/A";
97
- const technologyName = procedure.technology?.name || "N/A";
98
- const clinicName = procedure.clinicInfo?.name || "N/A";
99
- const practitionerName = procedure.doctorInfo?.name || "N/A";
100
-
101
- return {
102
- id: procedure.id,
103
- name: procedure.name,
104
- description: procedure.description || "",
105
- photo: "", // No photo source identified yet
106
- family: procedure.family,
107
- categoryName: categoryName,
108
- subcategoryName: subcategoryName,
109
- technologyName: technologyName,
110
- price: procedure.price,
111
- pricingMeasure: procedure.pricingMeasure,
112
- currency: procedure.currency,
113
- duration: procedure.duration,
114
- clinicId: procedure.clinicBranchId,
115
- clinicName: clinicName,
116
- practitionerId: procedure.practitionerId,
117
- practitionerName: practitionerName,
118
- };
119
- }
120
-
121
- // Helper to update practitioner's procedures
122
- private async _updatePractitionerProcedures(
123
- practitionerId: string,
124
- procedureSummary: ProcedureSummaryInfo | null, // Null means remove
125
- procedureIdToRemove?: string
126
- ) {
127
- const practitionerRef = doc(
128
- this.db,
129
- PRACTITIONERS_COLLECTION,
130
- practitionerId
131
- );
132
- const updateData: { [key: string]: FieldValue } = {};
133
-
134
- if (procedureSummary) {
135
- // Add/Update
136
- updateData["procedures"] = arrayUnion(procedureSummary.id);
137
- // Remove old summary first (by ID) then add new one to ensure update
138
- updateData["proceduresInfo"] = arrayRemove(
139
- ...[
140
- // Need to spread the arrayRemove arguments
141
- // Create a 'matcher' object with only the ID for removal
142
- { id: procedureSummary.id },
143
- ]
144
- );
145
- // We'll add the new summary in a separate update to avoid conflicts if needed, or rely on a subsequent transaction/batch commit
146
- } else if (procedureIdToRemove) {
147
- // Remove
148
- updateData["procedures"] = arrayRemove(procedureIdToRemove);
149
- updateData["proceduresInfo"] = arrayRemove(
150
- ...[{ id: procedureIdToRemove }] // Use matcher for removal
151
- );
152
- } else {
153
- return; // No operation needed
154
- }
155
-
156
- updateData["updatedAt"] = serverTimestamp();
157
-
158
- // Perform the update
159
- const batch = writeBatch(this.db);
160
- batch.update(practitionerRef, updateData);
161
-
162
- // If adding/updating, add the new summary info after removing the old one
163
- if (procedureSummary) {
164
- batch.update(practitionerRef, {
165
- proceduresInfo: arrayUnion(procedureSummary),
166
- });
167
- }
168
-
169
- await batch.commit();
170
- }
171
-
172
- // Helper to update clinic's procedures
173
- private async _updateClinicProcedures(
174
- clinicId: string,
175
- procedureSummary: ProcedureSummaryInfo | null, // Null means remove
176
- procedureIdToRemove?: string
177
- ) {
178
- const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
179
-
180
- // Check if clinic exists before attempting update
181
- const clinicSnap = await getDoc(clinicRef);
182
- if (!clinicSnap.exists()) {
183
- console.warn(
184
- `Clinic ${clinicId} not found, skipping procedure aggregation update.`
185
- );
186
- return;
187
- }
188
-
189
- const updateData: { [key: string]: FieldValue } = {};
190
-
191
- if (procedureSummary) {
192
- // Add/Update - Using arrayUnion for simplicity, assuming procedure IDs are unique in services array too
193
- // Note: 'services' array might be deprecated or used differently now
194
- updateData["services"] = arrayUnion(procedureSummary.id);
195
- // Remove old summary first (by ID) then add new one to ensure update
196
- updateData["proceduresInfo"] = arrayRemove(
197
- ...[{ id: procedureSummary.id }] // Use matcher for removal
198
- );
199
- } else if (procedureIdToRemove) {
200
- // Remove
201
- updateData["services"] = arrayRemove(procedureIdToRemove);
202
- updateData["proceduresInfo"] = arrayRemove(
203
- ...[{ id: procedureIdToRemove }] // Use matcher for removal
204
- );
205
- } else {
206
- return; // No operation needed
207
- }
208
-
209
- updateData["updatedAt"] = serverTimestamp();
210
-
211
- // Perform the update
212
- const batch = writeBatch(this.db);
213
- batch.update(clinicRef, updateData);
214
-
215
- // If adding/updating, add the new summary info after removing the old one
216
- if (procedureSummary) {
217
- batch.update(clinicRef, {
218
- proceduresInfo: arrayUnion(procedureSummary),
219
- });
220
- }
221
-
222
- await batch.commit();
223
- }
224
-
225
96
  /**
226
- * Creates a new procedure and updates related practitioner/clinic aggregates
97
+ * Creates a new procedure
227
98
  * @param data - The data for creating a new procedure
228
99
  * @returns The created procedure
229
100
  */
@@ -289,14 +160,13 @@ export class ProcedureService extends BaseService {
289
160
  };
290
161
 
291
162
  // Create aggregated doctor info for the procedure document
292
- // Re-use logic from previous implementation or simplify
293
163
  const doctorInfo = {
294
164
  id: practitionerSnapshot.id,
295
165
  name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
296
166
  description: practitioner.basicInfo.bio || "",
297
167
  photo: practitioner.basicInfo.profileImageUrl || "",
298
- rating: practitioner.reviewInfo?.averageRating || 0, // Example rating source
299
- services: practitioner.procedures || [], // Link services to practitioner's procedures
168
+ rating: practitioner.reviewInfo?.averageRating || 0,
169
+ services: practitioner.procedures || [],
300
170
  };
301
171
 
302
172
  // Create the procedure object
@@ -330,48 +200,14 @@ export class ProcedureService extends BaseService {
330
200
  isActive: true, // Default to active
331
201
  };
332
202
 
333
- // --- Transaction/Batch Start ---
334
- const batch = writeBatch(this.db);
335
-
336
- // 1. Create the procedure document
203
+ // Create the procedure document
337
204
  const procedureRef = doc(this.db, PROCEDURES_COLLECTION, procedureId);
338
- batch.set(procedureRef, {
205
+ await setDoc(procedureRef, {
339
206
  ...newProcedure,
340
207
  createdAt: serverTimestamp(),
341
208
  updatedAt: serverTimestamp(),
342
209
  });
343
210
 
344
- // 2. Create the summary object AFTER the main object is defined
345
- const procedureSummary = this._createProcedureSummaryInfo({
346
- ...newProcedure,
347
- createdAt: new Date(), // Use placeholder date for summary creation
348
- updatedAt: new Date(),
349
- });
350
-
351
- // 3. Update Practitioner
352
- const practitionerUpdateData: { [key: string]: FieldValue } = {
353
- procedures: arrayUnion(procedureId),
354
- proceduresInfo: arrayUnion(procedureSummary),
355
- updatedAt: serverTimestamp(),
356
- };
357
- batch.update(practitionerRef, practitionerUpdateData);
358
-
359
- // 4. Update Clinic
360
- const clinicUpdateData: { [key: string]: FieldValue } = {
361
- // services: arrayUnion(procedureId), // Decide if 'services' array is still needed
362
- proceduresInfo: arrayUnion(procedureSummary),
363
- // Potentially update clinic.doctors array if not already present
364
- doctors: arrayUnion(validatedData.practitionerId),
365
- // Potentially update clinic.doctorsInfo array
366
- // This requires fetching existing doctorsInfo and adding/updating
367
- // For simplicity now, we'll just add the procedure summary
368
- updatedAt: serverTimestamp(),
369
- };
370
- batch.update(clinicRef, clinicUpdateData);
371
-
372
- // --- Transaction/Batch Commit ---
373
- await batch.commit();
374
-
375
211
  // Return the created procedure (fetch again to get server timestamps)
376
212
  const savedDoc = await getDoc(procedureRef);
377
213
  return savedDoc.data() as Procedure;
@@ -428,7 +264,7 @@ export class ProcedureService extends BaseService {
428
264
  }
429
265
 
430
266
  /**
431
- * Updates a procedure and its related aggregates in Practitioner and Clinic docs
267
+ * Updates a procedure
432
268
  * @param id - The ID of the procedure to update
433
269
  * @param data - The data to update the procedure with
434
270
  * @returns The updated procedure
@@ -578,131 +414,19 @@ export class ProcedureService extends BaseService {
578
414
  console.warn("Attempted to update product without a valid technologyId");
579
415
  }
580
416
 
581
- // --- Perform updates using a batch ---
582
- const batch = writeBatch(this.db);
583
-
584
- // 1. Update the main procedure document
585
- batch.update(procedureRef, {
417
+ // Update the procedure document
418
+ await updateDoc(procedureRef, {
586
419
  ...updatedProcedureData,
587
420
  updatedAt: serverTimestamp(),
588
421
  });
589
422
 
590
- // 2. Create the updated summary info object
591
- // We need the final state of the procedure for the summary
592
- const finalProcedureStateForSummary = {
593
- ...existingProcedure,
594
- ...updatedProcedureData, // Apply updates
595
- // Ensure nested objects needed for summary are present
596
- category: updatedProcedureData.category || existingProcedure.category,
597
- subcategory:
598
- updatedProcedureData.subcategory || existingProcedure.subcategory,
599
- technology:
600
- updatedProcedureData.technology || existingProcedure.technology,
601
- product: updatedProcedureData.product || existingProcedure.product,
602
- clinicInfo:
603
- updatedProcedureData.clinicInfo || existingProcedure.clinicInfo,
604
- doctorInfo:
605
- updatedProcedureData.doctorInfo || existingProcedure.doctorInfo,
606
- practitionerId:
607
- validatedData.practitionerId || existingProcedure.practitionerId, // Use potentially updated IDs
608
- clinicBranchId:
609
- validatedData.clinicBranchId || existingProcedure.clinicBranchId,
610
- } as Procedure; // Cast because we're merging potentially partial data
611
-
612
- const updatedProcedureSummary = this._createProcedureSummaryInfo(
613
- finalProcedureStateForSummary
614
- );
615
-
616
- // 3. Update Practitioner(s)
617
- if (practitionerChanged) {
618
- // Remove from old practitioner
619
- const oldPractitionerRef = doc(
620
- this.db,
621
- PRACTITIONERS_COLLECTION,
622
- oldPractitionerId
623
- );
624
- batch.update(oldPractitionerRef, {
625
- procedures: arrayRemove(id),
626
- proceduresInfo: arrayRemove(...[{ id: id }]), // Matcher for removal
627
- updatedAt: serverTimestamp(),
628
- });
629
- // Add to new practitioner
630
- const newPractitionerRef = doc(
631
- this.db,
632
- PRACTITIONERS_COLLECTION,
633
- updatedProcedureSummary.practitionerId
634
- );
635
- batch.update(newPractitionerRef, {
636
- procedures: arrayUnion(id),
637
- proceduresInfo: arrayUnion(updatedProcedureSummary),
638
- updatedAt: serverTimestamp(),
639
- });
640
- } else {
641
- // Update in place for the current practitioner
642
- const currentPractitionerRef = doc(
643
- this.db,
644
- PRACTITIONERS_COLLECTION,
645
- oldPractitionerId
646
- );
647
- // Remove old first
648
- batch.update(currentPractitionerRef, {
649
- proceduresInfo: arrayRemove(...[{ id: id }]),
650
- updatedAt: serverTimestamp(),
651
- });
652
- // Add updated
653
- batch.update(currentPractitionerRef, {
654
- proceduresInfo: arrayUnion(updatedProcedureSummary),
655
- updatedAt: serverTimestamp(),
656
- });
657
- }
658
-
659
- // 4. Update Clinic(s)
660
- if (clinicChanged) {
661
- // Remove from old clinic
662
- const oldClinicRef = doc(this.db, CLINICS_COLLECTION, oldClinicId);
663
- batch.update(oldClinicRef, {
664
- // services: arrayRemove(id),
665
- proceduresInfo: arrayRemove(...[{ id: id }]), // Matcher for removal
666
- updatedAt: serverTimestamp(),
667
- // Potentially remove from clinic.doctors and clinic.doctorsInfo if practitioner also changed or was last one for this clinic
668
- });
669
- // Add to new clinic
670
- const newClinicRef = doc(
671
- this.db,
672
- CLINICS_COLLECTION,
673
- updatedProcedureSummary.clinicId
674
- );
675
- batch.update(newClinicRef, {
676
- // services: arrayUnion(id),
677
- proceduresInfo: arrayUnion(updatedProcedureSummary),
678
- doctors: arrayUnion(updatedProcedureSummary.practitionerId), // Ensure practitioner is listed
679
- updatedAt: serverTimestamp(),
680
- });
681
- } else {
682
- // Update in place for the current clinic
683
- const currentClinicRef = doc(this.db, CLINICS_COLLECTION, oldClinicId);
684
- // Remove old first
685
- batch.update(currentClinicRef, {
686
- proceduresInfo: arrayRemove(...[{ id: id }]),
687
- updatedAt: serverTimestamp(),
688
- });
689
- // Add updated
690
- batch.update(currentClinicRef, {
691
- proceduresInfo: arrayUnion(updatedProcedureSummary),
692
- updatedAt: serverTimestamp(),
693
- });
694
- }
695
-
696
- // --- Commit Batch ---
697
- await batch.commit();
698
-
699
423
  // Return the updated procedure
700
424
  const updatedSnapshot = await getDoc(procedureRef);
701
425
  return updatedSnapshot.data() as Procedure;
702
426
  }
703
427
 
704
428
  /**
705
- * Deactivates a procedure (soft delete) and updates aggregates
429
+ * Deactivates a procedure (soft delete)
706
430
  * @param id - The ID of the procedure to deactivate
707
431
  */
708
432
  async deactivateProcedure(id: string): Promise<void> {
@@ -712,51 +436,16 @@ export class ProcedureService extends BaseService {
712
436
  console.warn(`Procedure ${id} not found for deactivation.`);
713
437
  return;
714
438
  }
715
- const procedure = procedureSnap.data() as Procedure;
716
-
717
- // We treat deactivation like a deletion for the aggregated lists
718
- // Alternatively, keep inactive procedures but filter them client-side
719
-
720
- // --- Perform updates using a batch ---
721
- const batch = writeBatch(this.db);
722
439
 
723
- // 1. Mark procedure as inactive
724
- batch.update(procedureRef, {
440
+ // Mark procedure as inactive
441
+ await updateDoc(procedureRef, {
725
442
  isActive: false,
726
443
  updatedAt: serverTimestamp(),
727
444
  });
728
-
729
- // 2. Remove from Practitioner aggregates
730
- const practitionerRef = doc(
731
- this.db,
732
- PRACTITIONERS_COLLECTION,
733
- procedure.practitionerId
734
- );
735
- batch.update(practitionerRef, {
736
- procedures: arrayRemove(id),
737
- proceduresInfo: arrayRemove(...[{ id: id }]), // Matcher for removal
738
- updatedAt: serverTimestamp(),
739
- });
740
-
741
- // 3. Remove from Clinic aggregates
742
- const clinicRef = doc(
743
- this.db,
744
- CLINICS_COLLECTION,
745
- procedure.clinicBranchId
746
- );
747
- batch.update(clinicRef, {
748
- // services: arrayRemove(id),
749
- proceduresInfo: arrayRemove(...[{ id: id }]), // Matcher for removal
750
- updatedAt: serverTimestamp(),
751
- // Potentially update clinic.doctors/doctorsInfo if this was the last active procedure for this doctor at this clinic
752
- });
753
-
754
- // --- Commit Batch ---
755
- await batch.commit();
756
445
  }
757
446
 
758
447
  /**
759
- * Deletes a procedure permanently and updates related aggregates
448
+ * Deletes a procedure permanently
760
449
  * @param id - The ID of the procedure to delete
761
450
  * @returns A boolean indicating if the deletion was successful
762
451
  */
@@ -769,46 +458,8 @@ export class ProcedureService extends BaseService {
769
458
  return false;
770
459
  }
771
460
 
772
- const procedure = procedureSnapshot.data() as Procedure;
773
-
774
- // --- Perform updates using a batch ---
775
- const batch = writeBatch(this.db);
776
-
777
- // 1. Remove from Practitioner aggregates
778
- if (procedure.practitionerId) {
779
- const practitionerRef = doc(
780
- this.db,
781
- PRACTITIONERS_COLLECTION,
782
- procedure.practitionerId
783
- );
784
- batch.update(practitionerRef, {
785
- procedures: arrayRemove(id),
786
- proceduresInfo: arrayRemove(...[{ id: id }]), // Matcher for removal
787
- updatedAt: serverTimestamp(),
788
- });
789
- }
790
-
791
- // 2. Remove from Clinic aggregates
792
- if (procedure.clinicBranchId) {
793
- const clinicRef = doc(
794
- this.db,
795
- CLINICS_COLLECTION,
796
- procedure.clinicBranchId
797
- );
798
- batch.update(clinicRef, {
799
- // services: arrayRemove(id),
800
- proceduresInfo: arrayRemove(...[{ id: id }]), // Matcher for removal
801
- updatedAt: serverTimestamp(),
802
- // Potentially update clinic.doctors/doctorsInfo
803
- });
804
- }
805
-
806
- // 3. Delete the procedure document itself
807
- batch.delete(procedureRef);
808
-
809
- // --- Commit Batch ---
810
- await batch.commit();
811
-
461
+ // Delete the procedure document
462
+ await deleteDoc(procedureRef);
812
463
  return true;
813
464
  }
814
465
 
@@ -893,4 +544,316 @@ export class ProcedureService extends BaseService {
893
544
  throw error;
894
545
  }
895
546
  }
547
+
548
+ /**
549
+ * Searches and filters procedures based on multiple criteria
550
+ *
551
+ * @param filters - Various filters to apply
552
+ * @param filters.nameSearch - Optional search text for procedure name
553
+ * @param filters.treatmentBenefits - Optional array of treatment benefits to filter by
554
+ * @param filters.procedureFamily - Optional procedure family to filter by
555
+ * @param filters.procedureCategory - Optional procedure category to filter by
556
+ * @param filters.procedureSubcategory - Optional procedure subcategory to filter by
557
+ * @param filters.procedureTechnology - Optional procedure technology to filter by
558
+ * @param filters.location - Optional location for distance-based search
559
+ * @param filters.radiusInKm - Optional radius in kilometers (required if location is provided)
560
+ * @param filters.minPrice - Optional minimum price
561
+ * @param filters.maxPrice - Optional maximum price
562
+ * @param filters.minRating - Optional minimum rating (0-5)
563
+ * @param filters.maxRating - Optional maximum rating (0-5)
564
+ * @param filters.pagination - Optional number of results per page
565
+ * @param filters.lastDoc - Optional last document for pagination
566
+ * @param filters.isActive - Optional filter for active procedures only
567
+ * @returns Filtered procedures and the last document for pagination
568
+ */
569
+ async getProceduresByFilters(filters: {
570
+ nameSearch?: string;
571
+ treatmentBenefits?: TreatmentBenefit[];
572
+ procedureFamily?: ProcedureFamily;
573
+ procedureCategory?: string;
574
+ procedureSubcategory?: string;
575
+ procedureTechnology?: string;
576
+ location?: { latitude: number; longitude: number };
577
+ radiusInKm?: number;
578
+ minPrice?: number;
579
+ maxPrice?: number;
580
+ minRating?: number;
581
+ maxRating?: number;
582
+ pagination?: number;
583
+ lastDoc?: any;
584
+ isActive?: boolean;
585
+ }): Promise<{
586
+ procedures: (Procedure & { distance?: number })[];
587
+ lastDoc: any;
588
+ }> {
589
+ try {
590
+ console.log(
591
+ "[PROCEDURE_SERVICE] Starting procedure filtering with criteria:",
592
+ filters
593
+ );
594
+
595
+ // Determine if we're doing a geo query or a regular query
596
+ const isGeoQuery =
597
+ filters.location && filters.radiusInKm && filters.radiusInKm > 0;
598
+
599
+ // Initialize base constraints
600
+ const constraints: QueryConstraint[] = [];
601
+
602
+ // Add active status filter (default to active if not specified)
603
+ if (filters.isActive !== undefined) {
604
+ constraints.push(where("isActive", "==", filters.isActive));
605
+ } else {
606
+ constraints.push(where("isActive", "==", true));
607
+ }
608
+
609
+ // Filter by procedure family if specified
610
+ if (filters.procedureFamily) {
611
+ constraints.push(where("family", "==", filters.procedureFamily));
612
+ }
613
+
614
+ // Add ordering to make pagination consistent
615
+ constraints.push(orderBy(documentId()));
616
+
617
+ // Add pagination if specified
618
+ if (filters.pagination && filters.pagination > 0 && filters.lastDoc) {
619
+ constraints.push(startAfter(filters.lastDoc));
620
+ constraints.push(limit(filters.pagination));
621
+ } else if (filters.pagination && filters.pagination > 0) {
622
+ constraints.push(limit(filters.pagination));
623
+ }
624
+
625
+ let proceduresResult: (Procedure & { distance?: number })[] = [];
626
+ let lastVisibleDoc = null;
627
+
628
+ // For geo queries, we need a different approach
629
+ if (isGeoQuery) {
630
+ const center = filters.location!;
631
+ const radiusInKm = filters.radiusInKm!;
632
+
633
+ // Get the geohash query bounds
634
+ const bounds = geohashQueryBounds(
635
+ [center.latitude, center.longitude],
636
+ radiusInKm * 1000 // Convert to meters
637
+ );
638
+
639
+ // Collect matching procedures from all bounds
640
+ const matchingProcedures: (Procedure & { distance: number })[] = [];
641
+
642
+ // Execute queries for each bound
643
+ for (const bound of bounds) {
644
+ // Create a geo query for this bound
645
+ const geoConstraints = [
646
+ ...constraints,
647
+ where("clinicInfo.location.geohash", ">=", bound[0]),
648
+ where("clinicInfo.location.geohash", "<=", bound[1]),
649
+ ];
650
+
651
+ const q = query(
652
+ collection(this.db, PROCEDURES_COLLECTION),
653
+ ...geoConstraints
654
+ );
655
+ const querySnapshot = await getDocs(q);
656
+
657
+ console.log(
658
+ `[PROCEDURE_SERVICE] Found ${querySnapshot.docs.length} procedures in geo bound`
659
+ );
660
+
661
+ // Process results and filter by actual distance
662
+ for (const doc of querySnapshot.docs) {
663
+ const procedure = { ...doc.data(), id: doc.id } as Procedure;
664
+
665
+ // Calculate actual distance
666
+ const distance = distanceBetween(
667
+ [center.latitude, center.longitude],
668
+ [
669
+ procedure.clinicInfo.location.latitude,
670
+ procedure.clinicInfo.location.longitude,
671
+ ]
672
+ );
673
+
674
+ // Convert to kilometers
675
+ const distanceInKm = distance / 1000;
676
+
677
+ // Check if within radius
678
+ if (distanceInKm <= radiusInKm) {
679
+ // Add distance to procedure object
680
+ matchingProcedures.push({
681
+ ...procedure,
682
+ distance: distanceInKm,
683
+ });
684
+ }
685
+ }
686
+ }
687
+
688
+ // Apply additional filters that couldn't be applied in the query
689
+ let filteredProcedures = matchingProcedures;
690
+
691
+ // Apply remaining filters in memory
692
+ filteredProcedures = this.applyInMemoryFilters(
693
+ filteredProcedures,
694
+ filters
695
+ );
696
+
697
+ // Sort by distance
698
+ filteredProcedures.sort((a, b) => a.distance - b.distance);
699
+
700
+ // Apply pagination after all filters have been applied
701
+ if (filters.pagination && filters.pagination > 0) {
702
+ // If we have a lastDoc, find its index
703
+ let startIndex = 0;
704
+ if (filters.lastDoc) {
705
+ const lastDocIndex = filteredProcedures.findIndex(
706
+ (procedure) => procedure.id === filters.lastDoc.id
707
+ );
708
+ if (lastDocIndex !== -1) {
709
+ startIndex = lastDocIndex + 1;
710
+ }
711
+ }
712
+
713
+ // Get paginated subset
714
+ const paginatedProcedures = filteredProcedures.slice(
715
+ startIndex,
716
+ startIndex + filters.pagination
717
+ );
718
+
719
+ // Set last document for next pagination
720
+ lastVisibleDoc =
721
+ paginatedProcedures.length > 0
722
+ ? paginatedProcedures[paginatedProcedures.length - 1]
723
+ : null;
724
+
725
+ proceduresResult = paginatedProcedures;
726
+ } else {
727
+ proceduresResult = filteredProcedures;
728
+ }
729
+ } else {
730
+ // For non-geo queries, execute a single query with all constraints
731
+ const q = query(
732
+ collection(this.db, PROCEDURES_COLLECTION),
733
+ ...constraints
734
+ );
735
+ const querySnapshot = await getDocs(q);
736
+
737
+ console.log(
738
+ `[PROCEDURE_SERVICE] Found ${querySnapshot.docs.length} procedures with regular query`
739
+ );
740
+
741
+ // Convert docs to procedures
742
+ const procedures = querySnapshot.docs.map((doc) => {
743
+ return { ...doc.data(), id: doc.id } as Procedure;
744
+ });
745
+
746
+ // Apply filters that couldn't be applied in the query
747
+ let filteredProcedures = this.applyInMemoryFilters(procedures, filters);
748
+
749
+ // Set last document for pagination
750
+ lastVisibleDoc =
751
+ querySnapshot.docs.length > 0
752
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
753
+ : null;
754
+
755
+ proceduresResult = filteredProcedures;
756
+ }
757
+
758
+ return {
759
+ procedures: proceduresResult,
760
+ lastDoc: lastVisibleDoc,
761
+ };
762
+ } catch (error) {
763
+ console.error("[PROCEDURE_SERVICE] Error filtering procedures:", error);
764
+ throw error;
765
+ }
766
+ }
767
+
768
+ /**
769
+ * Helper method to apply in-memory filters to procedures
770
+ * Used by getProceduresByFilters to apply filters that can't be done in Firestore queries
771
+ *
772
+ * @param procedures - The procedures to filter
773
+ * @param filters - The filters to apply
774
+ * @returns Filtered procedures
775
+ */
776
+ private applyInMemoryFilters<T extends Procedure & { distance?: number }>(
777
+ procedures: T[],
778
+ filters: {
779
+ nameSearch?: string;
780
+ treatmentBenefits?: TreatmentBenefit[];
781
+ procedureCategory?: string;
782
+ procedureSubcategory?: string;
783
+ procedureTechnology?: string;
784
+ minPrice?: number;
785
+ maxPrice?: number;
786
+ minRating?: number;
787
+ maxRating?: number;
788
+ }
789
+ ): T[] {
790
+ let filteredProcedures = procedures;
791
+
792
+ // Filter by name search if specified
793
+ if (filters.nameSearch && filters.nameSearch.trim() !== "") {
794
+ const searchTerm = filters.nameSearch.toLowerCase().trim();
795
+ filteredProcedures = filteredProcedures.filter((procedure) => {
796
+ return procedure.name.toLowerCase().includes(searchTerm);
797
+ });
798
+ }
799
+
800
+ // Filter by treatment benefits if specified
801
+ if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
802
+ filteredProcedures = filteredProcedures.filter((procedure) => {
803
+ // Check if procedure has all specified treatment benefits
804
+ return filters.treatmentBenefits!.every((benefit) =>
805
+ procedure.treatmentBenefits.includes(benefit)
806
+ );
807
+ });
808
+ }
809
+
810
+ // Filter by procedure category if specified
811
+ if (filters.procedureCategory) {
812
+ filteredProcedures = filteredProcedures.filter(
813
+ (procedure) => procedure.category.id === filters.procedureCategory
814
+ );
815
+ }
816
+
817
+ // Filter by procedure subcategory if specified
818
+ if (filters.procedureSubcategory) {
819
+ filteredProcedures = filteredProcedures.filter(
820
+ (procedure) => procedure.subcategory.id === filters.procedureSubcategory
821
+ );
822
+ }
823
+
824
+ // Filter by procedure technology if specified
825
+ if (filters.procedureTechnology) {
826
+ filteredProcedures = filteredProcedures.filter(
827
+ (procedure) => procedure.technology.id === filters.procedureTechnology
828
+ );
829
+ }
830
+
831
+ // Filter by price range if specified
832
+ if (filters.minPrice !== undefined) {
833
+ filteredProcedures = filteredProcedures.filter(
834
+ (procedure) => procedure.price >= filters.minPrice!
835
+ );
836
+ }
837
+
838
+ if (filters.maxPrice !== undefined) {
839
+ filteredProcedures = filteredProcedures.filter(
840
+ (procedure) => procedure.price <= filters.maxPrice!
841
+ );
842
+ }
843
+
844
+ // Filter by rating if specified
845
+ if (filters.minRating !== undefined) {
846
+ filteredProcedures = filteredProcedures.filter(
847
+ (procedure) => procedure.reviewInfo.averageRating >= filters.minRating!
848
+ );
849
+ }
850
+
851
+ if (filters.maxRating !== undefined) {
852
+ filteredProcedures = filteredProcedures.filter(
853
+ (procedure) => procedure.reviewInfo.averageRating <= filters.maxRating!
854
+ );
855
+ }
856
+
857
+ return filteredProcedures;
858
+ }
896
859
  }