@blackcode_sa/metaestetics-api 1.13.5 → 1.13.6

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 (291) hide show
  1. package/dist/admin/index.d.mts +20 -1
  2. package/dist/admin/index.d.ts +20 -1
  3. package/dist/admin/index.js +217 -1
  4. package/dist/admin/index.mjs +217 -1
  5. package/package.json +121 -121
  6. package/src/__mocks__/firstore.ts +10 -10
  7. package/src/admin/aggregation/README.md +79 -79
  8. package/src/admin/aggregation/appointment/README.md +128 -128
  9. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
  10. package/src/admin/aggregation/appointment/index.ts +1 -1
  11. package/src/admin/aggregation/clinic/README.md +52 -52
  12. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +966 -703
  13. package/src/admin/aggregation/clinic/index.ts +1 -1
  14. package/src/admin/aggregation/forms/README.md +13 -13
  15. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  16. package/src/admin/aggregation/forms/index.ts +1 -1
  17. package/src/admin/aggregation/index.ts +8 -8
  18. package/src/admin/aggregation/patient/README.md +27 -27
  19. package/src/admin/aggregation/patient/index.ts +1 -1
  20. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  21. package/src/admin/aggregation/practitioner/README.md +42 -42
  22. package/src/admin/aggregation/practitioner/index.ts +1 -1
  23. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  24. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  25. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  26. package/src/admin/aggregation/procedure/README.md +43 -43
  27. package/src/admin/aggregation/procedure/index.ts +1 -1
  28. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  29. package/src/admin/aggregation/reviews/index.ts +1 -1
  30. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
  31. package/src/admin/analytics/analytics.admin.service.ts +278 -278
  32. package/src/admin/analytics/index.ts +2 -2
  33. package/src/admin/booking/README.md +125 -125
  34. package/src/admin/booking/booking.admin.ts +1037 -1037
  35. package/src/admin/booking/booking.calculator.ts +712 -712
  36. package/src/admin/booking/booking.types.ts +59 -59
  37. package/src/admin/booking/index.ts +3 -3
  38. package/src/admin/booking/timezones-problem.md +185 -185
  39. package/src/admin/calendar/README.md +7 -7
  40. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  41. package/src/admin/calendar/index.ts +1 -1
  42. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  43. package/src/admin/documentation-templates/index.ts +1 -1
  44. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  45. package/src/admin/free-consultation/index.ts +1 -1
  46. package/src/admin/index.ts +81 -81
  47. package/src/admin/logger/index.ts +78 -78
  48. package/src/admin/mailing/README.md +95 -95
  49. package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
  50. package/src/admin/mailing/appointment/index.ts +1 -1
  51. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  52. package/src/admin/mailing/base.mailing.service.ts +208 -208
  53. package/src/admin/mailing/index.ts +3 -3
  54. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  55. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  56. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  57. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  58. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  59. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  60. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  61. package/src/admin/notifications/index.ts +1 -1
  62. package/src/admin/notifications/notifications.admin.ts +710 -710
  63. package/src/admin/requirements/README.md +128 -128
  64. package/src/admin/requirements/index.ts +1 -1
  65. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  66. package/src/admin/users/index.ts +1 -1
  67. package/src/admin/users/user-profile.admin.ts +405 -405
  68. package/src/backoffice/constants/certification.constants.ts +13 -13
  69. package/src/backoffice/constants/index.ts +1 -1
  70. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  71. package/src/backoffice/errors/index.ts +1 -1
  72. package/src/backoffice/expo-safe/README.md +26 -26
  73. package/src/backoffice/expo-safe/index.ts +41 -41
  74. package/src/backoffice/index.ts +5 -5
  75. package/src/backoffice/services/FIXES_README.md +102 -102
  76. package/src/backoffice/services/README.md +57 -57
  77. package/src/backoffice/services/analytics.service.proposal.md +863 -863
  78. package/src/backoffice/services/analytics.service.summary.md +143 -143
  79. package/src/backoffice/services/brand.service.ts +256 -256
  80. package/src/backoffice/services/category.service.ts +384 -384
  81. package/src/backoffice/services/constants.service.ts +385 -385
  82. package/src/backoffice/services/documentation-template.service.ts +202 -202
  83. package/src/backoffice/services/index.ts +10 -10
  84. package/src/backoffice/services/migrate-products.ts +116 -116
  85. package/src/backoffice/services/product.service.ts +553 -553
  86. package/src/backoffice/services/requirement.service.ts +235 -235
  87. package/src/backoffice/services/subcategory.service.ts +461 -461
  88. package/src/backoffice/services/technology.service.ts +1151 -1151
  89. package/src/backoffice/types/README.md +12 -12
  90. package/src/backoffice/types/admin-constants.types.ts +69 -69
  91. package/src/backoffice/types/brand.types.ts +29 -29
  92. package/src/backoffice/types/category.types.ts +67 -67
  93. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  94. package/src/backoffice/types/index.ts +10 -10
  95. package/src/backoffice/types/procedure-product.types.ts +38 -38
  96. package/src/backoffice/types/product.types.ts +240 -240
  97. package/src/backoffice/types/requirement.types.ts +63 -63
  98. package/src/backoffice/types/static/README.md +18 -18
  99. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  100. package/src/backoffice/types/static/certification.types.ts +37 -37
  101. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  102. package/src/backoffice/types/static/index.ts +6 -6
  103. package/src/backoffice/types/static/pricing.types.ts +16 -16
  104. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  105. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  106. package/src/backoffice/types/subcategory.types.ts +34 -34
  107. package/src/backoffice/types/technology.types.ts +168 -168
  108. package/src/backoffice/validations/index.ts +1 -1
  109. package/src/backoffice/validations/schemas.ts +164 -164
  110. package/src/config/__mocks__/firebase.ts +99 -99
  111. package/src/config/firebase.ts +78 -78
  112. package/src/config/index.ts +9 -9
  113. package/src/errors/auth.error.ts +6 -6
  114. package/src/errors/auth.errors.ts +200 -200
  115. package/src/errors/clinic.errors.ts +32 -32
  116. package/src/errors/firebase.errors.ts +47 -47
  117. package/src/errors/user.errors.ts +99 -99
  118. package/src/index.backup.ts +407 -407
  119. package/src/index.ts +6 -6
  120. package/src/locales/en.ts +31 -31
  121. package/src/recommender/admin/index.ts +1 -1
  122. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  123. package/src/recommender/front/index.ts +1 -1
  124. package/src/recommender/front/services/onboarding.service.ts +5 -5
  125. package/src/recommender/front/services/recommender.service.ts +3 -3
  126. package/src/recommender/index.ts +1 -1
  127. package/src/services/PATIENTAUTH.MD +197 -197
  128. package/src/services/README.md +106 -106
  129. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  130. package/src/services/__tests__/auth/auth.setup.ts +293 -293
  131. package/src/services/__tests__/auth.service.test.ts +346 -346
  132. package/src/services/__tests__/base.service.test.ts +77 -77
  133. package/src/services/__tests__/user.service.test.ts +528 -528
  134. package/src/services/analytics/ARCHITECTURE.md +199 -199
  135. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
  136. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
  137. package/src/services/analytics/QUICK_START.md +393 -393
  138. package/src/services/analytics/README.md +304 -304
  139. package/src/services/analytics/SUMMARY.md +141 -141
  140. package/src/services/analytics/TRENDS.md +380 -380
  141. package/src/services/analytics/USAGE_GUIDE.md +518 -518
  142. package/src/services/analytics/analytics-cloud.service.ts +222 -222
  143. package/src/services/analytics/analytics.service.ts +2142 -2142
  144. package/src/services/analytics/index.ts +4 -4
  145. package/src/services/analytics/review-analytics.service.ts +941 -941
  146. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
  147. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
  148. package/src/services/analytics/utils/grouping.utils.ts +434 -434
  149. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
  150. package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
  151. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
  152. package/src/services/appointment/README.md +17 -17
  153. package/src/services/appointment/appointment.service.ts +2558 -2558
  154. package/src/services/appointment/index.ts +1 -1
  155. package/src/services/appointment/utils/appointment.utils.ts +552 -552
  156. package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
  157. package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
  158. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  159. package/src/services/appointment/utils/zone-management.utils.ts +353 -353
  160. package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
  161. package/src/services/auth/auth.service.ts +989 -989
  162. package/src/services/auth/auth.v2.service.ts +961 -961
  163. package/src/services/auth/index.ts +7 -7
  164. package/src/services/auth/utils/error.utils.ts +90 -90
  165. package/src/services/auth/utils/firebase.utils.ts +49 -49
  166. package/src/services/auth/utils/index.ts +21 -21
  167. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  168. package/src/services/base.service.ts +41 -41
  169. package/src/services/calendar/calendar.service.ts +1077 -1077
  170. package/src/services/calendar/calendar.v2.service.ts +1683 -1683
  171. package/src/services/calendar/calendar.v3.service.ts +313 -313
  172. package/src/services/calendar/externalCalendar.service.ts +178 -178
  173. package/src/services/calendar/index.ts +5 -5
  174. package/src/services/calendar/synced-calendars.service.ts +743 -743
  175. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  176. package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
  177. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  178. package/src/services/calendar/utils/docs.utils.ts +157 -157
  179. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  180. package/src/services/calendar/utils/index.ts +8 -8
  181. package/src/services/calendar/utils/patient.utils.ts +198 -198
  182. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  183. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  184. package/src/services/clinic/README.md +204 -204
  185. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
  186. package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
  187. package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
  188. package/src/services/clinic/billing-transactions.service.ts +217 -217
  189. package/src/services/clinic/clinic-admin.service.ts +202 -202
  190. package/src/services/clinic/clinic-group.service.ts +310 -310
  191. package/src/services/clinic/clinic.service.ts +708 -708
  192. package/src/services/clinic/index.ts +5 -5
  193. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  194. package/src/services/clinic/utils/admin.utils.ts +551 -551
  195. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  196. package/src/services/clinic/utils/clinic.utils.ts +949 -949
  197. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  198. package/src/services/clinic/utils/filter.utils.ts +446 -446
  199. package/src/services/clinic/utils/index.ts +11 -11
  200. package/src/services/clinic/utils/photos.utils.ts +188 -188
  201. package/src/services/clinic/utils/search.utils.ts +84 -84
  202. package/src/services/clinic/utils/tag.utils.ts +124 -124
  203. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  204. package/src/services/documentation-templates/filled-document.service.ts +587 -587
  205. package/src/services/documentation-templates/index.ts +2 -2
  206. package/src/services/index.ts +14 -14
  207. package/src/services/media/index.ts +1 -1
  208. package/src/services/media/media.service.ts +418 -418
  209. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  210. package/src/services/notifications/index.ts +1 -1
  211. package/src/services/notifications/notification.service.ts +215 -215
  212. package/src/services/patient/README.md +48 -48
  213. package/src/services/patient/To-Do.md +43 -43
  214. package/src/services/patient/__tests__/patient.service.test.ts +294 -294
  215. package/src/services/patient/index.ts +2 -2
  216. package/src/services/patient/patient.service.ts +883 -883
  217. package/src/services/patient/patientRequirements.service.ts +285 -285
  218. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  219. package/src/services/patient/utils/clinic.utils.ts +80 -80
  220. package/src/services/patient/utils/docs.utils.ts +142 -142
  221. package/src/services/patient/utils/index.ts +9 -9
  222. package/src/services/patient/utils/location.utils.ts +126 -126
  223. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  224. package/src/services/patient/utils/medical.utils.ts +458 -458
  225. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  226. package/src/services/patient/utils/profile.utils.ts +510 -510
  227. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  228. package/src/services/patient/utils/token.utils.ts +211 -211
  229. package/src/services/practitioner/README.md +145 -145
  230. package/src/services/practitioner/index.ts +1 -1
  231. package/src/services/practitioner/practitioner.service.ts +1742 -1742
  232. package/src/services/procedure/README.md +163 -163
  233. package/src/services/procedure/index.ts +1 -1
  234. package/src/services/procedure/procedure.service.ts +2200 -2200
  235. package/src/services/reviews/index.ts +1 -1
  236. package/src/services/reviews/reviews.service.ts +734 -734
  237. package/src/services/user/index.ts +1 -1
  238. package/src/services/user/user.service.ts +489 -489
  239. package/src/services/user/user.v2.service.ts +466 -466
  240. package/src/types/analytics/analytics.types.ts +597 -597
  241. package/src/types/analytics/grouped-analytics.types.ts +173 -173
  242. package/src/types/analytics/index.ts +4 -4
  243. package/src/types/analytics/stored-analytics.types.ts +137 -137
  244. package/src/types/appointment/index.ts +480 -480
  245. package/src/types/calendar/index.ts +258 -258
  246. package/src/types/calendar/synced-calendar.types.ts +66 -66
  247. package/src/types/clinic/index.ts +498 -498
  248. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  249. package/src/types/clinic/preferences.types.ts +159 -159
  250. package/src/types/clinic/to-do +3 -3
  251. package/src/types/documentation-templates/index.ts +308 -308
  252. package/src/types/index.ts +47 -47
  253. package/src/types/notifications/README.md +77 -77
  254. package/src/types/notifications/index.ts +286 -286
  255. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  256. package/src/types/patient/allergies.ts +58 -58
  257. package/src/types/patient/index.ts +275 -275
  258. package/src/types/patient/medical-info.types.ts +152 -152
  259. package/src/types/patient/patient-requirements.ts +92 -92
  260. package/src/types/patient/token.types.ts +61 -61
  261. package/src/types/practitioner/index.ts +206 -206
  262. package/src/types/procedure/index.ts +181 -181
  263. package/src/types/profile/index.ts +39 -39
  264. package/src/types/reviews/index.ts +132 -132
  265. package/src/types/tz-lookup.d.ts +4 -4
  266. package/src/types/user/index.ts +38 -38
  267. package/src/utils/TIMESTAMPS.md +176 -176
  268. package/src/utils/TimestampUtils.ts +241 -241
  269. package/src/utils/index.ts +1 -1
  270. package/src/validations/appointment.schema.ts +574 -574
  271. package/src/validations/calendar.schema.ts +225 -225
  272. package/src/validations/clinic.schema.ts +494 -494
  273. package/src/validations/common.schema.ts +25 -25
  274. package/src/validations/documentation-templates/index.ts +1 -1
  275. package/src/validations/documentation-templates/template.schema.ts +220 -220
  276. package/src/validations/documentation-templates.schema.ts +10 -10
  277. package/src/validations/index.ts +20 -20
  278. package/src/validations/media.schema.ts +10 -10
  279. package/src/validations/notification.schema.ts +90 -90
  280. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  281. package/src/validations/patient/medical-info.schema.ts +125 -125
  282. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  283. package/src/validations/patient/token.schema.ts +29 -29
  284. package/src/validations/patient.schema.ts +217 -217
  285. package/src/validations/practitioner.schema.ts +222 -222
  286. package/src/validations/procedure-product.schema.ts +41 -41
  287. package/src/validations/procedure.schema.ts +124 -124
  288. package/src/validations/profile-info.schema.ts +41 -41
  289. package/src/validations/reviews.schema.ts +195 -195
  290. package/src/validations/schemas.ts +104 -104
  291. package/src/validations/shared.schema.ts +78 -78
@@ -1,941 +1,941 @@
1
- import { Firestore, collection, query, where, getDocs, getDoc, doc, Timestamp } from 'firebase/firestore';
2
- import { BaseService } from '../base.service';
3
- import { Review, PractitionerReview, ProcedureReview, REVIEWS_COLLECTION } from '../../types/reviews';
4
- import { Appointment, APPOINTMENTS_COLLECTION } from '../../types/appointment';
5
- import { AnalyticsDateRange, AnalyticsFilters, ReviewTrend, TrendPeriod } from '../../types/analytics';
6
- import { AppointmentService } from '../appointment/appointment.service';
7
- import {
8
- groupAppointmentsByPeriod,
9
- generatePeriods,
10
- getTrendChange,
11
- type TrendPeriod as TrendPeriodType,
12
- type PeriodInfo,
13
- } from './utils/trend-calculation.utils';
14
-
15
- /**
16
- * Review metrics for a specific entity (practitioner, procedure, etc.)
17
- * Full review analytics metrics with detailed breakdowns
18
- */
19
- export interface ReviewAnalyticsMetrics {
20
- entityId: string;
21
- entityName: string;
22
- entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology';
23
-
24
- // Overall metrics
25
- totalReviews: number;
26
- averageRating: number;
27
- recommendationRate: number; // % that would recommend
28
-
29
- // For Practitioner reviews
30
- practitionerMetrics?: {
31
- averageKnowledgeAndExpertise: number;
32
- averageCommunicationSkills: number;
33
- averageBedSideManner: number;
34
- averageThoroughness: number;
35
- averageTrustworthiness: number;
36
- };
37
-
38
- // For Procedure reviews
39
- procedureMetrics?: {
40
- averageEffectiveness: number;
41
- averageOutcomeExplanation: number;
42
- averagePainManagement: number;
43
- averageFollowUpCare: number;
44
- averageValueForMoney: number;
45
- };
46
-
47
- // Comparison to overall average
48
- comparisonToOverall: {
49
- ratingDifference: number; // Positive = above average, negative = below
50
- recommendationDifference: number; // % difference
51
- };
52
- }
53
-
54
- /**
55
- * Review detail with full information
56
- */
57
- export interface ReviewDetail {
58
- reviewId: string;
59
- appointmentId: string;
60
- patientId: string;
61
- patientName?: string;
62
- createdAt: Date;
63
-
64
- // Relevant sub-review based on entityType
65
- practitionerReview?: PractitionerReview;
66
- procedureReview?: ProcedureReview;
67
-
68
- // Context from appointment
69
- procedureName?: string;
70
- practitionerName?: string;
71
- appointmentDate: Date;
72
- }
73
-
74
- /**
75
- * Overall review averages for comparison
76
- */
77
- export interface OverallReviewAverages {
78
- // Overall practitioner averages
79
- practitionerAverage: {
80
- totalReviews: number;
81
- averageRating: number;
82
- recommendationRate: number;
83
- averageKnowledgeAndExpertise: number;
84
- averageCommunicationSkills: number;
85
- averageBedSideManner: number;
86
- averageThoroughness: number;
87
- averageTrustworthiness: number;
88
- };
89
-
90
- // Overall procedure averages
91
- procedureAverage: {
92
- totalReviews: number;
93
- averageRating: number;
94
- recommendationRate: number;
95
- averageEffectiveness: number;
96
- averageOutcomeExplanation: number;
97
- averagePainManagement: number;
98
- averageFollowUpCare: number;
99
- averageValueForMoney: number;
100
- };
101
- }
102
-
103
- /**
104
- * Review Analytics Service
105
- * Provides review metrics and analytics for practitioners, procedures, categories, and technologies
106
- */
107
- export class ReviewAnalyticsService extends BaseService {
108
- private appointmentService: AppointmentService;
109
-
110
- constructor(db: Firestore, auth: any, app: any, appointmentService?: AppointmentService) {
111
- super(db, auth, app);
112
- // AppointmentService is optional - will be set if provided
113
- this.appointmentService = appointmentService as AppointmentService;
114
- }
115
- /**
116
- * Fetches reviews filtered by date range and optional filters
117
- * Properly filters by clinic branch by checking appointment's clinicId
118
- */
119
- private async fetchReviews(
120
- dateRange?: AnalyticsDateRange,
121
- filters?: AnalyticsFilters
122
- ): Promise<Review[]> {
123
- let q = query(collection(this.db, REVIEWS_COLLECTION));
124
-
125
- // Apply date range filter
126
- if (dateRange) {
127
- const startTimestamp = Timestamp.fromDate(dateRange.start);
128
- const endTimestamp = Timestamp.fromDate(dateRange.end);
129
- q = query(q, where('createdAt', '>=', startTimestamp), where('createdAt', '<=', endTimestamp));
130
- }
131
-
132
- const snapshot = await getDocs(q);
133
- const reviews = snapshot.docs.map(doc => {
134
- const data = doc.data();
135
- return {
136
- ...data,
137
- id: doc.id,
138
- createdAt: data.createdAt?.toDate ? data.createdAt.toDate() : new Date(data.createdAt),
139
- updatedAt: data.updatedAt?.toDate ? data.updatedAt.toDate() : new Date(data.updatedAt),
140
- } as Review;
141
- });
142
-
143
- console.log(`[ReviewAnalytics] Fetched ${reviews.length} reviews in date range`);
144
-
145
- // Filter by clinic branch if specified
146
- if (filters?.clinicBranchId && reviews.length > 0) {
147
- // We need to fetch appointments to check which clinic they belong to
148
- // Firestore 'in' operator supports max 10 items, so we batch
149
- const appointmentIds = [...new Set(reviews.map(r => r.appointmentId))];
150
- console.log(`[ReviewAnalytics] Filtering by clinic ${filters.clinicBranchId}, checking ${appointmentIds.length} appointments`);
151
-
152
- const validAppointmentIds = new Set<string>();
153
-
154
- // Process in batches of 10
155
- for (let i = 0; i < appointmentIds.length; i += 10) {
156
- const batch = appointmentIds.slice(i, i + 10);
157
- const appointmentsQuery = query(
158
- collection(this.db, APPOINTMENTS_COLLECTION),
159
- where('id', 'in', batch)
160
- );
161
- const appointmentSnapshot = await getDocs(appointmentsQuery);
162
-
163
- appointmentSnapshot.docs.forEach(doc => {
164
- const appointment = doc.data() as Appointment;
165
- // Appointment uses 'clinicBranchId' field directly
166
- if (appointment.clinicBranchId === filters.clinicBranchId) {
167
- validAppointmentIds.add(doc.id);
168
- }
169
- });
170
- }
171
-
172
- const filteredReviews = reviews.filter(review => validAppointmentIds.has(review.appointmentId));
173
- console.log(`[ReviewAnalytics] After clinic filter: ${filteredReviews.length} reviews (from ${validAppointmentIds.size} valid appointments)`);
174
-
175
- return filteredReviews;
176
- }
177
-
178
- return reviews;
179
- }
180
-
181
- /**
182
- * Gets review metrics for a specific entity
183
- */
184
- async getReviewMetricsByEntity(
185
- entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
186
- entityId: string,
187
- dateRange?: AnalyticsDateRange,
188
- filters?: AnalyticsFilters
189
- ): Promise<ReviewAnalyticsMetrics | null> {
190
- const reviews = await this.fetchReviews(dateRange, filters);
191
-
192
- // Filter reviews based on entity type
193
- let relevantReviews: Review[] = [];
194
-
195
- if (entityType === 'practitioner') {
196
- relevantReviews = reviews.filter(r => r.practitionerReview?.practitionerId === entityId);
197
- } else if (entityType === 'procedure') {
198
- relevantReviews = reviews.filter(r => r.procedureReview?.procedureId === entityId);
199
- } else if (entityType === 'category' || entityType === 'subcategory') {
200
- // For category/subcategory, we need to get reviews for all procedures in that category
201
- // This requires fetching appointments to get procedure info
202
- // For now, we'll need to enhance this with appointment data
203
- relevantReviews = reviews; // Placeholder - will be enhanced
204
- }
205
-
206
- if (relevantReviews.length === 0) {
207
- return null;
208
- }
209
-
210
- // Calculate metrics
211
- return this.calculateReviewMetrics(relevantReviews, entityType, entityId);
212
- }
213
-
214
- /**
215
- * Gets review metrics for multiple entities (grouped)
216
- */
217
- async getReviewMetricsByEntities(
218
- entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
219
- dateRange?: AnalyticsDateRange,
220
- filters?: AnalyticsFilters
221
- ): Promise<ReviewAnalyticsMetrics[]> {
222
- const reviews = await this.fetchReviews(dateRange, filters);
223
- const entityMap = new Map<string, { reviews: Review[]; name: string }>();
224
-
225
- // For practitioner, procedure, and technology, we fetch appointments to get actual names
226
- // (Reviews have IDs stored in name fields, not actual names)
227
- let practitionerNameMap: Map<string, string> | null = null;
228
- let procedureNameMap: Map<string, string> | null = null;
229
- let procedureToTechnologyMap: Map<string, { id: string; name: string }> | null = null;
230
-
231
- if (entityType === 'practitioner' || entityType === 'procedure' || entityType === 'technology') {
232
- if (!this.appointmentService) {
233
- console.warn(`[ReviewAnalytics] AppointmentService not available for ${entityType} name resolution`);
234
- return [];
235
- }
236
-
237
- console.log(`[ReviewAnalytics] Grouping by ${entityType}, fetching appointments for name resolution...`);
238
-
239
- // Fetch all appointments to build name mapping tables
240
- const searchParams: any = {
241
- ...filters,
242
- };
243
- if (dateRange) {
244
- searchParams.startDate = dateRange.start;
245
- searchParams.endDate = dateRange.end;
246
- }
247
- const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
248
- const appointments = appointmentsResult.appointments || [];
249
-
250
- console.log(`[ReviewAnalytics] Found ${appointments.length} appointments for name resolution`);
251
-
252
- // Build all name mapping tables
253
- practitionerNameMap = new Map<string, string>();
254
- procedureNameMap = new Map<string, string>();
255
- procedureToTechnologyMap = new Map<string, { id: string; name: string }>();
256
-
257
- appointments.forEach((appointment: Appointment) => {
258
- // Map practitioner ID -> name
259
- if (appointment.practitionerId && appointment.practitionerInfo?.name) {
260
- practitionerNameMap!.set(appointment.practitionerId, appointment.practitionerInfo.name);
261
- }
262
-
263
- // Map main procedure ID -> name
264
- if (appointment.procedureId) {
265
- if (appointment.procedureInfo?.name) {
266
- procedureNameMap!.set(appointment.procedureId, appointment.procedureInfo.name);
267
- }
268
-
269
- // Map procedure -> technology
270
- const mainTechnologyId = appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
271
- const mainTechnologyName = appointment.procedureExtendedInfo?.procedureTechnologyName ||
272
- appointment.procedureInfo?.name ||
273
- 'Unknown Technology';
274
- procedureToTechnologyMap!.set(appointment.procedureId, {
275
- id: mainTechnologyId,
276
- name: mainTechnologyName,
277
- });
278
- }
279
-
280
- // Map extended procedures
281
- if (appointment.metadata?.extendedProcedures) {
282
- appointment.metadata.extendedProcedures.forEach((extendedProc) => {
283
- if (extendedProc.procedureId) {
284
- if (extendedProc.procedureName) {
285
- procedureNameMap!.set(extendedProc.procedureId, extendedProc.procedureName);
286
- }
287
-
288
- const extTechnologyId = extendedProc.procedureTechnologyId || 'unknown-technology';
289
- const extTechnologyName = extendedProc.procedureTechnologyName || 'Unknown Technology';
290
- procedureToTechnologyMap!.set(extendedProc.procedureId, {
291
- id: extTechnologyId,
292
- name: extTechnologyName,
293
- });
294
- }
295
- });
296
- }
297
- });
298
-
299
- console.log(`[ReviewAnalytics] Built name maps: ${practitionerNameMap.size} practitioners, ${procedureNameMap.size} procedures, ${procedureToTechnologyMap.size} technologies`);
300
- }
301
-
302
- // Now group reviews based on entity type
303
- if (entityType === 'technology' && procedureToTechnologyMap) {
304
- let processedReviewCount = 0;
305
-
306
- reviews.forEach(review => {
307
- // Process main procedure review
308
- if (review.procedureReview?.procedureId) {
309
- const techInfo = procedureToTechnologyMap!.get(review.procedureReview.procedureId);
310
- if (techInfo) {
311
- if (!entityMap.has(techInfo.id)) {
312
- entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
313
- }
314
- entityMap.get(techInfo.id)!.reviews.push(review);
315
- processedReviewCount++;
316
- }
317
- }
318
-
319
- // Process extended procedure reviews
320
- if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
321
- review.extendedProcedureReviews.forEach((extendedReview) => {
322
- if (extendedReview.procedureId) {
323
- const techInfo = procedureToTechnologyMap!.get(extendedReview.procedureId);
324
- if (techInfo) {
325
- if (!entityMap.has(techInfo.id)) {
326
- entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
327
- }
328
- const reviewWithExtendedOnly: Review = {
329
- ...review,
330
- procedureReview: extendedReview,
331
- extendedProcedureReviews: undefined,
332
- };
333
- entityMap.get(techInfo.id)!.reviews.push(reviewWithExtendedOnly);
334
- processedReviewCount++;
335
- }
336
- }
337
- });
338
- }
339
- });
340
-
341
- console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} technology groups`);
342
- entityMap.forEach((data, techId) => {
343
- console.log(`[ReviewAnalytics] - ${data.name} (${techId}): ${data.reviews.length} reviews`);
344
- });
345
- } else if (entityType === 'procedure' && procedureNameMap) {
346
- let processedReviewCount = 0;
347
-
348
- reviews.forEach(review => {
349
- // Process main procedure review
350
- if (review.procedureReview) {
351
- const procedureId = review.procedureReview.procedureId;
352
- // Use actual name from appointment, fallback to review name, then 'Unknown'
353
- const procedureName = (procedureId && procedureNameMap!.get(procedureId)) ||
354
- review.procedureReview.procedureName ||
355
- 'Unknown Procedure';
356
-
357
- if (procedureId) {
358
- if (!entityMap.has(procedureId)) {
359
- entityMap.set(procedureId, { reviews: [], name: procedureName });
360
- }
361
- entityMap.get(procedureId)!.reviews.push(review);
362
- processedReviewCount++;
363
- }
364
- }
365
-
366
- // Process extended procedure reviews
367
- if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
368
- review.extendedProcedureReviews.forEach((extendedReview) => {
369
- const procedureId = extendedReview.procedureId;
370
- // Use actual name from appointment, fallback to review name, then 'Unknown'
371
- const procedureName = (procedureId && procedureNameMap!.get(procedureId)) ||
372
- extendedReview.procedureName ||
373
- 'Unknown Procedure';
374
-
375
- if (procedureId) {
376
- if (!entityMap.has(procedureId)) {
377
- entityMap.set(procedureId, { reviews: [], name: procedureName });
378
- }
379
- const reviewWithExtendedOnly: Review = {
380
- ...review,
381
- procedureReview: extendedReview,
382
- extendedProcedureReviews: undefined,
383
- };
384
- entityMap.get(procedureId)!.reviews.push(reviewWithExtendedOnly);
385
- processedReviewCount++;
386
- }
387
- });
388
- }
389
- });
390
-
391
- console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} procedure groups`);
392
- entityMap.forEach((data, procId) => {
393
- console.log(`[ReviewAnalytics] - ${data.name} (${procId}): ${data.reviews.length} reviews`);
394
- });
395
- } else if (entityType === 'practitioner' && practitionerNameMap) {
396
- // Group reviews by practitioner
397
- reviews.forEach(review => {
398
- if (review.practitionerReview) {
399
- const practitionerId = review.practitionerReview.practitionerId;
400
- // Use actual name from appointment, fallback to review name, then 'Unknown'
401
- const practitionerName = (practitionerId && practitionerNameMap!.get(practitionerId)) ||
402
- review.practitionerReview.practitionerName ||
403
- 'Unknown Practitioner';
404
-
405
- if (practitionerId) {
406
- if (!entityMap.has(practitionerId)) {
407
- entityMap.set(practitionerId, { reviews: [], name: practitionerName });
408
- }
409
- entityMap.get(practitionerId)!.reviews.push(review);
410
- }
411
- }
412
- });
413
-
414
- console.log(`[ReviewAnalytics] Processed ${reviews.length} reviews into ${entityMap.size} practitioner groups`);
415
- entityMap.forEach((data, practId) => {
416
- console.log(`[ReviewAnalytics] - ${data.name} (${practId}): ${data.reviews.length} reviews`);
417
- });
418
- } else {
419
- // Handle other entity types (category, subcategory, etc.)
420
- reviews.forEach(review => {
421
- let entityId: string | undefined;
422
- let entityName: string | undefined;
423
-
424
- // TODO: Handle category/subcategory grouping
425
-
426
- if (entityId) {
427
- if (!entityMap.has(entityId)) {
428
- entityMap.set(entityId, { reviews: [], name: entityName || entityId });
429
- }
430
- entityMap.get(entityId)!.reviews.push(review);
431
- }
432
- });
433
- }
434
-
435
- // Calculate metrics for each entity
436
- const metrics: ReviewAnalyticsMetrics[] = [];
437
- for (const [entityId, data] of entityMap.entries()) {
438
- const metric = this.calculateReviewMetrics(data.reviews, entityType, entityId);
439
- if (metric) {
440
- metric.entityName = data.name; // Use the mapped name
441
- metrics.push(metric);
442
- }
443
- }
444
-
445
- return metrics;
446
- }
447
-
448
- /**
449
- * Calculates review metrics from a list of reviews
450
- */
451
- private calculateReviewMetrics(
452
- reviews: Review[],
453
- entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
454
- entityId: string
455
- ): ReviewAnalyticsMetrics | null {
456
- if (reviews.length === 0) {
457
- return null;
458
- }
459
-
460
- let totalRating = 0;
461
- let recommendationCount = 0;
462
- let practitionerMetrics: ReviewAnalyticsMetrics['practitionerMetrics'];
463
- let procedureMetrics: ReviewAnalyticsMetrics['procedureMetrics'];
464
- let entityName = entityId; // Default, will be enhanced from appointments
465
-
466
- if (entityType === 'practitioner') {
467
- const practitionerReviews = reviews.filter(r => r.practitionerReview).map(r => r.practitionerReview!);
468
-
469
- if (practitionerReviews.length === 0) {
470
- return null;
471
- }
472
-
473
- // Get entity name from first review
474
- entityName = practitionerReviews[0].practitionerName || entityId;
475
-
476
- // Calculate averages
477
- totalRating = practitionerReviews.reduce((sum, r) => sum + r.overallRating, 0);
478
- recommendationCount = practitionerReviews.filter(r => r.wouldRecommend).length;
479
-
480
- practitionerMetrics = {
481
- averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map(r => r.knowledgeAndExpertise)),
482
- averageCommunicationSkills: this.calculateAverage(practitionerReviews.map(r => r.communicationSkills)),
483
- averageBedSideManner: this.calculateAverage(practitionerReviews.map(r => r.bedSideManner)),
484
- averageThoroughness: this.calculateAverage(practitionerReviews.map(r => r.thoroughness)),
485
- averageTrustworthiness: this.calculateAverage(practitionerReviews.map(r => r.trustworthiness)),
486
- };
487
- } else if (entityType === 'procedure' || entityType === 'technology') {
488
- // Technology uses the same logic as procedure since technology reviews are procedure reviews
489
- const procedureReviews = reviews.filter(r => r.procedureReview).map(r => r.procedureReview!);
490
-
491
- if (procedureReviews.length === 0) {
492
- return null;
493
- }
494
-
495
- // Get entity name from first review (or use the name that was passed in for technology)
496
- if (entityType === 'procedure') {
497
- entityName = procedureReviews[0].procedureName || entityId;
498
- }
499
- // For technology, entityName is already set from the calling method
500
-
501
- // Calculate averages
502
- totalRating = procedureReviews.reduce((sum, r) => sum + r.overallRating, 0);
503
- recommendationCount = procedureReviews.filter(r => r.wouldRecommend).length;
504
-
505
- procedureMetrics = {
506
- averageEffectiveness: this.calculateAverage(procedureReviews.map(r => r.effectivenessOfTreatment)),
507
- averageOutcomeExplanation: this.calculateAverage(procedureReviews.map(r => r.outcomeExplanation)),
508
- averagePainManagement: this.calculateAverage(procedureReviews.map(r => r.painManagement)),
509
- averageFollowUpCare: this.calculateAverage(procedureReviews.map(r => r.followUpCare)),
510
- averageValueForMoney: this.calculateAverage(procedureReviews.map(r => r.valueForMoney)),
511
- };
512
- }
513
-
514
- const averageRating = totalRating / reviews.length;
515
- const recommendationRate = (recommendationCount / reviews.length) * 100;
516
-
517
- const result: ReviewAnalyticsMetrics = {
518
- entityId,
519
- entityName,
520
- entityType,
521
- totalReviews: reviews.length,
522
- averageRating,
523
- recommendationRate,
524
- practitionerMetrics,
525
- procedureMetrics,
526
- comparisonToOverall: {
527
- ratingDifference: 0, // Will be calculated when comparing to overall
528
- recommendationDifference: 0,
529
- },
530
- };
531
-
532
- return result;
533
- }
534
-
535
- /**
536
- * Gets overall review averages for comparison
537
- */
538
- async getOverallReviewAverages(
539
- dateRange?: AnalyticsDateRange,
540
- filters?: AnalyticsFilters
541
- ): Promise<OverallReviewAverages> {
542
- const reviews = await this.fetchReviews(dateRange, filters);
543
-
544
- const practitionerReviews = reviews
545
- .filter(r => r.practitionerReview)
546
- .map(r => r.practitionerReview!);
547
-
548
- const procedureReviews = reviews
549
- .filter(r => r.procedureReview)
550
- .map(r => r.procedureReview!);
551
-
552
- return {
553
- practitionerAverage: {
554
- totalReviews: practitionerReviews.length,
555
- averageRating: practitionerReviews.length > 0
556
- ? this.calculateAverage(practitionerReviews.map(r => r.overallRating))
557
- : 0,
558
- recommendationRate: practitionerReviews.length > 0
559
- ? (practitionerReviews.filter(r => r.wouldRecommend).length / practitionerReviews.length) * 100
560
- : 0,
561
- averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map(r => r.knowledgeAndExpertise)),
562
- averageCommunicationSkills: this.calculateAverage(practitionerReviews.map(r => r.communicationSkills)),
563
- averageBedSideManner: this.calculateAverage(practitionerReviews.map(r => r.bedSideManner)),
564
- averageThoroughness: this.calculateAverage(practitionerReviews.map(r => r.thoroughness)),
565
- averageTrustworthiness: this.calculateAverage(practitionerReviews.map(r => r.trustworthiness)),
566
- },
567
- procedureAverage: {
568
- totalReviews: procedureReviews.length,
569
- averageRating: procedureReviews.length > 0
570
- ? this.calculateAverage(procedureReviews.map(r => r.overallRating))
571
- : 0,
572
- recommendationRate: procedureReviews.length > 0
573
- ? (procedureReviews.filter(r => r.wouldRecommend).length / procedureReviews.length) * 100
574
- : 0,
575
- averageEffectiveness: this.calculateAverage(procedureReviews.map(r => r.effectivenessOfTreatment)),
576
- averageOutcomeExplanation: this.calculateAverage(procedureReviews.map(r => r.outcomeExplanation)),
577
- averagePainManagement: this.calculateAverage(procedureReviews.map(r => r.painManagement)),
578
- averageFollowUpCare: this.calculateAverage(procedureReviews.map(r => r.followUpCare)),
579
- averageValueForMoney: this.calculateAverage(procedureReviews.map(r => r.valueForMoney)),
580
- },
581
- };
582
- }
583
-
584
- /**
585
- * Gets review details for a specific entity
586
- */
587
- async getReviewDetails(
588
- entityType: 'practitioner' | 'procedure',
589
- entityId: string,
590
- dateRange?: AnalyticsDateRange,
591
- filters?: AnalyticsFilters
592
- ): Promise<ReviewDetail[]> {
593
- const reviews = await this.fetchReviews(dateRange, filters);
594
-
595
- // Filter reviews based on entity type
596
- let relevantReviews: Review[] = [];
597
-
598
- if (entityType === 'practitioner') {
599
- relevantReviews = reviews.filter(r => r.practitionerReview?.practitionerId === entityId);
600
- } else if (entityType === 'procedure') {
601
- relevantReviews = reviews.filter(r => r.procedureReview?.procedureId === entityId);
602
- }
603
-
604
- // Enhance with appointment data
605
- const details: ReviewDetail[] = [];
606
- for (const review of relevantReviews) {
607
- try {
608
- const appointmentDocRef = doc(this.db, APPOINTMENTS_COLLECTION, review.appointmentId);
609
- const appointmentDoc = await getDoc(appointmentDocRef);
610
-
611
- let appointment: Appointment | null = null;
612
- if (appointmentDoc.exists()) {
613
- appointment = appointmentDoc.data() as Appointment;
614
- }
615
-
616
- const createdAt = review.createdAt instanceof Timestamp ? review.createdAt.toDate() : new Date(review.createdAt);
617
- const appointmentDate = appointment?.appointmentStartTime
618
- ? (appointment.appointmentStartTime instanceof Timestamp
619
- ? appointment.appointmentStartTime.toDate()
620
- : appointment.appointmentStartTime)
621
- : createdAt;
622
-
623
- details.push({
624
- reviewId: review.id,
625
- appointmentId: review.appointmentId,
626
- patientId: review.patientId,
627
- patientName: review.patientName || appointment?.patientInfo?.fullName,
628
- createdAt,
629
- practitionerReview: review.practitionerReview,
630
- procedureReview: review.procedureReview,
631
- procedureName: appointment?.procedureInfo?.name,
632
- practitionerName: appointment?.practitionerInfo?.name,
633
- appointmentDate,
634
- });
635
- } catch (error) {
636
- console.warn(`Failed to enhance review ${review.id}:`, error);
637
- }
638
- }
639
-
640
- return details;
641
- }
642
-
643
- /**
644
- * Helper method to calculate average
645
- */
646
- private calculateAverage(values: number[]): number {
647
- if (values.length === 0) return 0;
648
- const sum = values.reduce((acc, val) => acc + val, 0);
649
- return sum / values.length;
650
- }
651
-
652
- /**
653
- * Calculate review trends over time
654
- * Groups reviews by period and calculates rating and recommendation metrics
655
- *
656
- * @param dateRange - Date range for trend analysis (must align with period boundaries)
657
- * @param period - Period type (week, month, quarter, year)
658
- * @param filters - Optional filters for clinic, practitioner, procedure
659
- * @param entityType - Optional entity type to group trends by (practitioner, procedure, technology)
660
- * @returns Array of review trends with percentage changes
661
- */
662
- async getReviewTrends(
663
- dateRange: AnalyticsDateRange,
664
- period: TrendPeriod,
665
- filters?: AnalyticsFilters,
666
- entityType?: 'practitioner' | 'procedure' | 'technology'
667
- ): Promise<ReviewTrend[]> {
668
- // Fetch all reviews in the date range
669
- const reviews = await this.fetchReviews(dateRange, filters);
670
-
671
- if (reviews.length === 0) {
672
- return [];
673
- }
674
-
675
- // If grouping by entity, calculate trends per entity
676
- if (entityType) {
677
- return this.getGroupedReviewTrends(reviews, dateRange, period, entityType, filters);
678
- }
679
-
680
- // Calculate overall trends
681
- const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
682
- const trends: ReviewTrend[] = [];
683
-
684
- let previousAvgRating = 0;
685
- let previousRecRate = 0;
686
-
687
- periods.forEach((periodInfo: PeriodInfo) => {
688
- // Filter reviews for this period
689
- const periodReviews = reviews.filter(review => {
690
- const reviewDate = review.createdAt instanceof Date ? review.createdAt : (review.createdAt as Timestamp).toDate();
691
- return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
692
- });
693
-
694
- if (periodReviews.length === 0) {
695
- // No reviews in this period, skip or use 0
696
- trends.push({
697
- period: periodInfo.period,
698
- startDate: periodInfo.startDate,
699
- endDate: periodInfo.endDate,
700
- averageRating: 0,
701
- recommendationRate: 0,
702
- totalReviews: 0,
703
- previousPeriod: undefined,
704
- });
705
- previousAvgRating = 0;
706
- previousRecRate = 0;
707
- return;
708
- }
709
-
710
- // Calculate weighted average rating across practitioner and procedure reviews
711
- let totalRatingSum = 0;
712
- let totalRatingCount = 0;
713
- let totalRecommendations = 0;
714
- let totalRecommendationCount = 0;
715
-
716
- periodReviews.forEach(review => {
717
- if (review.practitionerReview) {
718
- totalRatingSum += review.practitionerReview.overallRating;
719
- totalRatingCount++;
720
- if (review.practitionerReview.wouldRecommend) {
721
- totalRecommendations++;
722
- }
723
- totalRecommendationCount++;
724
- }
725
- if (review.procedureReview) {
726
- totalRatingSum += review.procedureReview.overallRating;
727
- totalRatingCount++;
728
- if (review.procedureReview.wouldRecommend) {
729
- totalRecommendations++;
730
- }
731
- totalRecommendationCount++;
732
- }
733
- if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
734
- review.extendedProcedureReviews.forEach(extReview => {
735
- totalRatingSum += extReview.overallRating;
736
- totalRatingCount++;
737
- if (extReview.wouldRecommend) {
738
- totalRecommendations++;
739
- }
740
- totalRecommendationCount++;
741
- });
742
- }
743
- });
744
-
745
- const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
746
- const currentRecRate = totalRecommendationCount > 0 ? (totalRecommendations / totalRecommendationCount) * 100 : 0;
747
-
748
- // Calculate trend comparison
749
- const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
750
-
751
- trends.push({
752
- period: periodInfo.period,
753
- startDate: periodInfo.startDate,
754
- endDate: periodInfo.endDate,
755
- averageRating: currentAvgRating,
756
- recommendationRate: currentRecRate,
757
- totalReviews: periodReviews.length,
758
- previousPeriod: previousAvgRating > 0 ? {
759
- averageRating: previousAvgRating,
760
- recommendationRate: previousRecRate,
761
- percentageChange: Math.abs(trendChange.percentageChange),
762
- direction: trendChange.direction,
763
- } : undefined,
764
- });
765
-
766
- previousAvgRating = currentAvgRating;
767
- previousRecRate = currentRecRate;
768
- });
769
-
770
- return trends;
771
- }
772
-
773
- /**
774
- * Calculate grouped review trends (by practitioner, procedure, or technology)
775
- * Returns the AVERAGE across all entities of that type for each period
776
- * @private
777
- */
778
- private async getGroupedReviewTrends(
779
- reviews: Review[],
780
- dateRange: AnalyticsDateRange,
781
- period: TrendPeriod,
782
- entityType: 'practitioner' | 'procedure' | 'technology',
783
- filters?: AnalyticsFilters
784
- ): Promise<ReviewTrend[]> {
785
- const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
786
- const trends: ReviewTrend[] = [];
787
-
788
- // Fetch appointments if needed for technology mapping
789
- let appointments: Appointment[] = [];
790
- let procedureToTechnologyMap = new Map<string, { id: string; name: string }>();
791
-
792
- if (entityType === 'technology' && this.appointmentService) {
793
- const searchParams: any = { ...filters };
794
- if (dateRange) {
795
- searchParams.startDate = dateRange.start;
796
- searchParams.endDate = dateRange.end;
797
- }
798
- const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
799
- appointments = appointmentsResult.appointments || [];
800
-
801
- // Build procedure -> technology map
802
- appointments.forEach((appointment: Appointment) => {
803
- if (appointment.procedureId && appointment.procedureExtendedInfo?.procedureTechnologyId) {
804
- procedureToTechnologyMap.set(appointment.procedureId, {
805
- id: appointment.procedureExtendedInfo.procedureTechnologyId,
806
- name: appointment.procedureExtendedInfo.procedureTechnologyName || 'Unknown Technology',
807
- });
808
- }
809
- if (appointment.metadata?.extendedProcedures) {
810
- appointment.metadata.extendedProcedures.forEach(extProc => {
811
- if (extProc.procedureId && extProc.procedureTechnologyId) {
812
- procedureToTechnologyMap.set(extProc.procedureId, {
813
- id: extProc.procedureTechnologyId,
814
- name: extProc.procedureTechnologyName || 'Unknown Technology',
815
- });
816
- }
817
- });
818
- }
819
- });
820
- }
821
-
822
- let previousAvgRating = 0;
823
- let previousRecRate = 0;
824
-
825
- periods.forEach((periodInfo: PeriodInfo) => {
826
- // Filter reviews for this period
827
- const periodReviews = reviews.filter(review => {
828
- const reviewDate = review.createdAt instanceof Date ? review.createdAt : (review.createdAt as Timestamp).toDate();
829
- return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
830
- });
831
-
832
- if (periodReviews.length === 0) {
833
- trends.push({
834
- period: periodInfo.period,
835
- startDate: periodInfo.startDate,
836
- endDate: periodInfo.endDate,
837
- averageRating: 0,
838
- recommendationRate: 0,
839
- totalReviews: 0,
840
- previousPeriod: undefined,
841
- });
842
- previousAvgRating = 0;
843
- previousRecRate = 0;
844
- return;
845
- }
846
-
847
- // Calculate entity-specific averages
848
- let totalRatingSum = 0;
849
- let totalRatingCount = 0;
850
- let totalRecommendations = 0;
851
- let totalRecommendationCount = 0;
852
-
853
- periodReviews.forEach(review => {
854
- if (entityType === 'practitioner' && review.practitionerReview) {
855
- totalRatingSum += review.practitionerReview.overallRating;
856
- totalRatingCount++;
857
- if (review.practitionerReview.wouldRecommend) {
858
- totalRecommendations++;
859
- }
860
- totalRecommendationCount++;
861
- } else if (entityType === 'procedure' && review.procedureReview) {
862
- totalRatingSum += review.procedureReview.overallRating;
863
- totalRatingCount++;
864
- if (review.procedureReview.wouldRecommend) {
865
- totalRecommendations++;
866
- }
867
- totalRecommendationCount++;
868
-
869
- // Include extended procedure reviews
870
- if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
871
- review.extendedProcedureReviews.forEach(extReview => {
872
- totalRatingSum += extReview.overallRating;
873
- totalRatingCount++;
874
- if (extReview.wouldRecommend) {
875
- totalRecommendations++;
876
- }
877
- totalRecommendationCount++;
878
- });
879
- }
880
- } else if (entityType === 'technology') {
881
- // For technology, map procedure reviews to their technology
882
- if (review.procedureReview?.procedureId) {
883
- const tech = procedureToTechnologyMap.get(review.procedureReview.procedureId);
884
- if (tech) {
885
- totalRatingSum += review.procedureReview.overallRating;
886
- totalRatingCount++;
887
- if (review.procedureReview.wouldRecommend) {
888
- totalRecommendations++;
889
- }
890
- totalRecommendationCount++;
891
- }
892
- }
893
-
894
- // Include extended procedure reviews mapped to technology
895
- if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
896
- review.extendedProcedureReviews.forEach(extReview => {
897
- if (extReview.procedureId) {
898
- const tech = procedureToTechnologyMap.get(extReview.procedureId);
899
- if (tech) {
900
- totalRatingSum += extReview.overallRating;
901
- totalRatingCount++;
902
- if (extReview.wouldRecommend) {
903
- totalRecommendations++;
904
- }
905
- totalRecommendationCount++;
906
- }
907
- }
908
- });
909
- }
910
- }
911
- });
912
-
913
- const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
914
- const currentRecRate = totalRecommendationCount > 0 ? (totalRecommendations / totalRecommendationCount) * 100 : 0;
915
-
916
- // Calculate trend comparison
917
- const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
918
-
919
- trends.push({
920
- period: periodInfo.period,
921
- startDate: periodInfo.startDate,
922
- endDate: periodInfo.endDate,
923
- averageRating: currentAvgRating,
924
- recommendationRate: currentRecRate,
925
- totalReviews: totalRatingCount, // Count of reviews for this entity type
926
- previousPeriod: previousAvgRating > 0 ? {
927
- averageRating: previousAvgRating,
928
- recommendationRate: previousRecRate,
929
- percentageChange: Math.abs(trendChange.percentageChange),
930
- direction: trendChange.direction,
931
- } : undefined,
932
- });
933
-
934
- previousAvgRating = currentAvgRating;
935
- previousRecRate = currentRecRate;
936
- });
937
-
938
- return trends;
939
- }
940
- }
941
-
1
+ import { Firestore, collection, query, where, getDocs, getDoc, doc, Timestamp } from 'firebase/firestore';
2
+ import { BaseService } from '../base.service';
3
+ import { Review, PractitionerReview, ProcedureReview, REVIEWS_COLLECTION } from '../../types/reviews';
4
+ import { Appointment, APPOINTMENTS_COLLECTION } from '../../types/appointment';
5
+ import { AnalyticsDateRange, AnalyticsFilters, ReviewTrend, TrendPeriod } from '../../types/analytics';
6
+ import { AppointmentService } from '../appointment/appointment.service';
7
+ import {
8
+ groupAppointmentsByPeriod,
9
+ generatePeriods,
10
+ getTrendChange,
11
+ type TrendPeriod as TrendPeriodType,
12
+ type PeriodInfo,
13
+ } from './utils/trend-calculation.utils';
14
+
15
+ /**
16
+ * Review metrics for a specific entity (practitioner, procedure, etc.)
17
+ * Full review analytics metrics with detailed breakdowns
18
+ */
19
+ export interface ReviewAnalyticsMetrics {
20
+ entityId: string;
21
+ entityName: string;
22
+ entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology';
23
+
24
+ // Overall metrics
25
+ totalReviews: number;
26
+ averageRating: number;
27
+ recommendationRate: number; // % that would recommend
28
+
29
+ // For Practitioner reviews
30
+ practitionerMetrics?: {
31
+ averageKnowledgeAndExpertise: number;
32
+ averageCommunicationSkills: number;
33
+ averageBedSideManner: number;
34
+ averageThoroughness: number;
35
+ averageTrustworthiness: number;
36
+ };
37
+
38
+ // For Procedure reviews
39
+ procedureMetrics?: {
40
+ averageEffectiveness: number;
41
+ averageOutcomeExplanation: number;
42
+ averagePainManagement: number;
43
+ averageFollowUpCare: number;
44
+ averageValueForMoney: number;
45
+ };
46
+
47
+ // Comparison to overall average
48
+ comparisonToOverall: {
49
+ ratingDifference: number; // Positive = above average, negative = below
50
+ recommendationDifference: number; // % difference
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Review detail with full information
56
+ */
57
+ export interface ReviewDetail {
58
+ reviewId: string;
59
+ appointmentId: string;
60
+ patientId: string;
61
+ patientName?: string;
62
+ createdAt: Date;
63
+
64
+ // Relevant sub-review based on entityType
65
+ practitionerReview?: PractitionerReview;
66
+ procedureReview?: ProcedureReview;
67
+
68
+ // Context from appointment
69
+ procedureName?: string;
70
+ practitionerName?: string;
71
+ appointmentDate: Date;
72
+ }
73
+
74
+ /**
75
+ * Overall review averages for comparison
76
+ */
77
+ export interface OverallReviewAverages {
78
+ // Overall practitioner averages
79
+ practitionerAverage: {
80
+ totalReviews: number;
81
+ averageRating: number;
82
+ recommendationRate: number;
83
+ averageKnowledgeAndExpertise: number;
84
+ averageCommunicationSkills: number;
85
+ averageBedSideManner: number;
86
+ averageThoroughness: number;
87
+ averageTrustworthiness: number;
88
+ };
89
+
90
+ // Overall procedure averages
91
+ procedureAverage: {
92
+ totalReviews: number;
93
+ averageRating: number;
94
+ recommendationRate: number;
95
+ averageEffectiveness: number;
96
+ averageOutcomeExplanation: number;
97
+ averagePainManagement: number;
98
+ averageFollowUpCare: number;
99
+ averageValueForMoney: number;
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Review Analytics Service
105
+ * Provides review metrics and analytics for practitioners, procedures, categories, and technologies
106
+ */
107
+ export class ReviewAnalyticsService extends BaseService {
108
+ private appointmentService: AppointmentService;
109
+
110
+ constructor(db: Firestore, auth: any, app: any, appointmentService?: AppointmentService) {
111
+ super(db, auth, app);
112
+ // AppointmentService is optional - will be set if provided
113
+ this.appointmentService = appointmentService as AppointmentService;
114
+ }
115
+ /**
116
+ * Fetches reviews filtered by date range and optional filters
117
+ * Properly filters by clinic branch by checking appointment's clinicId
118
+ */
119
+ private async fetchReviews(
120
+ dateRange?: AnalyticsDateRange,
121
+ filters?: AnalyticsFilters
122
+ ): Promise<Review[]> {
123
+ let q = query(collection(this.db, REVIEWS_COLLECTION));
124
+
125
+ // Apply date range filter
126
+ if (dateRange) {
127
+ const startTimestamp = Timestamp.fromDate(dateRange.start);
128
+ const endTimestamp = Timestamp.fromDate(dateRange.end);
129
+ q = query(q, where('createdAt', '>=', startTimestamp), where('createdAt', '<=', endTimestamp));
130
+ }
131
+
132
+ const snapshot = await getDocs(q);
133
+ const reviews = snapshot.docs.map(doc => {
134
+ const data = doc.data();
135
+ return {
136
+ ...data,
137
+ id: doc.id,
138
+ createdAt: data.createdAt?.toDate ? data.createdAt.toDate() : new Date(data.createdAt),
139
+ updatedAt: data.updatedAt?.toDate ? data.updatedAt.toDate() : new Date(data.updatedAt),
140
+ } as Review;
141
+ });
142
+
143
+ console.log(`[ReviewAnalytics] Fetched ${reviews.length} reviews in date range`);
144
+
145
+ // Filter by clinic branch if specified
146
+ if (filters?.clinicBranchId && reviews.length > 0) {
147
+ // We need to fetch appointments to check which clinic they belong to
148
+ // Firestore 'in' operator supports max 10 items, so we batch
149
+ const appointmentIds = [...new Set(reviews.map(r => r.appointmentId))];
150
+ console.log(`[ReviewAnalytics] Filtering by clinic ${filters.clinicBranchId}, checking ${appointmentIds.length} appointments`);
151
+
152
+ const validAppointmentIds = new Set<string>();
153
+
154
+ // Process in batches of 10
155
+ for (let i = 0; i < appointmentIds.length; i += 10) {
156
+ const batch = appointmentIds.slice(i, i + 10);
157
+ const appointmentsQuery = query(
158
+ collection(this.db, APPOINTMENTS_COLLECTION),
159
+ where('id', 'in', batch)
160
+ );
161
+ const appointmentSnapshot = await getDocs(appointmentsQuery);
162
+
163
+ appointmentSnapshot.docs.forEach(doc => {
164
+ const appointment = doc.data() as Appointment;
165
+ // Appointment uses 'clinicBranchId' field directly
166
+ if (appointment.clinicBranchId === filters.clinicBranchId) {
167
+ validAppointmentIds.add(doc.id);
168
+ }
169
+ });
170
+ }
171
+
172
+ const filteredReviews = reviews.filter(review => validAppointmentIds.has(review.appointmentId));
173
+ console.log(`[ReviewAnalytics] After clinic filter: ${filteredReviews.length} reviews (from ${validAppointmentIds.size} valid appointments)`);
174
+
175
+ return filteredReviews;
176
+ }
177
+
178
+ return reviews;
179
+ }
180
+
181
+ /**
182
+ * Gets review metrics for a specific entity
183
+ */
184
+ async getReviewMetricsByEntity(
185
+ entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
186
+ entityId: string,
187
+ dateRange?: AnalyticsDateRange,
188
+ filters?: AnalyticsFilters
189
+ ): Promise<ReviewAnalyticsMetrics | null> {
190
+ const reviews = await this.fetchReviews(dateRange, filters);
191
+
192
+ // Filter reviews based on entity type
193
+ let relevantReviews: Review[] = [];
194
+
195
+ if (entityType === 'practitioner') {
196
+ relevantReviews = reviews.filter(r => r.practitionerReview?.practitionerId === entityId);
197
+ } else if (entityType === 'procedure') {
198
+ relevantReviews = reviews.filter(r => r.procedureReview?.procedureId === entityId);
199
+ } else if (entityType === 'category' || entityType === 'subcategory') {
200
+ // For category/subcategory, we need to get reviews for all procedures in that category
201
+ // This requires fetching appointments to get procedure info
202
+ // For now, we'll need to enhance this with appointment data
203
+ relevantReviews = reviews; // Placeholder - will be enhanced
204
+ }
205
+
206
+ if (relevantReviews.length === 0) {
207
+ return null;
208
+ }
209
+
210
+ // Calculate metrics
211
+ return this.calculateReviewMetrics(relevantReviews, entityType, entityId);
212
+ }
213
+
214
+ /**
215
+ * Gets review metrics for multiple entities (grouped)
216
+ */
217
+ async getReviewMetricsByEntities(
218
+ entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
219
+ dateRange?: AnalyticsDateRange,
220
+ filters?: AnalyticsFilters
221
+ ): Promise<ReviewAnalyticsMetrics[]> {
222
+ const reviews = await this.fetchReviews(dateRange, filters);
223
+ const entityMap = new Map<string, { reviews: Review[]; name: string }>();
224
+
225
+ // For practitioner, procedure, and technology, we fetch appointments to get actual names
226
+ // (Reviews have IDs stored in name fields, not actual names)
227
+ let practitionerNameMap: Map<string, string> | null = null;
228
+ let procedureNameMap: Map<string, string> | null = null;
229
+ let procedureToTechnologyMap: Map<string, { id: string; name: string }> | null = null;
230
+
231
+ if (entityType === 'practitioner' || entityType === 'procedure' || entityType === 'technology') {
232
+ if (!this.appointmentService) {
233
+ console.warn(`[ReviewAnalytics] AppointmentService not available for ${entityType} name resolution`);
234
+ return [];
235
+ }
236
+
237
+ console.log(`[ReviewAnalytics] Grouping by ${entityType}, fetching appointments for name resolution...`);
238
+
239
+ // Fetch all appointments to build name mapping tables
240
+ const searchParams: any = {
241
+ ...filters,
242
+ };
243
+ if (dateRange) {
244
+ searchParams.startDate = dateRange.start;
245
+ searchParams.endDate = dateRange.end;
246
+ }
247
+ const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
248
+ const appointments = appointmentsResult.appointments || [];
249
+
250
+ console.log(`[ReviewAnalytics] Found ${appointments.length} appointments for name resolution`);
251
+
252
+ // Build all name mapping tables
253
+ practitionerNameMap = new Map<string, string>();
254
+ procedureNameMap = new Map<string, string>();
255
+ procedureToTechnologyMap = new Map<string, { id: string; name: string }>();
256
+
257
+ appointments.forEach((appointment: Appointment) => {
258
+ // Map practitioner ID -> name
259
+ if (appointment.practitionerId && appointment.practitionerInfo?.name) {
260
+ practitionerNameMap!.set(appointment.practitionerId, appointment.practitionerInfo.name);
261
+ }
262
+
263
+ // Map main procedure ID -> name
264
+ if (appointment.procedureId) {
265
+ if (appointment.procedureInfo?.name) {
266
+ procedureNameMap!.set(appointment.procedureId, appointment.procedureInfo.name);
267
+ }
268
+
269
+ // Map procedure -> technology
270
+ const mainTechnologyId = appointment.procedureExtendedInfo?.procedureTechnologyId || 'unknown-technology';
271
+ const mainTechnologyName = appointment.procedureExtendedInfo?.procedureTechnologyName ||
272
+ appointment.procedureInfo?.name ||
273
+ 'Unknown Technology';
274
+ procedureToTechnologyMap!.set(appointment.procedureId, {
275
+ id: mainTechnologyId,
276
+ name: mainTechnologyName,
277
+ });
278
+ }
279
+
280
+ // Map extended procedures
281
+ if (appointment.metadata?.extendedProcedures) {
282
+ appointment.metadata.extendedProcedures.forEach((extendedProc) => {
283
+ if (extendedProc.procedureId) {
284
+ if (extendedProc.procedureName) {
285
+ procedureNameMap!.set(extendedProc.procedureId, extendedProc.procedureName);
286
+ }
287
+
288
+ const extTechnologyId = extendedProc.procedureTechnologyId || 'unknown-technology';
289
+ const extTechnologyName = extendedProc.procedureTechnologyName || 'Unknown Technology';
290
+ procedureToTechnologyMap!.set(extendedProc.procedureId, {
291
+ id: extTechnologyId,
292
+ name: extTechnologyName,
293
+ });
294
+ }
295
+ });
296
+ }
297
+ });
298
+
299
+ console.log(`[ReviewAnalytics] Built name maps: ${practitionerNameMap.size} practitioners, ${procedureNameMap.size} procedures, ${procedureToTechnologyMap.size} technologies`);
300
+ }
301
+
302
+ // Now group reviews based on entity type
303
+ if (entityType === 'technology' && procedureToTechnologyMap) {
304
+ let processedReviewCount = 0;
305
+
306
+ reviews.forEach(review => {
307
+ // Process main procedure review
308
+ if (review.procedureReview?.procedureId) {
309
+ const techInfo = procedureToTechnologyMap!.get(review.procedureReview.procedureId);
310
+ if (techInfo) {
311
+ if (!entityMap.has(techInfo.id)) {
312
+ entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
313
+ }
314
+ entityMap.get(techInfo.id)!.reviews.push(review);
315
+ processedReviewCount++;
316
+ }
317
+ }
318
+
319
+ // Process extended procedure reviews
320
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
321
+ review.extendedProcedureReviews.forEach((extendedReview) => {
322
+ if (extendedReview.procedureId) {
323
+ const techInfo = procedureToTechnologyMap!.get(extendedReview.procedureId);
324
+ if (techInfo) {
325
+ if (!entityMap.has(techInfo.id)) {
326
+ entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
327
+ }
328
+ const reviewWithExtendedOnly: Review = {
329
+ ...review,
330
+ procedureReview: extendedReview,
331
+ extendedProcedureReviews: undefined,
332
+ };
333
+ entityMap.get(techInfo.id)!.reviews.push(reviewWithExtendedOnly);
334
+ processedReviewCount++;
335
+ }
336
+ }
337
+ });
338
+ }
339
+ });
340
+
341
+ console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} technology groups`);
342
+ entityMap.forEach((data, techId) => {
343
+ console.log(`[ReviewAnalytics] - ${data.name} (${techId}): ${data.reviews.length} reviews`);
344
+ });
345
+ } else if (entityType === 'procedure' && procedureNameMap) {
346
+ let processedReviewCount = 0;
347
+
348
+ reviews.forEach(review => {
349
+ // Process main procedure review
350
+ if (review.procedureReview) {
351
+ const procedureId = review.procedureReview.procedureId;
352
+ // Use actual name from appointment, fallback to review name, then 'Unknown'
353
+ const procedureName = (procedureId && procedureNameMap!.get(procedureId)) ||
354
+ review.procedureReview.procedureName ||
355
+ 'Unknown Procedure';
356
+
357
+ if (procedureId) {
358
+ if (!entityMap.has(procedureId)) {
359
+ entityMap.set(procedureId, { reviews: [], name: procedureName });
360
+ }
361
+ entityMap.get(procedureId)!.reviews.push(review);
362
+ processedReviewCount++;
363
+ }
364
+ }
365
+
366
+ // Process extended procedure reviews
367
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
368
+ review.extendedProcedureReviews.forEach((extendedReview) => {
369
+ const procedureId = extendedReview.procedureId;
370
+ // Use actual name from appointment, fallback to review name, then 'Unknown'
371
+ const procedureName = (procedureId && procedureNameMap!.get(procedureId)) ||
372
+ extendedReview.procedureName ||
373
+ 'Unknown Procedure';
374
+
375
+ if (procedureId) {
376
+ if (!entityMap.has(procedureId)) {
377
+ entityMap.set(procedureId, { reviews: [], name: procedureName });
378
+ }
379
+ const reviewWithExtendedOnly: Review = {
380
+ ...review,
381
+ procedureReview: extendedReview,
382
+ extendedProcedureReviews: undefined,
383
+ };
384
+ entityMap.get(procedureId)!.reviews.push(reviewWithExtendedOnly);
385
+ processedReviewCount++;
386
+ }
387
+ });
388
+ }
389
+ });
390
+
391
+ console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} procedure groups`);
392
+ entityMap.forEach((data, procId) => {
393
+ console.log(`[ReviewAnalytics] - ${data.name} (${procId}): ${data.reviews.length} reviews`);
394
+ });
395
+ } else if (entityType === 'practitioner' && practitionerNameMap) {
396
+ // Group reviews by practitioner
397
+ reviews.forEach(review => {
398
+ if (review.practitionerReview) {
399
+ const practitionerId = review.practitionerReview.practitionerId;
400
+ // Use actual name from appointment, fallback to review name, then 'Unknown'
401
+ const practitionerName = (practitionerId && practitionerNameMap!.get(practitionerId)) ||
402
+ review.practitionerReview.practitionerName ||
403
+ 'Unknown Practitioner';
404
+
405
+ if (practitionerId) {
406
+ if (!entityMap.has(practitionerId)) {
407
+ entityMap.set(practitionerId, { reviews: [], name: practitionerName });
408
+ }
409
+ entityMap.get(practitionerId)!.reviews.push(review);
410
+ }
411
+ }
412
+ });
413
+
414
+ console.log(`[ReviewAnalytics] Processed ${reviews.length} reviews into ${entityMap.size} practitioner groups`);
415
+ entityMap.forEach((data, practId) => {
416
+ console.log(`[ReviewAnalytics] - ${data.name} (${practId}): ${data.reviews.length} reviews`);
417
+ });
418
+ } else {
419
+ // Handle other entity types (category, subcategory, etc.)
420
+ reviews.forEach(review => {
421
+ let entityId: string | undefined;
422
+ let entityName: string | undefined;
423
+
424
+ // TODO: Handle category/subcategory grouping
425
+
426
+ if (entityId) {
427
+ if (!entityMap.has(entityId)) {
428
+ entityMap.set(entityId, { reviews: [], name: entityName || entityId });
429
+ }
430
+ entityMap.get(entityId)!.reviews.push(review);
431
+ }
432
+ });
433
+ }
434
+
435
+ // Calculate metrics for each entity
436
+ const metrics: ReviewAnalyticsMetrics[] = [];
437
+ for (const [entityId, data] of entityMap.entries()) {
438
+ const metric = this.calculateReviewMetrics(data.reviews, entityType, entityId);
439
+ if (metric) {
440
+ metric.entityName = data.name; // Use the mapped name
441
+ metrics.push(metric);
442
+ }
443
+ }
444
+
445
+ return metrics;
446
+ }
447
+
448
+ /**
449
+ * Calculates review metrics from a list of reviews
450
+ */
451
+ private calculateReviewMetrics(
452
+ reviews: Review[],
453
+ entityType: 'practitioner' | 'procedure' | 'category' | 'subcategory' | 'technology',
454
+ entityId: string
455
+ ): ReviewAnalyticsMetrics | null {
456
+ if (reviews.length === 0) {
457
+ return null;
458
+ }
459
+
460
+ let totalRating = 0;
461
+ let recommendationCount = 0;
462
+ let practitionerMetrics: ReviewAnalyticsMetrics['practitionerMetrics'];
463
+ let procedureMetrics: ReviewAnalyticsMetrics['procedureMetrics'];
464
+ let entityName = entityId; // Default, will be enhanced from appointments
465
+
466
+ if (entityType === 'practitioner') {
467
+ const practitionerReviews = reviews.filter(r => r.practitionerReview).map(r => r.practitionerReview!);
468
+
469
+ if (practitionerReviews.length === 0) {
470
+ return null;
471
+ }
472
+
473
+ // Get entity name from first review
474
+ entityName = practitionerReviews[0].practitionerName || entityId;
475
+
476
+ // Calculate averages
477
+ totalRating = practitionerReviews.reduce((sum, r) => sum + r.overallRating, 0);
478
+ recommendationCount = practitionerReviews.filter(r => r.wouldRecommend).length;
479
+
480
+ practitionerMetrics = {
481
+ averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map(r => r.knowledgeAndExpertise)),
482
+ averageCommunicationSkills: this.calculateAverage(practitionerReviews.map(r => r.communicationSkills)),
483
+ averageBedSideManner: this.calculateAverage(practitionerReviews.map(r => r.bedSideManner)),
484
+ averageThoroughness: this.calculateAverage(practitionerReviews.map(r => r.thoroughness)),
485
+ averageTrustworthiness: this.calculateAverage(practitionerReviews.map(r => r.trustworthiness)),
486
+ };
487
+ } else if (entityType === 'procedure' || entityType === 'technology') {
488
+ // Technology uses the same logic as procedure since technology reviews are procedure reviews
489
+ const procedureReviews = reviews.filter(r => r.procedureReview).map(r => r.procedureReview!);
490
+
491
+ if (procedureReviews.length === 0) {
492
+ return null;
493
+ }
494
+
495
+ // Get entity name from first review (or use the name that was passed in for technology)
496
+ if (entityType === 'procedure') {
497
+ entityName = procedureReviews[0].procedureName || entityId;
498
+ }
499
+ // For technology, entityName is already set from the calling method
500
+
501
+ // Calculate averages
502
+ totalRating = procedureReviews.reduce((sum, r) => sum + r.overallRating, 0);
503
+ recommendationCount = procedureReviews.filter(r => r.wouldRecommend).length;
504
+
505
+ procedureMetrics = {
506
+ averageEffectiveness: this.calculateAverage(procedureReviews.map(r => r.effectivenessOfTreatment)),
507
+ averageOutcomeExplanation: this.calculateAverage(procedureReviews.map(r => r.outcomeExplanation)),
508
+ averagePainManagement: this.calculateAverage(procedureReviews.map(r => r.painManagement)),
509
+ averageFollowUpCare: this.calculateAverage(procedureReviews.map(r => r.followUpCare)),
510
+ averageValueForMoney: this.calculateAverage(procedureReviews.map(r => r.valueForMoney)),
511
+ };
512
+ }
513
+
514
+ const averageRating = totalRating / reviews.length;
515
+ const recommendationRate = (recommendationCount / reviews.length) * 100;
516
+
517
+ const result: ReviewAnalyticsMetrics = {
518
+ entityId,
519
+ entityName,
520
+ entityType,
521
+ totalReviews: reviews.length,
522
+ averageRating,
523
+ recommendationRate,
524
+ practitionerMetrics,
525
+ procedureMetrics,
526
+ comparisonToOverall: {
527
+ ratingDifference: 0, // Will be calculated when comparing to overall
528
+ recommendationDifference: 0,
529
+ },
530
+ };
531
+
532
+ return result;
533
+ }
534
+
535
+ /**
536
+ * Gets overall review averages for comparison
537
+ */
538
+ async getOverallReviewAverages(
539
+ dateRange?: AnalyticsDateRange,
540
+ filters?: AnalyticsFilters
541
+ ): Promise<OverallReviewAverages> {
542
+ const reviews = await this.fetchReviews(dateRange, filters);
543
+
544
+ const practitionerReviews = reviews
545
+ .filter(r => r.practitionerReview)
546
+ .map(r => r.practitionerReview!);
547
+
548
+ const procedureReviews = reviews
549
+ .filter(r => r.procedureReview)
550
+ .map(r => r.procedureReview!);
551
+
552
+ return {
553
+ practitionerAverage: {
554
+ totalReviews: practitionerReviews.length,
555
+ averageRating: practitionerReviews.length > 0
556
+ ? this.calculateAverage(practitionerReviews.map(r => r.overallRating))
557
+ : 0,
558
+ recommendationRate: practitionerReviews.length > 0
559
+ ? (practitionerReviews.filter(r => r.wouldRecommend).length / practitionerReviews.length) * 100
560
+ : 0,
561
+ averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map(r => r.knowledgeAndExpertise)),
562
+ averageCommunicationSkills: this.calculateAverage(practitionerReviews.map(r => r.communicationSkills)),
563
+ averageBedSideManner: this.calculateAverage(practitionerReviews.map(r => r.bedSideManner)),
564
+ averageThoroughness: this.calculateAverage(practitionerReviews.map(r => r.thoroughness)),
565
+ averageTrustworthiness: this.calculateAverage(practitionerReviews.map(r => r.trustworthiness)),
566
+ },
567
+ procedureAverage: {
568
+ totalReviews: procedureReviews.length,
569
+ averageRating: procedureReviews.length > 0
570
+ ? this.calculateAverage(procedureReviews.map(r => r.overallRating))
571
+ : 0,
572
+ recommendationRate: procedureReviews.length > 0
573
+ ? (procedureReviews.filter(r => r.wouldRecommend).length / procedureReviews.length) * 100
574
+ : 0,
575
+ averageEffectiveness: this.calculateAverage(procedureReviews.map(r => r.effectivenessOfTreatment)),
576
+ averageOutcomeExplanation: this.calculateAverage(procedureReviews.map(r => r.outcomeExplanation)),
577
+ averagePainManagement: this.calculateAverage(procedureReviews.map(r => r.painManagement)),
578
+ averageFollowUpCare: this.calculateAverage(procedureReviews.map(r => r.followUpCare)),
579
+ averageValueForMoney: this.calculateAverage(procedureReviews.map(r => r.valueForMoney)),
580
+ },
581
+ };
582
+ }
583
+
584
+ /**
585
+ * Gets review details for a specific entity
586
+ */
587
+ async getReviewDetails(
588
+ entityType: 'practitioner' | 'procedure',
589
+ entityId: string,
590
+ dateRange?: AnalyticsDateRange,
591
+ filters?: AnalyticsFilters
592
+ ): Promise<ReviewDetail[]> {
593
+ const reviews = await this.fetchReviews(dateRange, filters);
594
+
595
+ // Filter reviews based on entity type
596
+ let relevantReviews: Review[] = [];
597
+
598
+ if (entityType === 'practitioner') {
599
+ relevantReviews = reviews.filter(r => r.practitionerReview?.practitionerId === entityId);
600
+ } else if (entityType === 'procedure') {
601
+ relevantReviews = reviews.filter(r => r.procedureReview?.procedureId === entityId);
602
+ }
603
+
604
+ // Enhance with appointment data
605
+ const details: ReviewDetail[] = [];
606
+ for (const review of relevantReviews) {
607
+ try {
608
+ const appointmentDocRef = doc(this.db, APPOINTMENTS_COLLECTION, review.appointmentId);
609
+ const appointmentDoc = await getDoc(appointmentDocRef);
610
+
611
+ let appointment: Appointment | null = null;
612
+ if (appointmentDoc.exists()) {
613
+ appointment = appointmentDoc.data() as Appointment;
614
+ }
615
+
616
+ const createdAt = review.createdAt instanceof Timestamp ? review.createdAt.toDate() : new Date(review.createdAt);
617
+ const appointmentDate = appointment?.appointmentStartTime
618
+ ? (appointment.appointmentStartTime instanceof Timestamp
619
+ ? appointment.appointmentStartTime.toDate()
620
+ : appointment.appointmentStartTime)
621
+ : createdAt;
622
+
623
+ details.push({
624
+ reviewId: review.id,
625
+ appointmentId: review.appointmentId,
626
+ patientId: review.patientId,
627
+ patientName: review.patientName || appointment?.patientInfo?.fullName,
628
+ createdAt,
629
+ practitionerReview: review.practitionerReview,
630
+ procedureReview: review.procedureReview,
631
+ procedureName: appointment?.procedureInfo?.name,
632
+ practitionerName: appointment?.practitionerInfo?.name,
633
+ appointmentDate,
634
+ });
635
+ } catch (error) {
636
+ console.warn(`Failed to enhance review ${review.id}:`, error);
637
+ }
638
+ }
639
+
640
+ return details;
641
+ }
642
+
643
+ /**
644
+ * Helper method to calculate average
645
+ */
646
+ private calculateAverage(values: number[]): number {
647
+ if (values.length === 0) return 0;
648
+ const sum = values.reduce((acc, val) => acc + val, 0);
649
+ return sum / values.length;
650
+ }
651
+
652
+ /**
653
+ * Calculate review trends over time
654
+ * Groups reviews by period and calculates rating and recommendation metrics
655
+ *
656
+ * @param dateRange - Date range for trend analysis (must align with period boundaries)
657
+ * @param period - Period type (week, month, quarter, year)
658
+ * @param filters - Optional filters for clinic, practitioner, procedure
659
+ * @param entityType - Optional entity type to group trends by (practitioner, procedure, technology)
660
+ * @returns Array of review trends with percentage changes
661
+ */
662
+ async getReviewTrends(
663
+ dateRange: AnalyticsDateRange,
664
+ period: TrendPeriod,
665
+ filters?: AnalyticsFilters,
666
+ entityType?: 'practitioner' | 'procedure' | 'technology'
667
+ ): Promise<ReviewTrend[]> {
668
+ // Fetch all reviews in the date range
669
+ const reviews = await this.fetchReviews(dateRange, filters);
670
+
671
+ if (reviews.length === 0) {
672
+ return [];
673
+ }
674
+
675
+ // If grouping by entity, calculate trends per entity
676
+ if (entityType) {
677
+ return this.getGroupedReviewTrends(reviews, dateRange, period, entityType, filters);
678
+ }
679
+
680
+ // Calculate overall trends
681
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
682
+ const trends: ReviewTrend[] = [];
683
+
684
+ let previousAvgRating = 0;
685
+ let previousRecRate = 0;
686
+
687
+ periods.forEach((periodInfo: PeriodInfo) => {
688
+ // Filter reviews for this period
689
+ const periodReviews = reviews.filter(review => {
690
+ const reviewDate = review.createdAt instanceof Date ? review.createdAt : (review.createdAt as Timestamp).toDate();
691
+ return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
692
+ });
693
+
694
+ if (periodReviews.length === 0) {
695
+ // No reviews in this period, skip or use 0
696
+ trends.push({
697
+ period: periodInfo.period,
698
+ startDate: periodInfo.startDate,
699
+ endDate: periodInfo.endDate,
700
+ averageRating: 0,
701
+ recommendationRate: 0,
702
+ totalReviews: 0,
703
+ previousPeriod: undefined,
704
+ });
705
+ previousAvgRating = 0;
706
+ previousRecRate = 0;
707
+ return;
708
+ }
709
+
710
+ // Calculate weighted average rating across practitioner and procedure reviews
711
+ let totalRatingSum = 0;
712
+ let totalRatingCount = 0;
713
+ let totalRecommendations = 0;
714
+ let totalRecommendationCount = 0;
715
+
716
+ periodReviews.forEach(review => {
717
+ if (review.practitionerReview) {
718
+ totalRatingSum += review.practitionerReview.overallRating;
719
+ totalRatingCount++;
720
+ if (review.practitionerReview.wouldRecommend) {
721
+ totalRecommendations++;
722
+ }
723
+ totalRecommendationCount++;
724
+ }
725
+ if (review.procedureReview) {
726
+ totalRatingSum += review.procedureReview.overallRating;
727
+ totalRatingCount++;
728
+ if (review.procedureReview.wouldRecommend) {
729
+ totalRecommendations++;
730
+ }
731
+ totalRecommendationCount++;
732
+ }
733
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
734
+ review.extendedProcedureReviews.forEach(extReview => {
735
+ totalRatingSum += extReview.overallRating;
736
+ totalRatingCount++;
737
+ if (extReview.wouldRecommend) {
738
+ totalRecommendations++;
739
+ }
740
+ totalRecommendationCount++;
741
+ });
742
+ }
743
+ });
744
+
745
+ const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
746
+ const currentRecRate = totalRecommendationCount > 0 ? (totalRecommendations / totalRecommendationCount) * 100 : 0;
747
+
748
+ // Calculate trend comparison
749
+ const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
750
+
751
+ trends.push({
752
+ period: periodInfo.period,
753
+ startDate: periodInfo.startDate,
754
+ endDate: periodInfo.endDate,
755
+ averageRating: currentAvgRating,
756
+ recommendationRate: currentRecRate,
757
+ totalReviews: periodReviews.length,
758
+ previousPeriod: previousAvgRating > 0 ? {
759
+ averageRating: previousAvgRating,
760
+ recommendationRate: previousRecRate,
761
+ percentageChange: Math.abs(trendChange.percentageChange),
762
+ direction: trendChange.direction,
763
+ } : undefined,
764
+ });
765
+
766
+ previousAvgRating = currentAvgRating;
767
+ previousRecRate = currentRecRate;
768
+ });
769
+
770
+ return trends;
771
+ }
772
+
773
+ /**
774
+ * Calculate grouped review trends (by practitioner, procedure, or technology)
775
+ * Returns the AVERAGE across all entities of that type for each period
776
+ * @private
777
+ */
778
+ private async getGroupedReviewTrends(
779
+ reviews: Review[],
780
+ dateRange: AnalyticsDateRange,
781
+ period: TrendPeriod,
782
+ entityType: 'practitioner' | 'procedure' | 'technology',
783
+ filters?: AnalyticsFilters
784
+ ): Promise<ReviewTrend[]> {
785
+ const periods = generatePeriods(dateRange.start, dateRange.end, period as TrendPeriodType);
786
+ const trends: ReviewTrend[] = [];
787
+
788
+ // Fetch appointments if needed for technology mapping
789
+ let appointments: Appointment[] = [];
790
+ let procedureToTechnologyMap = new Map<string, { id: string; name: string }>();
791
+
792
+ if (entityType === 'technology' && this.appointmentService) {
793
+ const searchParams: any = { ...filters };
794
+ if (dateRange) {
795
+ searchParams.startDate = dateRange.start;
796
+ searchParams.endDate = dateRange.end;
797
+ }
798
+ const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
799
+ appointments = appointmentsResult.appointments || [];
800
+
801
+ // Build procedure -> technology map
802
+ appointments.forEach((appointment: Appointment) => {
803
+ if (appointment.procedureId && appointment.procedureExtendedInfo?.procedureTechnologyId) {
804
+ procedureToTechnologyMap.set(appointment.procedureId, {
805
+ id: appointment.procedureExtendedInfo.procedureTechnologyId,
806
+ name: appointment.procedureExtendedInfo.procedureTechnologyName || 'Unknown Technology',
807
+ });
808
+ }
809
+ if (appointment.metadata?.extendedProcedures) {
810
+ appointment.metadata.extendedProcedures.forEach(extProc => {
811
+ if (extProc.procedureId && extProc.procedureTechnologyId) {
812
+ procedureToTechnologyMap.set(extProc.procedureId, {
813
+ id: extProc.procedureTechnologyId,
814
+ name: extProc.procedureTechnologyName || 'Unknown Technology',
815
+ });
816
+ }
817
+ });
818
+ }
819
+ });
820
+ }
821
+
822
+ let previousAvgRating = 0;
823
+ let previousRecRate = 0;
824
+
825
+ periods.forEach((periodInfo: PeriodInfo) => {
826
+ // Filter reviews for this period
827
+ const periodReviews = reviews.filter(review => {
828
+ const reviewDate = review.createdAt instanceof Date ? review.createdAt : (review.createdAt as Timestamp).toDate();
829
+ return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
830
+ });
831
+
832
+ if (periodReviews.length === 0) {
833
+ trends.push({
834
+ period: periodInfo.period,
835
+ startDate: periodInfo.startDate,
836
+ endDate: periodInfo.endDate,
837
+ averageRating: 0,
838
+ recommendationRate: 0,
839
+ totalReviews: 0,
840
+ previousPeriod: undefined,
841
+ });
842
+ previousAvgRating = 0;
843
+ previousRecRate = 0;
844
+ return;
845
+ }
846
+
847
+ // Calculate entity-specific averages
848
+ let totalRatingSum = 0;
849
+ let totalRatingCount = 0;
850
+ let totalRecommendations = 0;
851
+ let totalRecommendationCount = 0;
852
+
853
+ periodReviews.forEach(review => {
854
+ if (entityType === 'practitioner' && review.practitionerReview) {
855
+ totalRatingSum += review.practitionerReview.overallRating;
856
+ totalRatingCount++;
857
+ if (review.practitionerReview.wouldRecommend) {
858
+ totalRecommendations++;
859
+ }
860
+ totalRecommendationCount++;
861
+ } else if (entityType === 'procedure' && review.procedureReview) {
862
+ totalRatingSum += review.procedureReview.overallRating;
863
+ totalRatingCount++;
864
+ if (review.procedureReview.wouldRecommend) {
865
+ totalRecommendations++;
866
+ }
867
+ totalRecommendationCount++;
868
+
869
+ // Include extended procedure reviews
870
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
871
+ review.extendedProcedureReviews.forEach(extReview => {
872
+ totalRatingSum += extReview.overallRating;
873
+ totalRatingCount++;
874
+ if (extReview.wouldRecommend) {
875
+ totalRecommendations++;
876
+ }
877
+ totalRecommendationCount++;
878
+ });
879
+ }
880
+ } else if (entityType === 'technology') {
881
+ // For technology, map procedure reviews to their technology
882
+ if (review.procedureReview?.procedureId) {
883
+ const tech = procedureToTechnologyMap.get(review.procedureReview.procedureId);
884
+ if (tech) {
885
+ totalRatingSum += review.procedureReview.overallRating;
886
+ totalRatingCount++;
887
+ if (review.procedureReview.wouldRecommend) {
888
+ totalRecommendations++;
889
+ }
890
+ totalRecommendationCount++;
891
+ }
892
+ }
893
+
894
+ // Include extended procedure reviews mapped to technology
895
+ if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
896
+ review.extendedProcedureReviews.forEach(extReview => {
897
+ if (extReview.procedureId) {
898
+ const tech = procedureToTechnologyMap.get(extReview.procedureId);
899
+ if (tech) {
900
+ totalRatingSum += extReview.overallRating;
901
+ totalRatingCount++;
902
+ if (extReview.wouldRecommend) {
903
+ totalRecommendations++;
904
+ }
905
+ totalRecommendationCount++;
906
+ }
907
+ }
908
+ });
909
+ }
910
+ }
911
+ });
912
+
913
+ const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
914
+ const currentRecRate = totalRecommendationCount > 0 ? (totalRecommendations / totalRecommendationCount) * 100 : 0;
915
+
916
+ // Calculate trend comparison
917
+ const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
918
+
919
+ trends.push({
920
+ period: periodInfo.period,
921
+ startDate: periodInfo.startDate,
922
+ endDate: periodInfo.endDate,
923
+ averageRating: currentAvgRating,
924
+ recommendationRate: currentRecRate,
925
+ totalReviews: totalRatingCount, // Count of reviews for this entity type
926
+ previousPeriod: previousAvgRating > 0 ? {
927
+ averageRating: previousAvgRating,
928
+ recommendationRate: previousRecRate,
929
+ percentageChange: Math.abs(trendChange.percentageChange),
930
+ direction: trendChange.direction,
931
+ } : undefined,
932
+ });
933
+
934
+ previousAvgRating = currentAvgRating;
935
+ previousRecRate = currentRecRate;
936
+ });
937
+
938
+ return trends;
939
+ }
940
+ }
941
+