@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
@@ -0,0 +1,842 @@
1
+ import {
2
+ collection,
3
+ doc,
4
+ getDoc,
5
+ getDocs,
6
+ query,
7
+ where,
8
+ updateDoc,
9
+ setDoc,
10
+ deleteDoc,
11
+ Timestamp,
12
+ serverTimestamp,
13
+ DocumentData,
14
+ writeBatch,
15
+ } from "firebase/firestore";
16
+ import { BaseService } from "../base.service";
17
+ import {
18
+ Review,
19
+ ClinicReview,
20
+ PractitionerReview,
21
+ ProcedureReview,
22
+ REVIEWS_COLLECTION,
23
+ ClinicReviewInfo,
24
+ PractitionerReviewInfo,
25
+ ProcedureReviewInfo,
26
+ } from "../../types/reviews";
27
+ import {
28
+ createReviewSchema,
29
+ reviewSchema,
30
+ } from "../../validations/reviews.schema";
31
+ import { z } from "zod";
32
+ import { Auth } from "firebase/auth";
33
+ import { Firestore } from "firebase/firestore";
34
+ import { FirebaseApp } from "firebase/app";
35
+ import { CLINICS_COLLECTION } from "../../types/clinic";
36
+ import { PRACTITIONERS_COLLECTION } from "../../types/practitioner";
37
+ import { PROCEDURES_COLLECTION } from "../../types/procedure";
38
+
39
+ export class ReviewService extends BaseService {
40
+ constructor(db: Firestore, auth: Auth, app: FirebaseApp) {
41
+ super(db, auth, app);
42
+ }
43
+
44
+ /**
45
+ * Creates a new review and updates related entities
46
+ * @param data - The review data to create
47
+ * @param appointmentId - ID of the completed appointment
48
+ * @returns The created review
49
+ */
50
+ async createReview(
51
+ data: Omit<
52
+ Review,
53
+ "id" | "createdAt" | "updatedAt" | "appointmentId" | "overallRating"
54
+ >,
55
+ appointmentId: string
56
+ ): Promise<Review> {
57
+ try {
58
+ // Validate input data
59
+ const validatedData = createReviewSchema.parse(data);
60
+
61
+ // Calculate overall rating based on all provided reviews
62
+ const ratings: number[] = [];
63
+
64
+ if (data.clinicReview) {
65
+ const clinicRatings = [
66
+ data.clinicReview.cleanliness,
67
+ data.clinicReview.facilities,
68
+ data.clinicReview.staffFriendliness,
69
+ data.clinicReview.waitingTime,
70
+ data.clinicReview.accessibility,
71
+ ];
72
+ const clinicAverage = this.calculateAverage(clinicRatings);
73
+ data.clinicReview.overallRating = clinicAverage;
74
+ ratings.push(clinicAverage);
75
+ }
76
+
77
+ if (data.practitionerReview) {
78
+ const practitionerRatings = [
79
+ data.practitionerReview.knowledgeAndExpertise,
80
+ data.practitionerReview.communicationSkills,
81
+ data.practitionerReview.bedSideManner,
82
+ data.practitionerReview.thoroughness,
83
+ data.practitionerReview.trustworthiness,
84
+ ];
85
+ const practitionerAverage = this.calculateAverage(practitionerRatings);
86
+ data.practitionerReview.overallRating = practitionerAverage;
87
+ ratings.push(practitionerAverage);
88
+ }
89
+
90
+ if (data.procedureReview) {
91
+ const procedureRatings = [
92
+ data.procedureReview.effectivenessOfTreatment,
93
+ data.procedureReview.outcomeExplanation,
94
+ data.procedureReview.painManagement,
95
+ data.procedureReview.followUpCare,
96
+ data.procedureReview.valueForMoney,
97
+ ];
98
+ const procedureAverage = this.calculateAverage(procedureRatings);
99
+ data.procedureReview.overallRating = procedureAverage;
100
+ ratings.push(procedureAverage);
101
+ }
102
+
103
+ const overallRating = this.calculateAverage(ratings);
104
+
105
+ // Generate a unique ID for the main review
106
+ const reviewId = this.generateId();
107
+
108
+ // Add IDs to each review component
109
+ if (data.clinicReview) {
110
+ data.clinicReview.id = this.generateId();
111
+ data.clinicReview.fullReviewId = reviewId;
112
+ }
113
+
114
+ if (data.practitionerReview) {
115
+ data.practitionerReview.id = this.generateId();
116
+ data.practitionerReview.fullReviewId = reviewId;
117
+ }
118
+
119
+ if (data.procedureReview) {
120
+ data.procedureReview.id = this.generateId();
121
+ data.procedureReview.fullReviewId = reviewId;
122
+ }
123
+
124
+ // Create the review object with timestamps
125
+ const now = new Date();
126
+ const review: Review = {
127
+ id: reviewId,
128
+ appointmentId,
129
+ patientId: data.patientId,
130
+ clinicReview: data.clinicReview,
131
+ practitionerReview: data.practitionerReview,
132
+ procedureReview: data.procedureReview,
133
+ overallComment: data.overallComment,
134
+ overallRating,
135
+ createdAt: now,
136
+ updatedAt: now,
137
+ };
138
+
139
+ // Validate complete review object
140
+ reviewSchema.parse(review);
141
+
142
+ // Save the review to Firestore
143
+ const docRef = doc(this.db, REVIEWS_COLLECTION, reviewId);
144
+ await setDoc(docRef, {
145
+ ...review,
146
+ createdAt: serverTimestamp(),
147
+ updatedAt: serverTimestamp(),
148
+ });
149
+
150
+ // Update related entities
151
+ const updatePromises: Promise<any>[] = [];
152
+
153
+ // Update clinic if clinic review exists
154
+ if (data.clinicReview) {
155
+ updatePromises.push(
156
+ this.updateClinicReviewInfo(data.clinicReview.clinicId)
157
+ );
158
+ }
159
+
160
+ // Update practitioner if practitioner review exists
161
+ if (data.practitionerReview) {
162
+ updatePromises.push(
163
+ this.updatePractitionerReviewInfo(
164
+ data.practitionerReview.practitionerId
165
+ )
166
+ );
167
+ }
168
+
169
+ // Update procedure if procedure review exists
170
+ if (data.procedureReview) {
171
+ updatePromises.push(
172
+ this.updateProcedureReviewInfo(data.procedureReview.procedureId)
173
+ );
174
+ }
175
+
176
+ // Wait for all updates to complete
177
+ await Promise.all(updatePromises);
178
+
179
+ return review;
180
+ } catch (error) {
181
+ if (error instanceof z.ZodError) {
182
+ throw new Error(`Invalid review data: ${error.message}`);
183
+ }
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Gets a review by ID
190
+ * @param reviewId The ID of the review to get
191
+ * @returns The review if found, null otherwise
192
+ */
193
+ async getReview(reviewId: string): Promise<Review | null> {
194
+ const docRef = doc(this.db, REVIEWS_COLLECTION, reviewId);
195
+ const docSnap = await getDoc(docRef);
196
+
197
+ if (!docSnap.exists()) {
198
+ return null;
199
+ }
200
+
201
+ return docSnap.data() as Review;
202
+ }
203
+
204
+ /**
205
+ * Gets all reviews for a specific patient
206
+ * @param patientId The ID of the patient
207
+ * @returns Array of reviews for the patient
208
+ */
209
+ async getReviewsByPatient(patientId: string): Promise<Review[]> {
210
+ const q = query(
211
+ collection(this.db, REVIEWS_COLLECTION),
212
+ where("patientId", "==", patientId)
213
+ );
214
+ const snapshot = await getDocs(q);
215
+ return snapshot.docs.map((doc) => doc.data() as Review);
216
+ }
217
+
218
+ /**
219
+ * Gets all reviews for a specific clinic
220
+ * @param clinicId The ID of the clinic
221
+ * @returns Array of reviews containing clinic reviews
222
+ */
223
+ async getReviewsByClinic(clinicId: string): Promise<Review[]> {
224
+ const q = query(
225
+ collection(this.db, REVIEWS_COLLECTION),
226
+ where("clinicReview.clinicId", "==", clinicId)
227
+ );
228
+ const snapshot = await getDocs(q);
229
+ return snapshot.docs.map((doc) => doc.data() as Review);
230
+ }
231
+
232
+ /**
233
+ * Gets all reviews for a specific practitioner
234
+ * @param practitionerId The ID of the practitioner
235
+ * @returns Array of reviews containing practitioner reviews
236
+ */
237
+ async getReviewsByPractitioner(practitionerId: string): Promise<Review[]> {
238
+ const q = query(
239
+ collection(this.db, REVIEWS_COLLECTION),
240
+ where("practitionerReview.practitionerId", "==", practitionerId)
241
+ );
242
+ const snapshot = await getDocs(q);
243
+ return snapshot.docs.map((doc) => doc.data() as Review);
244
+ }
245
+
246
+ /**
247
+ * Gets all reviews for a specific procedure
248
+ * @param procedureId The ID of the procedure
249
+ * @returns Array of reviews containing procedure reviews
250
+ */
251
+ async getReviewsByProcedure(procedureId: string): Promise<Review[]> {
252
+ const q = query(
253
+ collection(this.db, REVIEWS_COLLECTION),
254
+ where("procedureReview.procedureId", "==", procedureId)
255
+ );
256
+ const snapshot = await getDocs(q);
257
+ return snapshot.docs.map((doc) => doc.data() as Review);
258
+ }
259
+
260
+ /**
261
+ * Gets all reviews for a specific appointment
262
+ * @param appointmentId The ID of the appointment
263
+ * @returns The review for the appointment if found, null otherwise
264
+ */
265
+ async getReviewByAppointment(appointmentId: string): Promise<Review | null> {
266
+ const q = query(
267
+ collection(this.db, REVIEWS_COLLECTION),
268
+ where("appointmentId", "==", appointmentId)
269
+ );
270
+ const snapshot = await getDocs(q);
271
+
272
+ if (snapshot.empty) {
273
+ return null;
274
+ }
275
+
276
+ return snapshot.docs[0].data() as Review;
277
+ }
278
+
279
+ /**
280
+ * Deletes a review and updates related entities
281
+ * @param reviewId The ID of the review to delete
282
+ */
283
+ async deleteReview(reviewId: string): Promise<void> {
284
+ const review = await this.getReview(reviewId);
285
+ if (!review) {
286
+ throw new Error(`Review with ID ${reviewId} not found`);
287
+ }
288
+
289
+ // Delete the review
290
+ await deleteDoc(doc(this.db, REVIEWS_COLLECTION, reviewId));
291
+
292
+ // Update related entities after deletion
293
+ const updatePromises: Promise<any>[] = [];
294
+
295
+ if (review.clinicReview) {
296
+ updatePromises.push(
297
+ this.updateClinicReviewInfo(
298
+ review.clinicReview.clinicId,
299
+ review.clinicReview,
300
+ true
301
+ )
302
+ );
303
+ }
304
+
305
+ if (review.practitionerReview) {
306
+ updatePromises.push(
307
+ this.updatePractitionerReviewInfo(
308
+ review.practitionerReview.practitionerId,
309
+ review.practitionerReview,
310
+ true
311
+ )
312
+ );
313
+ }
314
+
315
+ if (review.procedureReview) {
316
+ updatePromises.push(
317
+ this.updateProcedureReviewInfo(
318
+ review.procedureReview.procedureId,
319
+ review.procedureReview,
320
+ true
321
+ )
322
+ );
323
+ }
324
+
325
+ // Wait for all updates to complete
326
+ await Promise.all(updatePromises);
327
+ }
328
+
329
+ /**
330
+ * Updates the review info for a clinic
331
+ * @param clinicId The ID of the clinic to update
332
+ * @param newReview Optional new review being added or removed
333
+ * @param isRemoval Whether this update is for a review removal
334
+ * @returns The updated clinic review info
335
+ */
336
+ async updateClinicReviewInfo(
337
+ clinicId: string,
338
+ newReview?: ClinicReview,
339
+ isRemoval: boolean = false
340
+ ): Promise<ClinicReviewInfo> {
341
+ // Get the current clinic document
342
+ const clinicDoc = await getDoc(doc(this.db, CLINICS_COLLECTION, clinicId));
343
+
344
+ if (!clinicDoc.exists()) {
345
+ throw new Error(`Clinic with ID ${clinicId} not found`);
346
+ }
347
+
348
+ const clinicData = clinicDoc.data();
349
+ const currentReviewInfo = (clinicData.reviewInfo as ClinicReviewInfo) || {
350
+ totalReviews: 0,
351
+ averageRating: 0,
352
+ cleanliness: 0,
353
+ facilities: 0,
354
+ staffFriendliness: 0,
355
+ waitingTime: 0,
356
+ accessibility: 0,
357
+ recommendationPercentage: 0,
358
+ };
359
+
360
+ // If we have no reviews and aren't adding a new one, return default values
361
+ if (currentReviewInfo.totalReviews === 0 && !newReview) {
362
+ await updateDoc(doc(this.db, CLINICS_COLLECTION, clinicId), {
363
+ reviewInfo: currentReviewInfo,
364
+ updatedAt: serverTimestamp(),
365
+ });
366
+ return currentReviewInfo;
367
+ }
368
+
369
+ let updatedReviewInfo: ClinicReviewInfo;
370
+
371
+ if (newReview) {
372
+ // Calculate new values based on existing averages and the new review
373
+ const oldTotal = currentReviewInfo.totalReviews;
374
+ const newTotal = isRemoval ? oldTotal - 1 : oldTotal + 1;
375
+
376
+ // If removing the last review, set all values to 0
377
+ if (newTotal === 0) {
378
+ updatedReviewInfo = {
379
+ totalReviews: 0,
380
+ averageRating: 0,
381
+ cleanliness: 0,
382
+ facilities: 0,
383
+ staffFriendliness: 0,
384
+ waitingTime: 0,
385
+ accessibility: 0,
386
+ recommendationPercentage: 0,
387
+ };
388
+ } else {
389
+ // Calculate new averages
390
+ const updateAverage = (
391
+ currentAvg: number,
392
+ newValue: number
393
+ ): number => {
394
+ const currentSum = currentAvg * oldTotal;
395
+ const newSum = isRemoval
396
+ ? currentSum - newValue
397
+ : currentSum + newValue;
398
+ const newAvg = newSum / newTotal;
399
+ return Math.round(newAvg * 10) / 10; // Round to 1 decimal
400
+ };
401
+
402
+ // Update recommendation percentage
403
+ const currentRecommendations =
404
+ (currentReviewInfo.recommendationPercentage / 100) * oldTotal;
405
+ const newRecommendations = isRemoval
406
+ ? newReview.wouldRecommend
407
+ ? currentRecommendations - 1
408
+ : currentRecommendations
409
+ : newReview.wouldRecommend
410
+ ? currentRecommendations + 1
411
+ : currentRecommendations;
412
+ const newRecommendationPercentage =
413
+ (newRecommendations / newTotal) * 100;
414
+
415
+ updatedReviewInfo = {
416
+ totalReviews: newTotal,
417
+ averageRating: updateAverage(
418
+ currentReviewInfo.averageRating,
419
+ newReview.overallRating
420
+ ),
421
+ cleanliness: updateAverage(
422
+ currentReviewInfo.cleanliness,
423
+ newReview.cleanliness
424
+ ),
425
+ facilities: updateAverage(
426
+ currentReviewInfo.facilities,
427
+ newReview.facilities
428
+ ),
429
+ staffFriendliness: updateAverage(
430
+ currentReviewInfo.staffFriendliness,
431
+ newReview.staffFriendliness
432
+ ),
433
+ waitingTime: updateAverage(
434
+ currentReviewInfo.waitingTime,
435
+ newReview.waitingTime
436
+ ),
437
+ accessibility: updateAverage(
438
+ currentReviewInfo.accessibility,
439
+ newReview.accessibility
440
+ ),
441
+ recommendationPercentage:
442
+ Math.round(newRecommendationPercentage * 10) / 10,
443
+ };
444
+ }
445
+ } else {
446
+ // If no new review provided, keep the current info
447
+ updatedReviewInfo = { ...currentReviewInfo };
448
+ }
449
+
450
+ // Update the clinic with the new review info
451
+ await updateDoc(doc(this.db, CLINICS_COLLECTION, clinicId), {
452
+ reviewInfo: updatedReviewInfo,
453
+ updatedAt: serverTimestamp(),
454
+ });
455
+
456
+ return updatedReviewInfo;
457
+ }
458
+
459
+ /**
460
+ * Updates the review info for a practitioner
461
+ * @param practitionerId The ID of the practitioner to update
462
+ * @param newReview Optional new review being added or removed
463
+ * @param isRemoval Whether this update is for a review removal
464
+ * @returns The updated practitioner review info
465
+ */
466
+ async updatePractitionerReviewInfo(
467
+ practitionerId: string,
468
+ newReview?: PractitionerReview,
469
+ isRemoval: boolean = false
470
+ ): Promise<PractitionerReviewInfo> {
471
+ // Get the current practitioner document
472
+ const practitionerDoc = await getDoc(
473
+ doc(this.db, PRACTITIONERS_COLLECTION, practitionerId)
474
+ );
475
+
476
+ if (!practitionerDoc.exists()) {
477
+ throw new Error(`Practitioner with ID ${practitionerId} not found`);
478
+ }
479
+
480
+ const practitionerData = practitionerDoc.data();
481
+ const currentReviewInfo =
482
+ (practitionerData.reviewInfo as PractitionerReviewInfo) || {
483
+ totalReviews: 0,
484
+ averageRating: 0,
485
+ knowledgeAndExpertise: 0,
486
+ communicationSkills: 0,
487
+ bedSideManner: 0,
488
+ thoroughness: 0,
489
+ trustworthiness: 0,
490
+ recommendationPercentage: 0,
491
+ };
492
+
493
+ // If we have no reviews and aren't adding a new one, return default values
494
+ if (currentReviewInfo.totalReviews === 0 && !newReview) {
495
+ await updateDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId), {
496
+ reviewInfo: currentReviewInfo,
497
+ updatedAt: serverTimestamp(),
498
+ });
499
+ return currentReviewInfo;
500
+ }
501
+
502
+ let updatedReviewInfo: PractitionerReviewInfo;
503
+
504
+ if (newReview) {
505
+ // Calculate new values based on existing averages and the new review
506
+ const oldTotal = currentReviewInfo.totalReviews;
507
+ const newTotal = isRemoval ? oldTotal - 1 : oldTotal + 1;
508
+
509
+ // If removing the last review, set all values to 0
510
+ if (newTotal === 0) {
511
+ updatedReviewInfo = {
512
+ totalReviews: 0,
513
+ averageRating: 0,
514
+ knowledgeAndExpertise: 0,
515
+ communicationSkills: 0,
516
+ bedSideManner: 0,
517
+ thoroughness: 0,
518
+ trustworthiness: 0,
519
+ recommendationPercentage: 0,
520
+ };
521
+ } else {
522
+ // Calculate new averages
523
+ const updateAverage = (
524
+ currentAvg: number,
525
+ newValue: number
526
+ ): number => {
527
+ const currentSum = currentAvg * oldTotal;
528
+ const newSum = isRemoval
529
+ ? currentSum - newValue
530
+ : currentSum + newValue;
531
+ const newAvg = newSum / newTotal;
532
+ return Math.round(newAvg * 10) / 10; // Round to 1 decimal
533
+ };
534
+
535
+ // Update recommendation percentage
536
+ const currentRecommendations =
537
+ (currentReviewInfo.recommendationPercentage / 100) * oldTotal;
538
+ const newRecommendations = isRemoval
539
+ ? newReview.wouldRecommend
540
+ ? currentRecommendations - 1
541
+ : currentRecommendations
542
+ : newReview.wouldRecommend
543
+ ? currentRecommendations + 1
544
+ : currentRecommendations;
545
+ const newRecommendationPercentage =
546
+ (newRecommendations / newTotal) * 100;
547
+
548
+ updatedReviewInfo = {
549
+ totalReviews: newTotal,
550
+ averageRating: updateAverage(
551
+ currentReviewInfo.averageRating,
552
+ newReview.overallRating
553
+ ),
554
+ knowledgeAndExpertise: updateAverage(
555
+ currentReviewInfo.knowledgeAndExpertise,
556
+ newReview.knowledgeAndExpertise
557
+ ),
558
+ communicationSkills: updateAverage(
559
+ currentReviewInfo.communicationSkills,
560
+ newReview.communicationSkills
561
+ ),
562
+ bedSideManner: updateAverage(
563
+ currentReviewInfo.bedSideManner,
564
+ newReview.bedSideManner
565
+ ),
566
+ thoroughness: updateAverage(
567
+ currentReviewInfo.thoroughness,
568
+ newReview.thoroughness
569
+ ),
570
+ trustworthiness: updateAverage(
571
+ currentReviewInfo.trustworthiness,
572
+ newReview.trustworthiness
573
+ ),
574
+ recommendationPercentage:
575
+ Math.round(newRecommendationPercentage * 10) / 10,
576
+ };
577
+ }
578
+ } else {
579
+ // If no new review provided, keep the current info
580
+ updatedReviewInfo = { ...currentReviewInfo };
581
+ }
582
+
583
+ // Update the practitioner with the new review info
584
+ await updateDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId), {
585
+ reviewInfo: updatedReviewInfo,
586
+ updatedAt: serverTimestamp(),
587
+ });
588
+
589
+ // Also update doctor info in procedures with the new rating
590
+ await this.updateDoctorInfoInProcedures(
591
+ practitionerId,
592
+ updatedReviewInfo.averageRating
593
+ );
594
+
595
+ return updatedReviewInfo;
596
+ }
597
+
598
+ /**
599
+ * Updates the review info for a procedure
600
+ * @param procedureId The ID of the procedure to update
601
+ * @param newReview Optional new review being added or removed
602
+ * @param isRemoval Whether this update is for a review removal
603
+ * @returns The updated procedure review info
604
+ */
605
+ async updateProcedureReviewInfo(
606
+ procedureId: string,
607
+ newReview?: ProcedureReview,
608
+ isRemoval: boolean = false
609
+ ): Promise<ProcedureReviewInfo> {
610
+ // Get the current procedure document
611
+ const procedureDoc = await getDoc(
612
+ doc(this.db, PROCEDURES_COLLECTION, procedureId)
613
+ );
614
+
615
+ if (!procedureDoc.exists()) {
616
+ throw new Error(`Procedure with ID ${procedureId} not found`);
617
+ }
618
+
619
+ const procedureData = procedureDoc.data();
620
+ const currentReviewInfo =
621
+ (procedureData.reviewInfo as ProcedureReviewInfo) || {
622
+ totalReviews: 0,
623
+ averageRating: 0,
624
+ effectivenessOfTreatment: 0,
625
+ outcomeExplanation: 0,
626
+ painManagement: 0,
627
+ followUpCare: 0,
628
+ valueForMoney: 0,
629
+ recommendationPercentage: 0,
630
+ };
631
+
632
+ // If we have no reviews and aren't adding a new one, return default values
633
+ if (currentReviewInfo.totalReviews === 0 && !newReview) {
634
+ await updateDoc(doc(this.db, PROCEDURES_COLLECTION, procedureId), {
635
+ reviewInfo: currentReviewInfo,
636
+ updatedAt: serverTimestamp(),
637
+ });
638
+ return currentReviewInfo;
639
+ }
640
+
641
+ let updatedReviewInfo: ProcedureReviewInfo;
642
+
643
+ if (newReview) {
644
+ // Calculate new values based on existing averages and the new review
645
+ const oldTotal = currentReviewInfo.totalReviews;
646
+ const newTotal = isRemoval ? oldTotal - 1 : oldTotal + 1;
647
+
648
+ // If removing the last review, set all values to 0
649
+ if (newTotal === 0) {
650
+ updatedReviewInfo = {
651
+ totalReviews: 0,
652
+ averageRating: 0,
653
+ effectivenessOfTreatment: 0,
654
+ outcomeExplanation: 0,
655
+ painManagement: 0,
656
+ followUpCare: 0,
657
+ valueForMoney: 0,
658
+ recommendationPercentage: 0,
659
+ };
660
+ } else {
661
+ // Calculate new averages
662
+ const updateAverage = (
663
+ currentAvg: number,
664
+ newValue: number
665
+ ): number => {
666
+ const currentSum = currentAvg * oldTotal;
667
+ const newSum = isRemoval
668
+ ? currentSum - newValue
669
+ : currentSum + newValue;
670
+ const newAvg = newSum / newTotal;
671
+ return Math.round(newAvg * 10) / 10; // Round to 1 decimal
672
+ };
673
+
674
+ // Update recommendation percentage
675
+ const currentRecommendations =
676
+ (currentReviewInfo.recommendationPercentage / 100) * oldTotal;
677
+ const newRecommendations = isRemoval
678
+ ? newReview.wouldRecommend
679
+ ? currentRecommendations - 1
680
+ : currentRecommendations
681
+ : newReview.wouldRecommend
682
+ ? currentRecommendations + 1
683
+ : currentRecommendations;
684
+ const newRecommendationPercentage =
685
+ (newRecommendations / newTotal) * 100;
686
+
687
+ updatedReviewInfo = {
688
+ totalReviews: newTotal,
689
+ averageRating: updateAverage(
690
+ currentReviewInfo.averageRating,
691
+ newReview.overallRating
692
+ ),
693
+ effectivenessOfTreatment: updateAverage(
694
+ currentReviewInfo.effectivenessOfTreatment,
695
+ newReview.effectivenessOfTreatment
696
+ ),
697
+ outcomeExplanation: updateAverage(
698
+ currentReviewInfo.outcomeExplanation,
699
+ newReview.outcomeExplanation
700
+ ),
701
+ painManagement: updateAverage(
702
+ currentReviewInfo.painManagement,
703
+ newReview.painManagement
704
+ ),
705
+ followUpCare: updateAverage(
706
+ currentReviewInfo.followUpCare,
707
+ newReview.followUpCare
708
+ ),
709
+ valueForMoney: updateAverage(
710
+ currentReviewInfo.valueForMoney,
711
+ newReview.valueForMoney
712
+ ),
713
+ recommendationPercentage:
714
+ Math.round(newRecommendationPercentage * 10) / 10,
715
+ };
716
+ }
717
+ } else {
718
+ // If no new review provided, keep the current info
719
+ updatedReviewInfo = { ...currentReviewInfo };
720
+ }
721
+
722
+ // Update the procedure with the new review info
723
+ await updateDoc(doc(this.db, PROCEDURES_COLLECTION, procedureId), {
724
+ reviewInfo: updatedReviewInfo,
725
+ updatedAt: serverTimestamp(),
726
+ });
727
+
728
+ return updatedReviewInfo;
729
+ }
730
+
731
+ /**
732
+ * Updates doctorInfo rating in all procedures for a practitioner
733
+ * @param practitionerId The ID of the practitioner
734
+ * @param rating The new rating to set
735
+ */
736
+ private async updateDoctorInfoInProcedures(
737
+ practitionerId: string,
738
+ rating: number
739
+ ): Promise<void> {
740
+ // Find all procedures for this practitioner
741
+ const q = query(
742
+ collection(this.db, PROCEDURES_COLLECTION),
743
+ where("practitionerId", "==", practitionerId)
744
+ );
745
+
746
+ const snapshot = await getDocs(q);
747
+
748
+ if (snapshot.empty) {
749
+ return;
750
+ }
751
+
752
+ // Batch update all procedures
753
+ const batch = writeBatch(this.db);
754
+
755
+ snapshot.docs.forEach((docSnapshot) => {
756
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, docSnapshot.id);
757
+ batch.update(procedureRef, {
758
+ "doctorInfo.rating": rating,
759
+ updatedAt: serverTimestamp(),
760
+ });
761
+ });
762
+
763
+ await batch.commit();
764
+ }
765
+
766
+ /**
767
+ * Verifies a review as checked by admin/staff
768
+ * @param reviewId The ID of the review to verify
769
+ */
770
+ async verifyReview(reviewId: string): Promise<void> {
771
+ const review = await this.getReview(reviewId);
772
+ if (!review) {
773
+ throw new Error(`Review with ID ${reviewId} not found`);
774
+ }
775
+
776
+ const batch = writeBatch(this.db);
777
+
778
+ // Update main review document
779
+ batch.update(doc(this.db, REVIEWS_COLLECTION, reviewId), {
780
+ updatedAt: serverTimestamp(),
781
+ });
782
+
783
+ // Update clinic review if it exists
784
+ if (review.clinicReview) {
785
+ review.clinicReview.isVerified = true;
786
+ }
787
+
788
+ // Update practitioner review if it exists
789
+ if (review.practitionerReview) {
790
+ review.practitionerReview.isVerified = true;
791
+ }
792
+
793
+ // Update procedure review if it exists
794
+ if (review.procedureReview) {
795
+ review.procedureReview.isVerified = true;
796
+ }
797
+
798
+ await batch.commit();
799
+
800
+ // Update all related entities
801
+ const updatePromises: Promise<any>[] = [];
802
+
803
+ if (review.clinicReview) {
804
+ updatePromises.push(
805
+ this.updateClinicReviewInfo(review.clinicReview.clinicId)
806
+ );
807
+ }
808
+
809
+ if (review.practitionerReview) {
810
+ updatePromises.push(
811
+ this.updatePractitionerReviewInfo(
812
+ review.practitionerReview.practitionerId
813
+ )
814
+ );
815
+ }
816
+
817
+ if (review.procedureReview) {
818
+ updatePromises.push(
819
+ this.updateProcedureReviewInfo(review.procedureReview.procedureId)
820
+ );
821
+ }
822
+
823
+ await Promise.all(updatePromises);
824
+ }
825
+
826
+ /**
827
+ * Calculates the average of an array of numbers
828
+ * @param numbers Array of numbers to average
829
+ * @returns The average, or 0 if the array is empty
830
+ */
831
+ private calculateAverage(numbers: number[]): number {
832
+ if (numbers.length === 0) {
833
+ return 0;
834
+ }
835
+
836
+ const sum = numbers.reduce((a, b) => a + b, 0);
837
+ const avg = sum / numbers.length;
838
+
839
+ // Round to 1 decimal place
840
+ return Math.round(avg * 10) / 10;
841
+ }
842
+ }