@blackcode_sa/metaestetics-api 1.5.28 → 1.5.30

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 (49) hide show
  1. package/dist/admin/index.d.mts +1324 -1
  2. package/dist/admin/index.d.ts +1324 -1
  3. package/dist/admin/index.js +1674 -2
  4. package/dist/admin/index.mjs +1668 -2
  5. package/dist/backoffice/index.d.mts +99 -7
  6. package/dist/backoffice/index.d.ts +99 -7
  7. package/dist/index.d.mts +4036 -2372
  8. package/dist/index.d.ts +4036 -2372
  9. package/dist/index.js +2331 -2009
  10. package/dist/index.mjs +2279 -1954
  11. package/package.json +2 -1
  12. package/src/admin/aggregation/README.md +79 -0
  13. package/src/admin/aggregation/clinic/README.md +52 -0
  14. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +642 -0
  15. package/src/admin/aggregation/patient/README.md +27 -0
  16. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -0
  17. package/src/admin/aggregation/practitioner/README.md +42 -0
  18. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -0
  19. package/src/admin/aggregation/procedure/README.md +43 -0
  20. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +508 -0
  21. package/src/admin/index.ts +60 -4
  22. package/src/admin/mailing/README.md +95 -0
  23. package/src/admin/mailing/base.mailing.service.ts +131 -0
  24. package/src/admin/mailing/index.ts +2 -0
  25. package/src/admin/mailing/practitionerInvite/index.ts +1 -0
  26. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +256 -0
  27. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -0
  28. package/src/index.ts +28 -4
  29. package/src/services/README.md +106 -0
  30. package/src/services/clinic/README.md +87 -0
  31. package/src/services/clinic/clinic.service.ts +197 -107
  32. package/src/services/clinic/utils/clinic.utils.ts +68 -119
  33. package/src/services/clinic/utils/filter.utils.d.ts +23 -0
  34. package/src/services/clinic/utils/filter.utils.ts +264 -0
  35. package/src/services/practitioner/README.md +145 -0
  36. package/src/services/practitioner/practitioner.service.ts +439 -104
  37. package/src/services/procedure/README.md +88 -0
  38. package/src/services/procedure/procedure.service.ts +521 -311
  39. package/src/services/reviews/reviews.service.ts +842 -0
  40. package/src/types/clinic/index.ts +24 -56
  41. package/src/types/practitioner/index.ts +34 -33
  42. package/src/types/procedure/index.ts +32 -0
  43. package/src/types/profile/index.ts +1 -1
  44. package/src/types/reviews/index.ts +126 -0
  45. package/src/validations/clinic.schema.ts +37 -64
  46. package/src/validations/practitioner.schema.ts +42 -32
  47. package/src/validations/procedure.schema.ts +11 -3
  48. package/src/validations/reviews.schema.ts +189 -0
  49. package/src/services/clinic/utils/review.utils.ts +0 -93
@@ -12,6 +12,14 @@ import {
12
12
  serverTimestamp,
13
13
  DocumentData,
14
14
  writeBatch,
15
+ arrayUnion,
16
+ arrayRemove,
17
+ FieldValue,
18
+ orderBy,
19
+ limit,
20
+ startAfter,
21
+ QueryConstraint,
22
+ documentId,
15
23
  } from "firebase/firestore";
16
24
  import { BaseService } from "../base.service";
17
25
  import {
@@ -19,6 +27,7 @@ import {
19
27
  CreateProcedureData,
20
28
  UpdateProcedureData,
21
29
  PROCEDURES_COLLECTION,
30
+ ProcedureSummaryInfo,
22
31
  } from "../../types/procedure";
23
32
  import {
24
33
  createProcedureSchema,
@@ -57,7 +66,10 @@ import {
57
66
  CertificationSpecialty,
58
67
  ProcedureFamily,
59
68
  } from "../../backoffice/types";
60
- import { CLINICS_COLLECTION } from "../../types/clinic";
69
+ import { Clinic, CLINICS_COLLECTION } from "../../types/clinic";
70
+ import { ProcedureReviewInfo } from "../../types/reviews";
71
+ import { distanceBetween, geohashQueryBounds } from "geofire-common";
72
+ import { TreatmentBenefit } from "../../backoffice/types/static/treatment-benefit.types";
61
73
 
62
74
  export class ProcedureService extends BaseService {
63
75
  private categoryService: CategoryService;
@@ -87,10 +99,9 @@ export class ProcedureService extends BaseService {
87
99
  * @returns The created procedure
88
100
  */
89
101
  async createProcedure(data: CreateProcedureData): Promise<Procedure> {
90
- // Validate input data
91
102
  const validatedData = createProcedureSchema.parse(data);
92
103
 
93
- // Get references to related entities
104
+ // Get references to related entities (Category, Subcategory, Technology, Product)
94
105
  const [category, subcategory, technology, product] = await Promise.all([
95
106
  this.categoryService.getById(validatedData.categoryId),
96
107
  this.subcategoryService.getById(
@@ -105,75 +116,65 @@ export class ProcedureService extends BaseService {
105
116
  ]);
106
117
 
107
118
  if (!category || !subcategory || !technology || !product) {
108
- throw new Error("One or more required entities not found");
119
+ throw new Error("One or more required base entities not found");
109
120
  }
110
121
 
111
- // Get clinic and practitioner information
122
+ // Get clinic and practitioner information for aggregation
112
123
  const clinicRef = doc(
113
124
  this.db,
114
125
  CLINICS_COLLECTION,
115
126
  validatedData.clinicBranchId
116
127
  );
117
128
  const clinicSnapshot = await getDoc(clinicRef);
118
-
119
129
  if (!clinicSnapshot.exists()) {
120
130
  throw new Error(
121
131
  `Clinic with ID ${validatedData.clinicBranchId} not found`
122
132
  );
123
133
  }
134
+ const clinic = clinicSnapshot.data() as Clinic; // Assert type
124
135
 
125
- const clinic = clinicSnapshot.data();
126
-
127
- // Create clinic info
128
- const clinicInfo = {
129
- id: clinicSnapshot.id,
130
- name: clinic.name,
131
- description: clinic.description || "",
132
- featuredPhoto:
133
- clinic.featuredPhotos && clinic.featuredPhotos.length > 0
134
- ? clinic.featuredPhotos[0]
135
- : clinic.coverPhoto || "",
136
- location: clinic.location,
137
- contactInfo: clinic.contactInfo,
138
- };
139
-
140
- // Get practitioner information
141
136
  const practitionerRef = doc(
142
137
  this.db,
143
138
  PRACTITIONERS_COLLECTION,
144
139
  validatedData.practitionerId
145
140
  );
146
141
  const practitionerSnapshot = await getDoc(practitionerRef);
147
-
148
142
  if (!practitionerSnapshot.exists()) {
149
143
  throw new Error(
150
144
  `Practitioner with ID ${validatedData.practitionerId} not found`
151
145
  );
152
146
  }
147
+ const practitioner = practitionerSnapshot.data() as Practitioner; // Assert type
153
148
 
154
- const practitioner = practitionerSnapshot.data();
155
-
156
- // Find doctor info in clinic's doctorsInfo array
157
- let doctorInfo = clinic.doctorsInfo?.find(
158
- (doctor: any) => doctor.id === validatedData.practitionerId
159
- );
149
+ // Create aggregated clinic info for the procedure document
150
+ const clinicInfo = {
151
+ id: clinicSnapshot.id,
152
+ name: clinic.name,
153
+ description: clinic.description || "",
154
+ featuredPhoto:
155
+ clinic.featuredPhotos && clinic.featuredPhotos.length > 0
156
+ ? clinic.featuredPhotos[0]
157
+ : clinic.coverPhoto || "",
158
+ location: clinic.location,
159
+ contactInfo: clinic.contactInfo,
160
+ };
160
161
 
161
- // If not found, create basic doctor info
162
- if (!doctorInfo) {
163
- doctorInfo = {
164
- id: practitionerSnapshot.id,
165
- name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
166
- description: practitioner.basicInfo.bio || "",
167
- photo: practitioner.basicInfo.profileImageUrl || "",
168
- rating: 0,
169
- services: [],
170
- };
171
- }
162
+ // Create aggregated doctor info for the procedure document
163
+ const doctorInfo = {
164
+ id: practitionerSnapshot.id,
165
+ name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
166
+ description: practitioner.basicInfo.bio || "",
167
+ photo: practitioner.basicInfo.profileImageUrl || "",
168
+ rating: practitioner.reviewInfo?.averageRating || 0,
169
+ services: practitioner.procedures || [],
170
+ };
172
171
 
173
172
  // Create the procedure object
174
- const procedure: Omit<Procedure, "id"> = {
173
+ const procedureId = this.generateId();
174
+ const newProcedure: Omit<Procedure, "createdAt" | "updatedAt"> = {
175
+ id: procedureId,
175
176
  ...validatedData,
176
- category,
177
+ category, // Embed full objects
177
178
  subcategory,
178
179
  technology,
179
180
  product,
@@ -183,24 +184,33 @@ export class ProcedureService extends BaseService {
183
184
  postRequirements: technology.requirements.post,
184
185
  certificationRequirement: technology.certificationRequirement,
185
186
  documentationTemplates: technology.documentationTemplates || [],
186
- clinicInfo,
187
- doctorInfo,
188
- isActive: true,
189
- createdAt: new Date(),
190
- updatedAt: new Date(),
187
+ clinicInfo, // Embed aggregated info
188
+ doctorInfo, // Embed aggregated info
189
+ reviewInfo: {
190
+ // Default empty reviews
191
+ totalReviews: 0,
192
+ averageRating: 0,
193
+ effectivenessOfTreatment: 0,
194
+ outcomeExplanation: 0,
195
+ painManagement: 0,
196
+ followUpCare: 0,
197
+ valueForMoney: 0,
198
+ recommendationPercentage: 0,
199
+ },
200
+ isActive: true, // Default to active
191
201
  };
192
202
 
193
- // Generate ID and create document
194
- const id = this.generateId();
195
- const docRef = doc(this.db, PROCEDURES_COLLECTION, id);
196
- await setDoc(docRef, {
197
- ...procedure,
198
- id,
203
+ // Create the procedure document
204
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, procedureId);
205
+ await setDoc(procedureRef, {
206
+ ...newProcedure,
199
207
  createdAt: serverTimestamp(),
200
208
  updatedAt: serverTimestamp(),
201
209
  });
202
210
 
203
- return { ...procedure, id } as Procedure;
211
+ // Return the created procedure (fetch again to get server timestamps)
212
+ const savedDoc = await getDoc(procedureRef);
213
+ return savedDoc.data() as Procedure;
204
214
  }
205
215
 
206
216
  /**
@@ -256,71 +266,207 @@ export class ProcedureService extends BaseService {
256
266
  /**
257
267
  * Updates a procedure
258
268
  * @param id - The ID of the procedure to update
259
- * @param data - The data to update
269
+ * @param data - The data to update the procedure with
260
270
  * @returns The updated procedure
261
271
  */
262
272
  async updateProcedure(
263
273
  id: string,
264
274
  data: UpdateProcedureData
265
275
  ): Promise<Procedure> {
266
- // Validate input data
267
276
  const validatedData = updateProcedureSchema.parse(data);
277
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, id);
278
+ const procedureSnapshot = await getDoc(procedureRef);
268
279
 
269
- // Get the existing procedure
270
- const existingProcedure = await this.getProcedure(id);
271
- if (!existingProcedure) {
280
+ if (!procedureSnapshot.exists()) {
272
281
  throw new Error(`Procedure with ID ${id} not found`);
273
282
  }
274
283
 
275
- // Update the procedure
276
- const docRef = doc(this.db, PROCEDURES_COLLECTION, id);
277
- await updateDoc(docRef, {
278
- ...validatedData,
284
+ const existingProcedure = procedureSnapshot.data() as Procedure;
285
+ let updatedProcedureData: Partial<Procedure> = { ...validatedData };
286
+
287
+ let practitionerChanged = false;
288
+ let clinicChanged = false;
289
+ const oldPractitionerId = existingProcedure.practitionerId;
290
+ const oldClinicId = existingProcedure.clinicBranchId;
291
+ let newPractitioner: Practitioner | null = null;
292
+ let newClinic: Clinic | null = null;
293
+
294
+ // --- Prepare updates and fetch new related data if IDs change ---
295
+
296
+ // Handle Practitioner Change
297
+ if (
298
+ validatedData.practitionerId &&
299
+ validatedData.practitionerId !== oldPractitionerId
300
+ ) {
301
+ practitionerChanged = true;
302
+ const newPractitionerRef = doc(
303
+ this.db,
304
+ PRACTITIONERS_COLLECTION,
305
+ validatedData.practitionerId
306
+ );
307
+ const newPractitionerSnap = await getDoc(newPractitionerRef);
308
+ if (!newPractitionerSnap.exists())
309
+ throw new Error(
310
+ `New Practitioner ${validatedData.practitionerId} not found`
311
+ );
312
+ newPractitioner = newPractitionerSnap.data() as Practitioner;
313
+ // Update doctorInfo within the procedure document
314
+ updatedProcedureData.doctorInfo = {
315
+ id: newPractitioner.id,
316
+ name: `${newPractitioner.basicInfo.firstName} ${newPractitioner.basicInfo.lastName}`,
317
+ description: newPractitioner.basicInfo.bio || "",
318
+ photo: newPractitioner.basicInfo.profileImageUrl || "",
319
+ rating: newPractitioner.reviewInfo?.averageRating || 0,
320
+ services: newPractitioner.procedures || [],
321
+ };
322
+ }
323
+
324
+ // Handle Clinic Change
325
+ if (
326
+ validatedData.clinicBranchId &&
327
+ validatedData.clinicBranchId !== oldClinicId
328
+ ) {
329
+ clinicChanged = true;
330
+ const newClinicRef = doc(
331
+ this.db,
332
+ CLINICS_COLLECTION,
333
+ validatedData.clinicBranchId
334
+ );
335
+ const newClinicSnap = await getDoc(newClinicRef);
336
+ if (!newClinicSnap.exists())
337
+ throw new Error(`New Clinic ${validatedData.clinicBranchId} not found`);
338
+ newClinic = newClinicSnap.data() as Clinic;
339
+ // Update clinicInfo within the procedure document
340
+ updatedProcedureData.clinicInfo = {
341
+ id: newClinic.id,
342
+ name: newClinic.name,
343
+ description: newClinic.description || "",
344
+ featuredPhoto:
345
+ newClinic.featuredPhotos && newClinic.featuredPhotos.length > 0
346
+ ? newClinic.featuredPhotos[0]
347
+ : newClinic.coverPhoto || "",
348
+ location: newClinic.location,
349
+ contactInfo: newClinic.contactInfo,
350
+ };
351
+ }
352
+
353
+ // Handle Category/Subcategory/Technology/Product Changes
354
+ let finalCategoryId = existingProcedure.category.id;
355
+ if (validatedData.categoryId) {
356
+ const category = await this.categoryService.getById(
357
+ validatedData.categoryId
358
+ );
359
+ if (!category)
360
+ throw new Error(`Category ${validatedData.categoryId} not found`);
361
+ updatedProcedureData.category = category;
362
+ finalCategoryId = category.id; // Update finalCategoryId if category changed
363
+ }
364
+
365
+ // Only fetch subcategory if its ID is provided AND we have a valid finalCategoryId
366
+ if (validatedData.subcategoryId && finalCategoryId) {
367
+ const subcategory = await this.subcategoryService.getById(
368
+ finalCategoryId,
369
+ validatedData.subcategoryId
370
+ );
371
+ if (!subcategory)
372
+ throw new Error(
373
+ `Subcategory ${validatedData.subcategoryId} not found for category ${finalCategoryId}`
374
+ );
375
+ updatedProcedureData.subcategory = subcategory;
376
+ } else if (validatedData.subcategoryId) {
377
+ console.warn(
378
+ "Attempted to update subcategory without a valid categoryId"
379
+ );
380
+ }
381
+
382
+ let finalTechnologyId = existingProcedure.technology.id;
383
+ if (validatedData.technologyId) {
384
+ const technology = await this.technologyService.getById(
385
+ validatedData.technologyId
386
+ );
387
+ if (!technology)
388
+ throw new Error(`Technology ${validatedData.technologyId} not found`);
389
+ updatedProcedureData.technology = technology;
390
+ finalTechnologyId = technology.id; // Update finalTechnologyId if technology changed
391
+ // Update related fields derived from technology
392
+ updatedProcedureData.blockingConditions = technology.blockingConditions;
393
+ updatedProcedureData.treatmentBenefits = technology.benefits;
394
+ updatedProcedureData.preRequirements = technology.requirements.pre;
395
+ updatedProcedureData.postRequirements = technology.requirements.post;
396
+ updatedProcedureData.certificationRequirement =
397
+ technology.certificationRequirement;
398
+ updatedProcedureData.documentationTemplates =
399
+ technology.documentationTemplates || [];
400
+ }
401
+
402
+ // Only fetch product if its ID is provided AND we have a valid finalTechnologyId
403
+ if (validatedData.productId && finalTechnologyId) {
404
+ const product = await this.productService.getById(
405
+ finalTechnologyId,
406
+ validatedData.productId
407
+ );
408
+ if (!product)
409
+ throw new Error(
410
+ `Product ${validatedData.productId} not found for technology ${finalTechnologyId}`
411
+ );
412
+ updatedProcedureData.product = product;
413
+ } else if (validatedData.productId) {
414
+ console.warn("Attempted to update product without a valid technologyId");
415
+ }
416
+
417
+ // Update the procedure document
418
+ await updateDoc(procedureRef, {
419
+ ...updatedProcedureData,
279
420
  updatedAt: serverTimestamp(),
280
421
  });
281
422
 
282
- // Return the updated procedure with combined data
283
- // Note: Since we're not changing the clinicInfo or doctorInfo in this update,
284
- // we just keep the existing values from the procedure
285
- return {
286
- ...existingProcedure,
287
- ...validatedData,
288
- updatedAt: new Date(),
289
- };
423
+ // Return the updated procedure
424
+ const updatedSnapshot = await getDoc(procedureRef);
425
+ return updatedSnapshot.data() as Procedure;
290
426
  }
291
427
 
292
428
  /**
293
- * Deactivates a procedure
429
+ * Deactivates a procedure (soft delete)
294
430
  * @param id - The ID of the procedure to deactivate
295
431
  */
296
432
  async deactivateProcedure(id: string): Promise<void> {
297
- const docRef = doc(this.db, PROCEDURES_COLLECTION, id);
298
- await updateDoc(docRef, {
433
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, id);
434
+ const procedureSnap = await getDoc(procedureRef);
435
+ if (!procedureSnap.exists()) {
436
+ console.warn(`Procedure ${id} not found for deactivation.`);
437
+ return;
438
+ }
439
+
440
+ // Mark procedure as inactive
441
+ await updateDoc(procedureRef, {
299
442
  isActive: false,
300
443
  updatedAt: serverTimestamp(),
301
444
  });
302
445
  }
303
446
 
447
+ /**
448
+ * Deletes a procedure permanently
449
+ * @param id - The ID of the procedure to delete
450
+ * @returns A boolean indicating if the deletion was successful
451
+ */
452
+ async deleteProcedure(id: string): Promise<boolean> {
453
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, id);
454
+ const procedureSnapshot = await getDoc(procedureRef);
455
+
456
+ if (!procedureSnapshot.exists()) {
457
+ // Already deleted or never existed
458
+ return false;
459
+ }
460
+
461
+ // Delete the procedure document
462
+ await deleteDoc(procedureRef);
463
+ return true;
464
+ }
465
+
304
466
  /**
305
467
  * Gets all procedures that a practitioner is certified to perform
306
468
  * @param practitioner - The practitioner's profile
307
- * @returns Object containing:
308
- * - procedures: List of procedures the practitioner can perform
309
- * - families: List of procedure families the practitioner can perform
310
- * - categories: List of category IDs the practitioner can perform
311
- * - subcategories: List of subcategory IDs the practitioner can perform
312
- *
313
- * @example
314
- * const practitioner = {
315
- * certification: {
316
- * level: CertificationLevel.DOCTOR,
317
- * specialties: [CertificationSpecialty.INJECTABLES]
318
- * }
319
- * };
320
- * const allowedProcedures = await procedureService.getAllowedProcedures(practitioner);
321
- * console.log(allowedProcedures.families); // [ProcedureFamily.AESTHETICS]
322
- * console.log(allowedProcedures.categories); // ["category1", "category2"]
323
- * console.log(allowedProcedures.subcategories); // ["subcategory1", "subcategory2"]
469
+ * @returns Object containing allowed technologies, families, categories, subcategories
324
470
  */
325
471
  async getAllowedTechnologies(practitioner: Practitioner): Promise<{
326
472
  technologies: Technology[];
@@ -328,7 +474,7 @@ export class ProcedureService extends BaseService {
328
474
  categories: string[];
329
475
  subcategories: string[];
330
476
  }> {
331
- // Get all allowed technologies for the practitioner
477
+ // This logic depends on TechnologyService and remains valid
332
478
  const { technologies, families, categories, subcategories } =
333
479
  await this.technologyService.getAllowedTechnologies(practitioner);
334
480
 
@@ -355,19 +501,26 @@ export class ProcedureService extends BaseService {
355
501
  const proceduresCollection = collection(this.db, PROCEDURES_COLLECTION);
356
502
  let proceduresQuery = query(proceduresCollection);
357
503
 
358
- // If pagination is specified and greater than 0, limit the query
504
+ // Apply pagination if specified
359
505
  if (pagination && pagination > 0) {
360
- const { limit, startAfter } = require("firebase/firestore");
506
+ const { limit, startAfter } = await import("firebase/firestore"); // Use dynamic import if needed top-level
361
507
 
362
508
  if (lastDoc) {
363
509
  proceduresQuery = query(
364
510
  proceduresCollection,
511
+ orderBy("name"), // Use imported orderBy
365
512
  startAfter(lastDoc),
366
513
  limit(pagination)
367
514
  );
368
515
  } else {
369
- proceduresQuery = query(proceduresCollection, limit(pagination));
516
+ proceduresQuery = query(
517
+ proceduresCollection,
518
+ orderBy("name"),
519
+ limit(pagination)
520
+ ); // Use imported orderBy
370
521
  }
522
+ } else {
523
+ proceduresQuery = query(proceduresCollection, orderBy("name")); // Use imported orderBy
371
524
  }
372
525
 
373
526
  const proceduresSnapshot = await getDocs(proceduresQuery);
@@ -376,17 +529,9 @@ export class ProcedureService extends BaseService {
376
529
 
377
530
  const procedures = proceduresSnapshot.docs.map((doc) => {
378
531
  const data = doc.data() as Procedure;
379
-
380
- // Ensure clinicInfo and doctorInfo are present
381
- if (!data.clinicInfo || !data.doctorInfo) {
382
- console.warn(
383
- `Procedure ${data.id} is missing clinicInfo or doctorInfo fields. These should be updated.`
384
- );
385
- }
386
-
387
532
  return {
388
533
  ...data,
389
- id: doc.id,
534
+ id: doc.id, // Ensure ID is present
390
535
  };
391
536
  });
392
537
 
@@ -401,249 +546,314 @@ export class ProcedureService extends BaseService {
401
546
  }
402
547
 
403
548
  /**
404
- * Updates the clinicInfo and doctorInfo fields in procedures when the source data changes
549
+ * Searches and filters procedures based on multiple criteria
405
550
  *
406
- * @param entityType - The type of entity that changed ("clinic" or "doctor")
407
- * @param entityId - The ID of the entity that changed
408
- * @param updatedData - The updated data for the entity
409
- * @returns Number of procedures updated
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
410
568
  */
411
- async updateProcedureAggregateData(
412
- entityType: "clinic" | "doctor",
413
- entityId: string,
414
- updatedData: any
415
- ): Promise<number> {
416
- let proceduresQuery;
417
- const updatedField = entityType === "clinic" ? "clinicInfo" : "doctorInfo";
418
-
419
- // Find all procedures related to this entity
420
- if (entityType === "clinic") {
421
- proceduresQuery = query(
422
- collection(this.db, PROCEDURES_COLLECTION),
423
- where("clinicBranchId", "==", entityId)
424
- );
425
- } else {
426
- proceduresQuery = query(
427
- collection(this.db, PROCEDURES_COLLECTION),
428
- where("practitionerId", "==", entityId)
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
429
593
  );
430
- }
431
-
432
- const snapshot = await getDocs(proceduresQuery);
433
594
 
434
- if (snapshot.empty) {
435
- return 0;
436
- }
595
+ // Determine if we're doing a geo query or a regular query
596
+ const isGeoQuery =
597
+ filters.location && filters.radiusInKm && filters.radiusInKm > 0;
437
598
 
438
- // Create the updated data object
439
- let updatedFieldData;
599
+ // Initialize base constraints
600
+ const constraints: QueryConstraint[] = [];
440
601
 
441
- if (entityType === "clinic") {
442
- // Clinic info format
443
- updatedFieldData = {
444
- id: entityId,
445
- name: updatedData.name,
446
- description: updatedData.description || "",
447
- featuredPhoto:
448
- updatedData.featuredPhotos && updatedData.featuredPhotos.length > 0
449
- ? updatedData.featuredPhotos[0]
450
- : updatedData.coverPhoto || "",
451
- location: updatedData.location,
452
- contactInfo: updatedData.contactInfo,
453
- };
454
- } else {
455
- // Doctor info format
456
- if (updatedData.basicInfo) {
457
- // If it's a practitioner object
458
- updatedFieldData = {
459
- id: entityId,
460
- name: `${updatedData.basicInfo.firstName} ${updatedData.basicInfo.lastName}`,
461
- description: updatedData.basicInfo.bio || "",
462
- photo: updatedData.basicInfo.profileImageUrl || "",
463
- rating: 0, // This would need to be calculated or passed in
464
- services: [], // This would need to be determined or passed in
465
- };
602
+ // Add active status filter (default to active if not specified)
603
+ if (filters.isActive !== undefined) {
604
+ constraints.push(where("isActive", "==", filters.isActive));
466
605
  } else {
467
- // If it's already a doctorInfo object
468
- updatedFieldData = {
469
- id: entityId,
470
- name: updatedData.name,
471
- description: updatedData.description || "",
472
- photo: updatedData.photo || "",
473
- rating: updatedData.rating || 0,
474
- services: updatedData.services || [],
475
- };
606
+ constraints.push(where("isActive", "==", true));
476
607
  }
477
- }
478
608
 
479
- // Update all found procedures
480
- const batch = writeBatch(this.db);
609
+ // Filter by procedure family if specified
610
+ if (filters.procedureFamily) {
611
+ constraints.push(where("family", "==", filters.procedureFamily));
612
+ }
481
613
 
482
- snapshot.docs.forEach((doc) => {
483
- batch.update(doc.ref, {
484
- [updatedField]: updatedFieldData,
485
- updatedAt: serverTimestamp(),
486
- });
487
- });
614
+ // Add ordering to make pagination consistent
615
+ constraints.push(orderBy(documentId()));
488
616
 
489
- await batch.commit();
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
+ }
490
624
 
491
- return snapshot.size;
492
- }
625
+ let proceduresResult: (Procedure & { distance?: number })[] = [];
626
+ let lastVisibleDoc = null;
493
627
 
494
- /**
495
- * Updates the clinicInfo for all procedures associated with a specific clinic
496
- *
497
- * @param clinicId - The ID of the clinic that was updated
498
- * @param clinicData - The updated clinic data
499
- * @returns Number of procedures updated
500
- */
501
- async updateClinicInfoInProcedures(
502
- clinicId: string,
503
- clinicData: any
504
- ): Promise<number> {
505
- return this.updateProcedureAggregateData("clinic", clinicId, clinicData);
506
- }
628
+ // For geo queries, we need a different approach
629
+ if (isGeoQuery) {
630
+ const center = filters.location!;
631
+ const radiusInKm = filters.radiusInKm!;
507
632
 
508
- /**
509
- * Updates the doctorInfo for all procedures associated with a specific practitioner
510
- *
511
- * @param practitionerId - The ID of the practitioner that was updated
512
- * @param practitionerData - The updated practitioner data
513
- * @returns Number of procedures updated
514
- */
515
- async updateDoctorInfoInProcedures(
516
- practitionerId: string,
517
- practitionerData: any
518
- ): Promise<number> {
519
- return this.updateProcedureAggregateData(
520
- "doctor",
521
- practitionerId,
522
- practitionerData
523
- );
524
- }
525
-
526
- /**
527
- * Updates all existing procedures to include clinicInfo and doctorInfo
528
- * This is a migration helper method that can be used to update existing procedures
529
- *
530
- * @returns Number of procedures updated
531
- */
532
- async migrateAllProceduresWithAggregateData(): Promise<number> {
533
- // Get all procedures
534
- const proceduresQuery = query(collection(this.db, PROCEDURES_COLLECTION));
535
- const proceduresSnapshot = await getDocs(proceduresQuery);
633
+ // Get the geohash query bounds
634
+ const bounds = geohashQueryBounds(
635
+ [center.latitude, center.longitude],
636
+ radiusInKm * 1000 // Convert to meters
637
+ );
536
638
 
537
- if (proceduresSnapshot.empty) {
538
- return 0;
539
- }
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);
540
656
 
541
- let updatedCount = 0;
542
- const batch = writeBatch(this.db);
543
- const batchLimit = 500; // Firestore batch limit
544
- let batchCount = 0;
657
+ console.log(
658
+ `[PROCEDURE_SERVICE] Found ${querySnapshot.docs.length} procedures in geo bound`
659
+ );
545
660
 
546
- // Process each procedure
547
- for (const procedureDoc of proceduresSnapshot.docs) {
548
- const procedure = procedureDoc.data() as Procedure;
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
+ }
549
687
 
550
- // Skip if already has both required fields
551
- if (procedure.clinicInfo && procedure.doctorInfo) {
552
- continue;
553
- }
688
+ // Apply additional filters that couldn't be applied in the query
689
+ let filteredProcedures = matchingProcedures;
554
690
 
555
- try {
556
- // Get clinic data
557
- const clinicRef = doc(
558
- this.db,
559
- CLINICS_COLLECTION,
560
- procedure.clinicBranchId
691
+ // Apply remaining filters in memory
692
+ filteredProcedures = this.applyInMemoryFilters(
693
+ filteredProcedures,
694
+ filters
561
695
  );
562
- const clinicSnapshot = await getDoc(clinicRef);
563
696
 
564
- if (!clinicSnapshot.exists()) {
565
- console.warn(
566
- `Clinic ${procedure.clinicBranchId} not found for procedure ${procedure.id}`
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
567
717
  );
568
- continue;
569
- }
570
718
 
571
- const clinic = clinicSnapshot.data();
572
-
573
- // Create clinic info
574
- const clinicInfo = {
575
- id: clinicSnapshot.id,
576
- name: clinic.name,
577
- description: clinic.description || "",
578
- featuredPhoto:
579
- clinic.featuredPhotos && clinic.featuredPhotos.length > 0
580
- ? clinic.featuredPhotos[0]
581
- : clinic.coverPhoto || "",
582
- location: clinic.location,
583
- contactInfo: clinic.contactInfo,
584
- };
719
+ // Set last document for next pagination
720
+ lastVisibleDoc =
721
+ paginatedProcedures.length > 0
722
+ ? paginatedProcedures[paginatedProcedures.length - 1]
723
+ : null;
585
724
 
586
- // Get practitioner data
587
- const practitionerRef = doc(
588
- this.db,
589
- PRACTITIONERS_COLLECTION,
590
- procedure.practitionerId
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
591
734
  );
592
- const practitionerSnapshot = await getDoc(practitionerRef);
735
+ const querySnapshot = await getDocs(q);
593
736
 
594
- if (!practitionerSnapshot.exists()) {
595
- console.warn(
596
- `Practitioner ${procedure.practitionerId} not found for procedure ${procedure.id}`
597
- );
598
- continue;
599
- }
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;
600
754
 
601
- const practitioner = practitionerSnapshot.data();
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
+ }
602
799
 
603
- // Find doctor info in clinic's doctorsInfo array
604
- let doctorInfo = clinic.doctorsInfo?.find(
605
- (doctor: any) => doctor.id === procedure.practitionerId
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)
606
806
  );
807
+ });
808
+ }
607
809
 
608
- // If not found, create basic doctor info
609
- if (!doctorInfo) {
610
- doctorInfo = {
611
- id: practitionerSnapshot.id,
612
- name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
613
- description: practitioner.basicInfo.bio || "",
614
- photo: practitioner.basicInfo.profileImageUrl || "",
615
- rating: 0,
616
- services: [],
617
- };
618
- }
810
+ // Filter by procedure category if specified
811
+ if (filters.procedureCategory) {
812
+ filteredProcedures = filteredProcedures.filter(
813
+ (procedure) => procedure.category.id === filters.procedureCategory
814
+ );
815
+ }
619
816
 
620
- // Add to batch
621
- batch.update(procedureDoc.ref, {
622
- clinicInfo,
623
- doctorInfo,
624
- updatedAt: serverTimestamp(),
625
- });
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
+ }
626
830
 
627
- batchCount++;
628
- updatedCount++;
831
+ // Filter by price range if specified
832
+ if (filters.minPrice !== undefined) {
833
+ filteredProcedures = filteredProcedures.filter(
834
+ (procedure) => procedure.price >= filters.minPrice!
835
+ );
836
+ }
629
837
 
630
- // Commit batch if we've reached the limit
631
- if (batchCount >= batchLimit) {
632
- await batch.commit();
633
- console.log(`Committed batch of ${batchCount} procedure updates`);
634
- batchCount = 0;
635
- }
636
- } catch (error) {
637
- console.error(`Error updating procedure ${procedure.id}:`, error);
638
- }
838
+ if (filters.maxPrice !== undefined) {
839
+ filteredProcedures = filteredProcedures.filter(
840
+ (procedure) => procedure.price <= filters.maxPrice!
841
+ );
639
842
  }
640
843
 
641
- // Commit any remaining updates
642
- if (batchCount > 0) {
643
- await batch.commit();
644
- console.log(`Committed final batch of ${batchCount} procedure updates`);
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
+ );
645
855
  }
646
856
 
647
- return updatedCount;
857
+ return filteredProcedures;
648
858
  }
649
859
  }