@blackcode_sa/metaestetics-api 1.13.3 → 1.13.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +15 -28
- package/dist/admin/index.d.ts +15 -28
- package/dist/index.d.mts +18 -30
- package/dist/index.d.ts +18 -30
- package/dist/index.js +11 -3
- package/dist/index.mjs +11 -3
- package/package.json +121 -119
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +128 -128
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
- package/src/admin/analytics/analytics.admin.service.ts +278 -278
- package/src/admin/analytics/index.ts +2 -2
- package/src/admin/booking/README.md +125 -125
- package/src/admin/booking/booking.admin.ts +1037 -1037
- package/src/admin/booking/booking.calculator.ts +712 -712
- package/src/admin/booking/booking.types.ts +59 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +7 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +1 -1
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +81 -81
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +95 -95
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/index.ts +3 -3
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +710 -710
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +57 -57
- package/src/backoffice/services/analytics.service.proposal.md +863 -863
- package/src/backoffice/services/analytics.service.summary.md +143 -143
- package/src/backoffice/services/brand.service.ts +256 -256
- package/src/backoffice/services/category.service.ts +384 -384
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +10 -10
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +553 -553
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +461 -461
- package/src/backoffice/services/technology.service.ts +1151 -1151
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +67 -67
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +240 -240
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +168 -168
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -164
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +9 -9
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +200 -200
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +293 -293
- package/src/services/__tests__/auth.service.test.ts +346 -346
- package/src/services/__tests__/base.service.test.ts +77 -77
- package/src/services/__tests__/user.service.test.ts +528 -528
- package/src/services/analytics/ARCHITECTURE.md +199 -199
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
- package/src/services/analytics/QUICK_START.md +393 -393
- package/src/services/analytics/README.md +304 -304
- package/src/services/analytics/SUMMARY.md +141 -141
- package/src/services/analytics/TRENDS.md +380 -380
- package/src/services/analytics/USAGE_GUIDE.md +518 -518
- package/src/services/analytics/analytics-cloud.service.ts +222 -222
- package/src/services/analytics/analytics.service.ts +2142 -2142
- package/src/services/analytics/index.ts +4 -4
- package/src/services/analytics/review-analytics.service.ts +941 -941
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
- package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
- package/src/services/analytics/utils/grouping.utils.ts +434 -434
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2558 -2558
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +552 -552
- package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
- package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +353 -353
- package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
- package/src/services/auth/auth.service.ts +989 -989
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1683 -1683
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
- package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +708 -708
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +949 -949
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +446 -446
- package/src/services/clinic/utils/index.ts +11 -11
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +84 -84
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +587 -587
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +14 -14
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +294 -294
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +883 -883
- package/src/services/patient/patientRequirements.service.ts +285 -285
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/clinic.utils.ts +80 -80
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +1742 -1742
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +2200 -2191
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +734 -734
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +489 -489
- package/src/services/user/user.v2.service.ts +466 -466
- package/src/types/analytics/analytics.types.ts +597 -597
- package/src/types/analytics/grouped-analytics.types.ts +173 -173
- package/src/types/analytics/index.ts +4 -4
- package/src/types/analytics/stored-analytics.types.ts +137 -137
- package/src/types/appointment/index.ts +480 -480
- package/src/types/calendar/index.ts +258 -258
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +498 -489
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +47 -47
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +286 -286
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/index.ts +275 -275
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +206 -206
- package/src/types/procedure/index.ts +181 -181
- package/src/types/profile/index.ts +39 -39
- package/src/types/reviews/index.ts +132 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +38 -38
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/appointment.schema.ts +574 -574
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +494 -493
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +20 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/medical-info.schema.ts +125 -125
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -217
- package/src/validations/practitioner.schema.ts +222 -222
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +124 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/reviews.schema.ts +195 -195
- package/src/validations/schemas.ts +104 -104
- 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
|
+
|