@blackcode_sa/metaestetics-api 1.13.0 → 1.13.2
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 +106 -1
- package/dist/admin/index.d.ts +106 -1
- package/dist/admin/index.js +1303 -130
- package/dist/admin/index.mjs +1303 -130
- package/dist/index.d.mts +360 -2
- package/dist/index.d.ts +360 -2
- package/dist/index.js +3422 -1888
- package/dist/index.mjs +3121 -1588
- package/package.json +1 -1
- package/src/services/analytics/README.md +17 -0
- package/src/services/analytics/TRENDS.md +380 -0
- package/src/services/analytics/analytics.service.ts +540 -30
- package/src/services/analytics/index.ts +1 -0
- package/src/services/analytics/review-analytics.service.ts +941 -0
- package/src/services/analytics/utils/cost-calculation.utils.ts +32 -4
- package/src/services/analytics/utils/grouping.utils.ts +40 -0
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -0
- package/src/services/appointment/appointment.service.ts +9 -0
- package/src/services/procedure/procedure.service.ts +419 -4
- package/src/services/reviews/reviews.service.ts +58 -7
- package/src/types/analytics/analytics.types.ts +98 -1
- package/src/types/analytics/grouped-analytics.types.ts +25 -0
package/dist/admin/index.mjs
CHANGED
|
@@ -616,8 +616,8 @@ var NotificationsAdmin = class {
|
|
|
616
616
|
* Dohvata notifikaciju po ID-u
|
|
617
617
|
*/
|
|
618
618
|
async getNotification(id) {
|
|
619
|
-
const
|
|
620
|
-
return
|
|
619
|
+
const doc3 = await this.db.collection("notifications").doc(id).get();
|
|
620
|
+
return doc3.exists ? { id: doc3.id, ...doc3.data() } : null;
|
|
621
621
|
}
|
|
622
622
|
/**
|
|
623
623
|
* Kreira novu notifikaciju
|
|
@@ -804,10 +804,10 @@ var NotificationsAdmin = class {
|
|
|
804
804
|
return;
|
|
805
805
|
}
|
|
806
806
|
const results = await Promise.allSettled(
|
|
807
|
-
pendingNotifications.docs.map(async (
|
|
807
|
+
pendingNotifications.docs.map(async (doc3) => {
|
|
808
808
|
const notification = {
|
|
809
|
-
id:
|
|
810
|
-
...
|
|
809
|
+
id: doc3.id,
|
|
810
|
+
...doc3.data()
|
|
811
811
|
};
|
|
812
812
|
Logger.info(
|
|
813
813
|
`[NotificationsAdmin] Processing notification ${notification.id} of type ${notification.notificationType}`
|
|
@@ -848,8 +848,8 @@ var NotificationsAdmin = class {
|
|
|
848
848
|
break;
|
|
849
849
|
}
|
|
850
850
|
const batch = this.db.batch();
|
|
851
|
-
oldNotifications.docs.forEach((
|
|
852
|
-
batch.delete(
|
|
851
|
+
oldNotifications.docs.forEach((doc3) => {
|
|
852
|
+
batch.delete(doc3.ref);
|
|
853
853
|
});
|
|
854
854
|
await batch.commit();
|
|
855
855
|
totalDeleted += oldNotifications.size;
|
|
@@ -3537,10 +3537,10 @@ var AppointmentAggregationService = class {
|
|
|
3537
3537
|
}
|
|
3538
3538
|
const batch = this.db.batch();
|
|
3539
3539
|
let instancesUpdatedCount = 0;
|
|
3540
|
-
instancesSnapshot.docs.forEach((
|
|
3541
|
-
const instance =
|
|
3540
|
+
instancesSnapshot.docs.forEach((doc3) => {
|
|
3541
|
+
const instance = doc3.data();
|
|
3542
3542
|
if (instance.overallStatus !== newOverallStatus && instance.overallStatus !== "failedToProcess" /* FAILED_TO_PROCESS */) {
|
|
3543
|
-
batch.update(
|
|
3543
|
+
batch.update(doc3.ref, {
|
|
3544
3544
|
overallStatus: newOverallStatus,
|
|
3545
3545
|
updatedAt: admin6.firestore.FieldValue.serverTimestamp()
|
|
3546
3546
|
// Cast for now
|
|
@@ -3549,7 +3549,7 @@ var AppointmentAggregationService = class {
|
|
|
3549
3549
|
});
|
|
3550
3550
|
instancesUpdatedCount++;
|
|
3551
3551
|
Logger.debug(
|
|
3552
|
-
`[AggService] Added update for PatientRequirementInstance ${
|
|
3552
|
+
`[AggService] Added update for PatientRequirementInstance ${doc3.id} to batch. New status: ${newOverallStatus}`
|
|
3553
3553
|
);
|
|
3554
3554
|
}
|
|
3555
3555
|
});
|
|
@@ -3724,8 +3724,8 @@ var AppointmentAggregationService = class {
|
|
|
3724
3724
|
// --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
|
|
3725
3725
|
async fetchPatientProfile(patientId) {
|
|
3726
3726
|
try {
|
|
3727
|
-
const
|
|
3728
|
-
return
|
|
3727
|
+
const doc3 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
|
|
3728
|
+
return doc3.exists ? doc3.data() : null;
|
|
3729
3729
|
} catch (error) {
|
|
3730
3730
|
Logger.error(`[AggService] Error fetching patient profile ${patientId}:`, error);
|
|
3731
3731
|
return null;
|
|
@@ -3738,12 +3738,12 @@ var AppointmentAggregationService = class {
|
|
|
3738
3738
|
*/
|
|
3739
3739
|
async fetchPatientSensitiveInfo(patientId) {
|
|
3740
3740
|
try {
|
|
3741
|
-
const
|
|
3742
|
-
if (!
|
|
3741
|
+
const doc3 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
|
|
3742
|
+
if (!doc3.exists) {
|
|
3743
3743
|
Logger.warn(`[AggService] No sensitive info found for patient ${patientId}`);
|
|
3744
3744
|
return null;
|
|
3745
3745
|
}
|
|
3746
|
-
return
|
|
3746
|
+
return doc3.data();
|
|
3747
3747
|
} catch (error) {
|
|
3748
3748
|
Logger.error(`[AggService] Error fetching patient sensitive info ${patientId}:`, error);
|
|
3749
3749
|
return null;
|
|
@@ -3760,12 +3760,12 @@ var AppointmentAggregationService = class {
|
|
|
3760
3760
|
return null;
|
|
3761
3761
|
}
|
|
3762
3762
|
try {
|
|
3763
|
-
const
|
|
3764
|
-
if (!
|
|
3763
|
+
const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
|
|
3764
|
+
if (!doc3.exists) {
|
|
3765
3765
|
Logger.warn(`[AggService] No practitioner profile found for ID ${practitionerId}`);
|
|
3766
3766
|
return null;
|
|
3767
3767
|
}
|
|
3768
|
-
return
|
|
3768
|
+
return doc3.data();
|
|
3769
3769
|
} catch (error) {
|
|
3770
3770
|
Logger.error(`[AggService] Error fetching practitioner profile ${practitionerId}:`, error);
|
|
3771
3771
|
return null;
|
|
@@ -3782,12 +3782,12 @@ var AppointmentAggregationService = class {
|
|
|
3782
3782
|
return null;
|
|
3783
3783
|
}
|
|
3784
3784
|
try {
|
|
3785
|
-
const
|
|
3786
|
-
if (!
|
|
3785
|
+
const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
|
|
3786
|
+
if (!doc3.exists) {
|
|
3787
3787
|
Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
|
|
3788
3788
|
return null;
|
|
3789
3789
|
}
|
|
3790
|
-
return
|
|
3790
|
+
return doc3.data();
|
|
3791
3791
|
} catch (error) {
|
|
3792
3792
|
Logger.error(`[AggService] Error fetching clinic info ${clinicId}:`, error);
|
|
3793
3793
|
return null;
|
|
@@ -4254,11 +4254,11 @@ var ClinicAggregationService = class {
|
|
|
4254
4254
|
return;
|
|
4255
4255
|
}
|
|
4256
4256
|
const batch = this.db.batch();
|
|
4257
|
-
snapshot.docs.forEach((
|
|
4257
|
+
snapshot.docs.forEach((doc3) => {
|
|
4258
4258
|
console.log(
|
|
4259
|
-
`[ClinicAggregationService] Updating location for calendar event ${
|
|
4259
|
+
`[ClinicAggregationService] Updating location for calendar event ${doc3.ref.path}`
|
|
4260
4260
|
);
|
|
4261
|
-
batch.update(
|
|
4261
|
+
batch.update(doc3.ref, {
|
|
4262
4262
|
eventLocation: newLocation,
|
|
4263
4263
|
updatedAt: admin7.firestore.FieldValue.serverTimestamp()
|
|
4264
4264
|
});
|
|
@@ -4301,11 +4301,11 @@ var ClinicAggregationService = class {
|
|
|
4301
4301
|
return;
|
|
4302
4302
|
}
|
|
4303
4303
|
const batch = this.db.batch();
|
|
4304
|
-
snapshot.docs.forEach((
|
|
4304
|
+
snapshot.docs.forEach((doc3) => {
|
|
4305
4305
|
console.log(
|
|
4306
|
-
`[ClinicAggregationService] Updating clinic info for calendar event ${
|
|
4306
|
+
`[ClinicAggregationService] Updating clinic info for calendar event ${doc3.ref.path}`
|
|
4307
4307
|
);
|
|
4308
|
-
batch.update(
|
|
4308
|
+
batch.update(doc3.ref, {
|
|
4309
4309
|
clinicInfo,
|
|
4310
4310
|
updatedAt: admin7.firestore.FieldValue.serverTimestamp()
|
|
4311
4311
|
});
|
|
@@ -4516,11 +4516,11 @@ var ClinicAggregationService = class {
|
|
|
4516
4516
|
return;
|
|
4517
4517
|
}
|
|
4518
4518
|
const batch = this.db.batch();
|
|
4519
|
-
snapshot.docs.forEach((
|
|
4519
|
+
snapshot.docs.forEach((doc3) => {
|
|
4520
4520
|
console.log(
|
|
4521
|
-
`[ClinicAggregationService] Canceling calendar event ${
|
|
4521
|
+
`[ClinicAggregationService] Canceling calendar event ${doc3.ref.path}`
|
|
4522
4522
|
);
|
|
4523
|
-
batch.update(
|
|
4523
|
+
batch.update(doc3.ref, {
|
|
4524
4524
|
status: "CANCELED",
|
|
4525
4525
|
cancelReason: "Clinic deleted",
|
|
4526
4526
|
updatedAt: admin7.firestore.FieldValue.serverTimestamp()
|
|
@@ -4788,11 +4788,11 @@ var PatientAggregationService = class {
|
|
|
4788
4788
|
return;
|
|
4789
4789
|
}
|
|
4790
4790
|
const batch = this.db.batch();
|
|
4791
|
-
snapshot.docs.forEach((
|
|
4791
|
+
snapshot.docs.forEach((doc3) => {
|
|
4792
4792
|
console.log(
|
|
4793
|
-
`[PatientAggregationService] Updating patient info for calendar event ${
|
|
4793
|
+
`[PatientAggregationService] Updating patient info for calendar event ${doc3.ref.path}`
|
|
4794
4794
|
);
|
|
4795
|
-
batch.update(
|
|
4795
|
+
batch.update(doc3.ref, {
|
|
4796
4796
|
patientInfo,
|
|
4797
4797
|
updatedAt: admin9.firestore.FieldValue.serverTimestamp()
|
|
4798
4798
|
});
|
|
@@ -4836,11 +4836,11 @@ var PatientAggregationService = class {
|
|
|
4836
4836
|
return;
|
|
4837
4837
|
}
|
|
4838
4838
|
const batch = this.db.batch();
|
|
4839
|
-
snapshot.docs.forEach((
|
|
4839
|
+
snapshot.docs.forEach((doc3) => {
|
|
4840
4840
|
console.log(
|
|
4841
|
-
`[PatientAggregationService] Canceling calendar event ${
|
|
4841
|
+
`[PatientAggregationService] Canceling calendar event ${doc3.ref.path}`
|
|
4842
4842
|
);
|
|
4843
|
-
batch.update(
|
|
4843
|
+
batch.update(doc3.ref, {
|
|
4844
4844
|
status: "CANCELED",
|
|
4845
4845
|
cancelReason: "Patient deleted",
|
|
4846
4846
|
updatedAt: admin9.firestore.FieldValue.serverTimestamp()
|
|
@@ -5011,11 +5011,11 @@ var PractitionerAggregationService = class {
|
|
|
5011
5011
|
return;
|
|
5012
5012
|
}
|
|
5013
5013
|
const batch = this.db.batch();
|
|
5014
|
-
snapshot.docs.forEach((
|
|
5014
|
+
snapshot.docs.forEach((doc3) => {
|
|
5015
5015
|
console.log(
|
|
5016
|
-
`[PractitionerAggregationService] Updating practitioner info for calendar event ${
|
|
5016
|
+
`[PractitionerAggregationService] Updating practitioner info for calendar event ${doc3.ref.path}`
|
|
5017
5017
|
);
|
|
5018
|
-
batch.update(
|
|
5018
|
+
batch.update(doc3.ref, {
|
|
5019
5019
|
practitionerInfo,
|
|
5020
5020
|
updatedAt: admin10.firestore.FieldValue.serverTimestamp()
|
|
5021
5021
|
});
|
|
@@ -5099,11 +5099,11 @@ var PractitionerAggregationService = class {
|
|
|
5099
5099
|
return;
|
|
5100
5100
|
}
|
|
5101
5101
|
const batch = this.db.batch();
|
|
5102
|
-
snapshot.docs.forEach((
|
|
5102
|
+
snapshot.docs.forEach((doc3) => {
|
|
5103
5103
|
console.log(
|
|
5104
|
-
`[PractitionerAggregationService] Canceling calendar event ${
|
|
5104
|
+
`[PractitionerAggregationService] Canceling calendar event ${doc3.ref.path}`
|
|
5105
5105
|
);
|
|
5106
|
-
batch.update(
|
|
5106
|
+
batch.update(doc3.ref, {
|
|
5107
5107
|
status: "CANCELED",
|
|
5108
5108
|
cancelReason: "Practitioner deleted",
|
|
5109
5109
|
updatedAt: admin10.firestore.FieldValue.serverTimestamp()
|
|
@@ -5655,8 +5655,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5655
5655
|
*/
|
|
5656
5656
|
async fetchClinicAdminById(adminId) {
|
|
5657
5657
|
try {
|
|
5658
|
-
const
|
|
5659
|
-
return
|
|
5658
|
+
const doc3 = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
|
|
5659
|
+
return doc3.exists ? doc3.data() : null;
|
|
5660
5660
|
} catch (error) {
|
|
5661
5661
|
Logger.error(
|
|
5662
5662
|
`[PractitionerInviteAggService] Error fetching clinic admin ${adminId}:`,
|
|
@@ -5672,8 +5672,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5672
5672
|
*/
|
|
5673
5673
|
async fetchPractitionerById(practitionerId) {
|
|
5674
5674
|
try {
|
|
5675
|
-
const
|
|
5676
|
-
return
|
|
5675
|
+
const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
|
|
5676
|
+
return doc3.exists ? doc3.data() : null;
|
|
5677
5677
|
} catch (error) {
|
|
5678
5678
|
Logger.error(
|
|
5679
5679
|
`[PractitionerInviteAggService] Error fetching practitioner ${practitionerId}:`,
|
|
@@ -5689,8 +5689,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5689
5689
|
*/
|
|
5690
5690
|
async fetchClinicById(clinicId) {
|
|
5691
5691
|
try {
|
|
5692
|
-
const
|
|
5693
|
-
return
|
|
5692
|
+
const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
|
|
5693
|
+
return doc3.exists ? doc3.data() : null;
|
|
5694
5694
|
} catch (error) {
|
|
5695
5695
|
Logger.error(
|
|
5696
5696
|
`[PractitionerInviteAggService] Error fetching clinic ${clinicId}:`,
|
|
@@ -6125,11 +6125,11 @@ var ProcedureAggregationService = class {
|
|
|
6125
6125
|
return;
|
|
6126
6126
|
}
|
|
6127
6127
|
const batch = this.db.batch();
|
|
6128
|
-
snapshot.docs.forEach((
|
|
6128
|
+
snapshot.docs.forEach((doc3) => {
|
|
6129
6129
|
console.log(
|
|
6130
|
-
`[ProcedureAggregationService] Updating procedure info for calendar event ${
|
|
6130
|
+
`[ProcedureAggregationService] Updating procedure info for calendar event ${doc3.ref.path}`
|
|
6131
6131
|
);
|
|
6132
|
-
batch.update(
|
|
6132
|
+
batch.update(doc3.ref, {
|
|
6133
6133
|
procedureInfo,
|
|
6134
6134
|
updatedAt: admin12.firestore.FieldValue.serverTimestamp()
|
|
6135
6135
|
});
|
|
@@ -6172,11 +6172,11 @@ var ProcedureAggregationService = class {
|
|
|
6172
6172
|
return;
|
|
6173
6173
|
}
|
|
6174
6174
|
const batch = this.db.batch();
|
|
6175
|
-
snapshot.docs.forEach((
|
|
6175
|
+
snapshot.docs.forEach((doc3) => {
|
|
6176
6176
|
console.log(
|
|
6177
|
-
`[ProcedureAggregationService] Canceling calendar event ${
|
|
6177
|
+
`[ProcedureAggregationService] Canceling calendar event ${doc3.ref.path}`
|
|
6178
6178
|
);
|
|
6179
|
-
batch.update(
|
|
6179
|
+
batch.update(doc3.ref, {
|
|
6180
6180
|
status: "CANCELED",
|
|
6181
6181
|
cancelReason: "Procedure deleted or inactivated",
|
|
6182
6182
|
updatedAt: admin12.firestore.FieldValue.serverTimestamp()
|
|
@@ -6557,7 +6557,7 @@ var ReviewsAggregationService = class {
|
|
|
6557
6557
|
);
|
|
6558
6558
|
return updatedReviewInfo2;
|
|
6559
6559
|
}
|
|
6560
|
-
const reviews = reviewsQuery.docs.map((
|
|
6560
|
+
const reviews = reviewsQuery.docs.map((doc3) => doc3.data());
|
|
6561
6561
|
const clinicReviews = reviews.map((review) => review.clinicReview).filter((review) => review !== void 0);
|
|
6562
6562
|
let totalRating = 0;
|
|
6563
6563
|
let totalCleanliness = 0;
|
|
@@ -6647,7 +6647,7 @@ var ReviewsAggregationService = class {
|
|
|
6647
6647
|
);
|
|
6648
6648
|
return updatedReviewInfo2;
|
|
6649
6649
|
}
|
|
6650
|
-
const reviews = reviewsQuery.docs.map((
|
|
6650
|
+
const reviews = reviewsQuery.docs.map((doc3) => doc3.data());
|
|
6651
6651
|
const practitionerReviews = reviews.map((review) => review.practitionerReview).filter((review) => review !== void 0);
|
|
6652
6652
|
let totalRating = 0;
|
|
6653
6653
|
let totalKnowledgeAndExpertise = 0;
|
|
@@ -6720,7 +6720,7 @@ var ReviewsAggregationService = class {
|
|
|
6720
6720
|
recommendationPercentage: 0
|
|
6721
6721
|
};
|
|
6722
6722
|
const allReviewsQuery = await this.db.collection(REVIEWS_COLLECTION).get();
|
|
6723
|
-
const reviews = allReviewsQuery.docs.map((
|
|
6723
|
+
const reviews = allReviewsQuery.docs.map((doc3) => doc3.data());
|
|
6724
6724
|
const procedureReviews = [];
|
|
6725
6725
|
reviews.forEach((review) => {
|
|
6726
6726
|
if (review.procedureReview && review.procedureReview.procedureId === procedureId) {
|
|
@@ -6890,7 +6890,7 @@ var ReviewsAggregationService = class {
|
|
|
6890
6890
|
import * as admin14 from "firebase-admin";
|
|
6891
6891
|
|
|
6892
6892
|
// src/services/analytics/analytics.service.ts
|
|
6893
|
-
import { where, Timestamp as
|
|
6893
|
+
import { where as where2, Timestamp as Timestamp3 } from "firebase/firestore";
|
|
6894
6894
|
|
|
6895
6895
|
// src/services/base.service.ts
|
|
6896
6896
|
import { getStorage } from "firebase/storage";
|
|
@@ -6990,7 +6990,9 @@ function extractProductUsage(appointment) {
|
|
|
6990
6990
|
if (item.type === "item" && item.productId) {
|
|
6991
6991
|
const price = item.priceOverrideAmount || item.price || 0;
|
|
6992
6992
|
const quantity = item.quantity || 1;
|
|
6993
|
-
const
|
|
6993
|
+
const calculatedSubtotal = price * quantity;
|
|
6994
|
+
const storedSubtotal = item.subtotal || 0;
|
|
6995
|
+
const subtotal = Math.abs(storedSubtotal - calculatedSubtotal) < 0.01 ? storedSubtotal : calculatedSubtotal;
|
|
6994
6996
|
products.push({
|
|
6995
6997
|
productId: item.productId,
|
|
6996
6998
|
productName: item.productName || "Unknown Product",
|
|
@@ -7119,6 +7121,17 @@ function calculateCancellationLeadTime(appointment) {
|
|
|
7119
7121
|
}
|
|
7120
7122
|
|
|
7121
7123
|
// src/services/analytics/utils/appointment-filtering.utils.ts
|
|
7124
|
+
function filterByDateRange(appointments, dateRange) {
|
|
7125
|
+
if (!dateRange) {
|
|
7126
|
+
return appointments;
|
|
7127
|
+
}
|
|
7128
|
+
const startTime = dateRange.start.getTime();
|
|
7129
|
+
const endTime = dateRange.end.getTime();
|
|
7130
|
+
return appointments.filter((appointment) => {
|
|
7131
|
+
const appointmentTime = appointment.appointmentStartTime.toMillis();
|
|
7132
|
+
return appointmentTime >= startTime && appointmentTime <= endTime;
|
|
7133
|
+
});
|
|
7134
|
+
}
|
|
7122
7135
|
function filterAppointments(appointments, filters) {
|
|
7123
7136
|
if (!filters) {
|
|
7124
7137
|
return appointments;
|
|
@@ -7394,6 +7407,7 @@ function calculateGroupedRevenueMetrics(appointments, entityType) {
|
|
|
7394
7407
|
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7395
7408
|
const completed = getCompletedAppointments(appointments);
|
|
7396
7409
|
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7410
|
+
var _a;
|
|
7397
7411
|
const entityAppointments = data.appointments;
|
|
7398
7412
|
const entityCompleted = entityAppointments.filter(
|
|
7399
7413
|
(a) => completed.some((c) => c.id === a.id)
|
|
@@ -7417,6 +7431,13 @@ function calculateGroupedRevenueMetrics(appointments, entityType) {
|
|
|
7417
7431
|
refundedRevenue += costData.cost;
|
|
7418
7432
|
}
|
|
7419
7433
|
});
|
|
7434
|
+
let practitionerId;
|
|
7435
|
+
let practitionerName;
|
|
7436
|
+
if (entityType === "procedure" && entityAppointments.length > 0) {
|
|
7437
|
+
const firstAppointment = entityAppointments[0];
|
|
7438
|
+
practitionerId = firstAppointment.practitionerId;
|
|
7439
|
+
practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
|
|
7440
|
+
}
|
|
7420
7441
|
return {
|
|
7421
7442
|
entityId,
|
|
7422
7443
|
entityName: data.name,
|
|
@@ -7429,7 +7450,9 @@ function calculateGroupedRevenueMetrics(appointments, entityType) {
|
|
|
7429
7450
|
unpaidRevenue,
|
|
7430
7451
|
refundedRevenue,
|
|
7431
7452
|
totalTax,
|
|
7432
|
-
totalSubtotal
|
|
7453
|
+
totalSubtotal,
|
|
7454
|
+
...practitionerId && { practitionerId },
|
|
7455
|
+
...practitionerName && { practitionerName }
|
|
7433
7456
|
};
|
|
7434
7457
|
});
|
|
7435
7458
|
}
|
|
@@ -7437,6 +7460,7 @@ function calculateGroupedProductUsageMetrics(appointments, entityType) {
|
|
|
7437
7460
|
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7438
7461
|
const completed = getCompletedAppointments(appointments);
|
|
7439
7462
|
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7463
|
+
var _a;
|
|
7440
7464
|
const entityAppointments = data.appointments;
|
|
7441
7465
|
const entityCompleted = entityAppointments.filter(
|
|
7442
7466
|
(a) => completed.some((c) => c.id === a.id)
|
|
@@ -7471,6 +7495,13 @@ function calculateGroupedProductUsageMetrics(appointments, entityType) {
|
|
|
7471
7495
|
})).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 10);
|
|
7472
7496
|
const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
|
|
7473
7497
|
const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
|
|
7498
|
+
let practitionerId;
|
|
7499
|
+
let practitionerName;
|
|
7500
|
+
if (entityType === "procedure" && entityAppointments.length > 0) {
|
|
7501
|
+
const firstAppointment = entityAppointments[0];
|
|
7502
|
+
practitionerId = firstAppointment.practitionerId;
|
|
7503
|
+
practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
|
|
7504
|
+
}
|
|
7474
7505
|
return {
|
|
7475
7506
|
entityId,
|
|
7476
7507
|
entityName: data.name,
|
|
@@ -7480,7 +7511,9 @@ function calculateGroupedProductUsageMetrics(appointments, entityType) {
|
|
|
7480
7511
|
totalProductRevenue,
|
|
7481
7512
|
totalProductQuantity,
|
|
7482
7513
|
averageProductsPerAppointment: entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
|
|
7483
|
-
topProducts
|
|
7514
|
+
topProducts,
|
|
7515
|
+
...practitionerId && { practitionerId },
|
|
7516
|
+
...practitionerName && { practitionerName }
|
|
7484
7517
|
};
|
|
7485
7518
|
});
|
|
7486
7519
|
}
|
|
@@ -7488,11 +7521,19 @@ function calculateGroupedTimeEfficiencyMetrics(appointments, entityType) {
|
|
|
7488
7521
|
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7489
7522
|
const completed = getCompletedAppointments(appointments);
|
|
7490
7523
|
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7524
|
+
var _a;
|
|
7491
7525
|
const entityAppointments = data.appointments;
|
|
7492
7526
|
const entityCompleted = entityAppointments.filter(
|
|
7493
7527
|
(a) => completed.some((c) => c.id === a.id)
|
|
7494
7528
|
);
|
|
7495
7529
|
const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
|
|
7530
|
+
let practitionerId;
|
|
7531
|
+
let practitionerName;
|
|
7532
|
+
if (entityType === "procedure" && entityAppointments.length > 0) {
|
|
7533
|
+
const firstAppointment = entityAppointments[0];
|
|
7534
|
+
practitionerId = firstAppointment.practitionerId;
|
|
7535
|
+
practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
|
|
7536
|
+
}
|
|
7496
7537
|
return {
|
|
7497
7538
|
entityId,
|
|
7498
7539
|
entityName: data.name,
|
|
@@ -7505,7 +7546,9 @@ function calculateGroupedTimeEfficiencyMetrics(appointments, entityType) {
|
|
|
7505
7546
|
totalOverrun: timeMetrics.totalOverrun,
|
|
7506
7547
|
totalUnderutilization: timeMetrics.totalUnderutilization,
|
|
7507
7548
|
averageOverrun: timeMetrics.averageOverrun,
|
|
7508
|
-
averageUnderutilization: timeMetrics.averageUnderutilization
|
|
7549
|
+
averageUnderutilization: timeMetrics.averageUnderutilization,
|
|
7550
|
+
...practitionerId && { practitionerId },
|
|
7551
|
+
...practitionerName && { practitionerName }
|
|
7509
7552
|
};
|
|
7510
7553
|
});
|
|
7511
7554
|
}
|
|
@@ -7587,6 +7630,763 @@ function calculateGroupedPatientBehaviorMetrics(appointments, entityType) {
|
|
|
7587
7630
|
});
|
|
7588
7631
|
}
|
|
7589
7632
|
|
|
7633
|
+
// src/services/analytics/utils/trend-calculation.utils.ts
|
|
7634
|
+
function getPeriodDates(date, period) {
|
|
7635
|
+
const year = date.getFullYear();
|
|
7636
|
+
const month = date.getMonth();
|
|
7637
|
+
const day = date.getDate();
|
|
7638
|
+
let startDate;
|
|
7639
|
+
let endDate;
|
|
7640
|
+
let periodString;
|
|
7641
|
+
switch (period) {
|
|
7642
|
+
case "week": {
|
|
7643
|
+
const dayOfWeek = date.getDay();
|
|
7644
|
+
const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
|
|
7645
|
+
startDate = new Date(year, month, diff);
|
|
7646
|
+
startDate.setHours(0, 0, 0, 0);
|
|
7647
|
+
endDate = new Date(startDate);
|
|
7648
|
+
endDate.setDate(endDate.getDate() + 6);
|
|
7649
|
+
endDate.setHours(23, 59, 59, 999);
|
|
7650
|
+
const weekNumber = getWeekNumber(date);
|
|
7651
|
+
periodString = `${year}-W${weekNumber.toString().padStart(2, "0")}`;
|
|
7652
|
+
break;
|
|
7653
|
+
}
|
|
7654
|
+
case "month": {
|
|
7655
|
+
startDate = new Date(year, month, 1);
|
|
7656
|
+
startDate.setHours(0, 0, 0, 0);
|
|
7657
|
+
endDate = new Date(year, month + 1, 0);
|
|
7658
|
+
endDate.setHours(23, 59, 59, 999);
|
|
7659
|
+
periodString = `${year}-${(month + 1).toString().padStart(2, "0")}`;
|
|
7660
|
+
break;
|
|
7661
|
+
}
|
|
7662
|
+
case "quarter": {
|
|
7663
|
+
const quarter = Math.floor(month / 3);
|
|
7664
|
+
const quarterStartMonth = quarter * 3;
|
|
7665
|
+
startDate = new Date(year, quarterStartMonth, 1);
|
|
7666
|
+
startDate.setHours(0, 0, 0, 0);
|
|
7667
|
+
endDate = new Date(year, quarterStartMonth + 3, 0);
|
|
7668
|
+
endDate.setHours(23, 59, 59, 999);
|
|
7669
|
+
periodString = `${year}-Q${quarter + 1}`;
|
|
7670
|
+
break;
|
|
7671
|
+
}
|
|
7672
|
+
case "year": {
|
|
7673
|
+
startDate = new Date(year, 0, 1);
|
|
7674
|
+
startDate.setHours(0, 0, 0, 0);
|
|
7675
|
+
endDate = new Date(year, 11, 31);
|
|
7676
|
+
endDate.setHours(23, 59, 59, 999);
|
|
7677
|
+
periodString = `${year}`;
|
|
7678
|
+
break;
|
|
7679
|
+
}
|
|
7680
|
+
}
|
|
7681
|
+
return {
|
|
7682
|
+
period: periodString,
|
|
7683
|
+
startDate,
|
|
7684
|
+
endDate
|
|
7685
|
+
};
|
|
7686
|
+
}
|
|
7687
|
+
function getWeekNumber(date) {
|
|
7688
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
7689
|
+
const dayNum = d.getUTCDay() || 7;
|
|
7690
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
7691
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
7692
|
+
return Math.ceil(((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
|
|
7693
|
+
}
|
|
7694
|
+
function groupAppointmentsByPeriod(appointments, period) {
|
|
7695
|
+
const periodMap = /* @__PURE__ */ new Map();
|
|
7696
|
+
appointments.forEach((appointment) => {
|
|
7697
|
+
const appointmentDate = appointment.appointmentStartTime.toDate();
|
|
7698
|
+
const periodInfo = getPeriodDates(appointmentDate, period);
|
|
7699
|
+
const periodKey = periodInfo.period;
|
|
7700
|
+
if (!periodMap.has(periodKey)) {
|
|
7701
|
+
periodMap.set(periodKey, []);
|
|
7702
|
+
}
|
|
7703
|
+
periodMap.get(periodKey).push(appointment);
|
|
7704
|
+
});
|
|
7705
|
+
return periodMap;
|
|
7706
|
+
}
|
|
7707
|
+
function generatePeriods(startDate, endDate, period) {
|
|
7708
|
+
const periods = [];
|
|
7709
|
+
const current = new Date(startDate);
|
|
7710
|
+
while (current <= endDate) {
|
|
7711
|
+
const periodInfo = getPeriodDates(current, period);
|
|
7712
|
+
if (periodInfo.endDate >= startDate && periodInfo.startDate <= endDate) {
|
|
7713
|
+
periods.push(periodInfo);
|
|
7714
|
+
}
|
|
7715
|
+
switch (period) {
|
|
7716
|
+
case "week":
|
|
7717
|
+
current.setDate(current.getDate() + 7);
|
|
7718
|
+
break;
|
|
7719
|
+
case "month":
|
|
7720
|
+
current.setMonth(current.getMonth() + 1);
|
|
7721
|
+
break;
|
|
7722
|
+
case "quarter":
|
|
7723
|
+
current.setMonth(current.getMonth() + 3);
|
|
7724
|
+
break;
|
|
7725
|
+
case "year":
|
|
7726
|
+
current.setFullYear(current.getFullYear() + 1);
|
|
7727
|
+
break;
|
|
7728
|
+
}
|
|
7729
|
+
}
|
|
7730
|
+
return periods.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
|
7731
|
+
}
|
|
7732
|
+
function calculatePercentageChange(current, previous) {
|
|
7733
|
+
if (previous === 0) {
|
|
7734
|
+
return current > 0 ? 100 : 0;
|
|
7735
|
+
}
|
|
7736
|
+
return (current - previous) / previous * 100;
|
|
7737
|
+
}
|
|
7738
|
+
function getTrendChange(current, previous) {
|
|
7739
|
+
const percentageChange = calculatePercentageChange(current, previous);
|
|
7740
|
+
const direction = percentageChange > 0.01 ? "up" : percentageChange < -0.01 ? "down" : "stable";
|
|
7741
|
+
return {
|
|
7742
|
+
value: current,
|
|
7743
|
+
previousValue: previous,
|
|
7744
|
+
percentageChange: Math.abs(percentageChange),
|
|
7745
|
+
direction
|
|
7746
|
+
};
|
|
7747
|
+
}
|
|
7748
|
+
|
|
7749
|
+
// src/services/analytics/review-analytics.service.ts
|
|
7750
|
+
import { collection, query, where, getDocs, getDoc as getDoc2, doc as doc2, Timestamp as Timestamp2 } from "firebase/firestore";
|
|
7751
|
+
var ReviewAnalyticsService = class extends BaseService {
|
|
7752
|
+
constructor(db, auth, app, appointmentService) {
|
|
7753
|
+
super(db, auth, app);
|
|
7754
|
+
this.appointmentService = appointmentService;
|
|
7755
|
+
}
|
|
7756
|
+
/**
|
|
7757
|
+
* Fetches reviews filtered by date range and optional filters
|
|
7758
|
+
* Properly filters by clinic branch by checking appointment's clinicId
|
|
7759
|
+
*/
|
|
7760
|
+
async fetchReviews(dateRange, filters) {
|
|
7761
|
+
let q = query(collection(this.db, REVIEWS_COLLECTION));
|
|
7762
|
+
if (dateRange) {
|
|
7763
|
+
const startTimestamp = Timestamp2.fromDate(dateRange.start);
|
|
7764
|
+
const endTimestamp = Timestamp2.fromDate(dateRange.end);
|
|
7765
|
+
q = query(q, where("createdAt", ">=", startTimestamp), where("createdAt", "<=", endTimestamp));
|
|
7766
|
+
}
|
|
7767
|
+
const snapshot = await getDocs(q);
|
|
7768
|
+
const reviews = snapshot.docs.map((doc3) => {
|
|
7769
|
+
var _a, _b;
|
|
7770
|
+
const data = doc3.data();
|
|
7771
|
+
return {
|
|
7772
|
+
...data,
|
|
7773
|
+
id: doc3.id,
|
|
7774
|
+
createdAt: ((_a = data.createdAt) == null ? void 0 : _a.toDate) ? data.createdAt.toDate() : new Date(data.createdAt),
|
|
7775
|
+
updatedAt: ((_b = data.updatedAt) == null ? void 0 : _b.toDate) ? data.updatedAt.toDate() : new Date(data.updatedAt)
|
|
7776
|
+
};
|
|
7777
|
+
});
|
|
7778
|
+
console.log(`[ReviewAnalytics] Fetched ${reviews.length} reviews in date range`);
|
|
7779
|
+
if ((filters == null ? void 0 : filters.clinicBranchId) && reviews.length > 0) {
|
|
7780
|
+
const appointmentIds = [...new Set(reviews.map((r) => r.appointmentId))];
|
|
7781
|
+
console.log(`[ReviewAnalytics] Filtering by clinic ${filters.clinicBranchId}, checking ${appointmentIds.length} appointments`);
|
|
7782
|
+
const validAppointmentIds = /* @__PURE__ */ new Set();
|
|
7783
|
+
for (let i = 0; i < appointmentIds.length; i += 10) {
|
|
7784
|
+
const batch = appointmentIds.slice(i, i + 10);
|
|
7785
|
+
const appointmentsQuery = query(
|
|
7786
|
+
collection(this.db, APPOINTMENTS_COLLECTION),
|
|
7787
|
+
where("id", "in", batch)
|
|
7788
|
+
);
|
|
7789
|
+
const appointmentSnapshot = await getDocs(appointmentsQuery);
|
|
7790
|
+
appointmentSnapshot.docs.forEach((doc3) => {
|
|
7791
|
+
const appointment = doc3.data();
|
|
7792
|
+
if (appointment.clinicBranchId === filters.clinicBranchId) {
|
|
7793
|
+
validAppointmentIds.add(doc3.id);
|
|
7794
|
+
}
|
|
7795
|
+
});
|
|
7796
|
+
}
|
|
7797
|
+
const filteredReviews = reviews.filter((review) => validAppointmentIds.has(review.appointmentId));
|
|
7798
|
+
console.log(`[ReviewAnalytics] After clinic filter: ${filteredReviews.length} reviews (from ${validAppointmentIds.size} valid appointments)`);
|
|
7799
|
+
return filteredReviews;
|
|
7800
|
+
}
|
|
7801
|
+
return reviews;
|
|
7802
|
+
}
|
|
7803
|
+
/**
|
|
7804
|
+
* Gets review metrics for a specific entity
|
|
7805
|
+
*/
|
|
7806
|
+
async getReviewMetricsByEntity(entityType, entityId, dateRange, filters) {
|
|
7807
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
7808
|
+
let relevantReviews = [];
|
|
7809
|
+
if (entityType === "practitioner") {
|
|
7810
|
+
relevantReviews = reviews.filter((r) => {
|
|
7811
|
+
var _a;
|
|
7812
|
+
return ((_a = r.practitionerReview) == null ? void 0 : _a.practitionerId) === entityId;
|
|
7813
|
+
});
|
|
7814
|
+
} else if (entityType === "procedure") {
|
|
7815
|
+
relevantReviews = reviews.filter((r) => {
|
|
7816
|
+
var _a;
|
|
7817
|
+
return ((_a = r.procedureReview) == null ? void 0 : _a.procedureId) === entityId;
|
|
7818
|
+
});
|
|
7819
|
+
} else if (entityType === "category" || entityType === "subcategory") {
|
|
7820
|
+
relevantReviews = reviews;
|
|
7821
|
+
}
|
|
7822
|
+
if (relevantReviews.length === 0) {
|
|
7823
|
+
return null;
|
|
7824
|
+
}
|
|
7825
|
+
return this.calculateReviewMetrics(relevantReviews, entityType, entityId);
|
|
7826
|
+
}
|
|
7827
|
+
/**
|
|
7828
|
+
* Gets review metrics for multiple entities (grouped)
|
|
7829
|
+
*/
|
|
7830
|
+
async getReviewMetricsByEntities(entityType, dateRange, filters) {
|
|
7831
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
7832
|
+
const entityMap = /* @__PURE__ */ new Map();
|
|
7833
|
+
let practitionerNameMap = null;
|
|
7834
|
+
let procedureNameMap = null;
|
|
7835
|
+
let procedureToTechnologyMap = null;
|
|
7836
|
+
if (entityType === "practitioner" || entityType === "procedure" || entityType === "technology") {
|
|
7837
|
+
if (!this.appointmentService) {
|
|
7838
|
+
console.warn(`[ReviewAnalytics] AppointmentService not available for ${entityType} name resolution`);
|
|
7839
|
+
return [];
|
|
7840
|
+
}
|
|
7841
|
+
console.log(`[ReviewAnalytics] Grouping by ${entityType}, fetching appointments for name resolution...`);
|
|
7842
|
+
const searchParams = {
|
|
7843
|
+
...filters
|
|
7844
|
+
};
|
|
7845
|
+
if (dateRange) {
|
|
7846
|
+
searchParams.startDate = dateRange.start;
|
|
7847
|
+
searchParams.endDate = dateRange.end;
|
|
7848
|
+
}
|
|
7849
|
+
const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
|
|
7850
|
+
const appointments = appointmentsResult.appointments || [];
|
|
7851
|
+
console.log(`[ReviewAnalytics] Found ${appointments.length} appointments for name resolution`);
|
|
7852
|
+
practitionerNameMap = /* @__PURE__ */ new Map();
|
|
7853
|
+
procedureNameMap = /* @__PURE__ */ new Map();
|
|
7854
|
+
procedureToTechnologyMap = /* @__PURE__ */ new Map();
|
|
7855
|
+
appointments.forEach((appointment) => {
|
|
7856
|
+
var _a, _b, _c, _d, _e, _f;
|
|
7857
|
+
if (appointment.practitionerId && ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name)) {
|
|
7858
|
+
practitionerNameMap.set(appointment.practitionerId, appointment.practitionerInfo.name);
|
|
7859
|
+
}
|
|
7860
|
+
if (appointment.procedureId) {
|
|
7861
|
+
if ((_b = appointment.procedureInfo) == null ? void 0 : _b.name) {
|
|
7862
|
+
procedureNameMap.set(appointment.procedureId, appointment.procedureInfo.name);
|
|
7863
|
+
}
|
|
7864
|
+
const mainTechnologyId = ((_c = appointment.procedureExtendedInfo) == null ? void 0 : _c.procedureTechnologyId) || "unknown-technology";
|
|
7865
|
+
const mainTechnologyName = ((_d = appointment.procedureExtendedInfo) == null ? void 0 : _d.procedureTechnologyName) || ((_e = appointment.procedureInfo) == null ? void 0 : _e.name) || "Unknown Technology";
|
|
7866
|
+
procedureToTechnologyMap.set(appointment.procedureId, {
|
|
7867
|
+
id: mainTechnologyId,
|
|
7868
|
+
name: mainTechnologyName
|
|
7869
|
+
});
|
|
7870
|
+
}
|
|
7871
|
+
if ((_f = appointment.metadata) == null ? void 0 : _f.extendedProcedures) {
|
|
7872
|
+
appointment.metadata.extendedProcedures.forEach((extendedProc) => {
|
|
7873
|
+
if (extendedProc.procedureId) {
|
|
7874
|
+
if (extendedProc.procedureName) {
|
|
7875
|
+
procedureNameMap.set(extendedProc.procedureId, extendedProc.procedureName);
|
|
7876
|
+
}
|
|
7877
|
+
const extTechnologyId = extendedProc.procedureTechnologyId || "unknown-technology";
|
|
7878
|
+
const extTechnologyName = extendedProc.procedureTechnologyName || "Unknown Technology";
|
|
7879
|
+
procedureToTechnologyMap.set(extendedProc.procedureId, {
|
|
7880
|
+
id: extTechnologyId,
|
|
7881
|
+
name: extTechnologyName
|
|
7882
|
+
});
|
|
7883
|
+
}
|
|
7884
|
+
});
|
|
7885
|
+
}
|
|
7886
|
+
});
|
|
7887
|
+
console.log(`[ReviewAnalytics] Built name maps: ${practitionerNameMap.size} practitioners, ${procedureNameMap.size} procedures, ${procedureToTechnologyMap.size} technologies`);
|
|
7888
|
+
}
|
|
7889
|
+
if (entityType === "technology" && procedureToTechnologyMap) {
|
|
7890
|
+
let processedReviewCount = 0;
|
|
7891
|
+
reviews.forEach((review) => {
|
|
7892
|
+
var _a;
|
|
7893
|
+
if ((_a = review.procedureReview) == null ? void 0 : _a.procedureId) {
|
|
7894
|
+
const techInfo = procedureToTechnologyMap.get(review.procedureReview.procedureId);
|
|
7895
|
+
if (techInfo) {
|
|
7896
|
+
if (!entityMap.has(techInfo.id)) {
|
|
7897
|
+
entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
|
|
7898
|
+
}
|
|
7899
|
+
entityMap.get(techInfo.id).reviews.push(review);
|
|
7900
|
+
processedReviewCount++;
|
|
7901
|
+
}
|
|
7902
|
+
}
|
|
7903
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
7904
|
+
review.extendedProcedureReviews.forEach((extendedReview) => {
|
|
7905
|
+
if (extendedReview.procedureId) {
|
|
7906
|
+
const techInfo = procedureToTechnologyMap.get(extendedReview.procedureId);
|
|
7907
|
+
if (techInfo) {
|
|
7908
|
+
if (!entityMap.has(techInfo.id)) {
|
|
7909
|
+
entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
|
|
7910
|
+
}
|
|
7911
|
+
const reviewWithExtendedOnly = {
|
|
7912
|
+
...review,
|
|
7913
|
+
procedureReview: extendedReview,
|
|
7914
|
+
extendedProcedureReviews: void 0
|
|
7915
|
+
};
|
|
7916
|
+
entityMap.get(techInfo.id).reviews.push(reviewWithExtendedOnly);
|
|
7917
|
+
processedReviewCount++;
|
|
7918
|
+
}
|
|
7919
|
+
}
|
|
7920
|
+
});
|
|
7921
|
+
}
|
|
7922
|
+
});
|
|
7923
|
+
console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} technology groups`);
|
|
7924
|
+
entityMap.forEach((data, techId) => {
|
|
7925
|
+
console.log(`[ReviewAnalytics] - ${data.name} (${techId}): ${data.reviews.length} reviews`);
|
|
7926
|
+
});
|
|
7927
|
+
} else if (entityType === "procedure" && procedureNameMap) {
|
|
7928
|
+
let processedReviewCount = 0;
|
|
7929
|
+
reviews.forEach((review) => {
|
|
7930
|
+
if (review.procedureReview) {
|
|
7931
|
+
const procedureId = review.procedureReview.procedureId;
|
|
7932
|
+
const procedureName = procedureId && procedureNameMap.get(procedureId) || review.procedureReview.procedureName || "Unknown Procedure";
|
|
7933
|
+
if (procedureId) {
|
|
7934
|
+
if (!entityMap.has(procedureId)) {
|
|
7935
|
+
entityMap.set(procedureId, { reviews: [], name: procedureName });
|
|
7936
|
+
}
|
|
7937
|
+
entityMap.get(procedureId).reviews.push(review);
|
|
7938
|
+
processedReviewCount++;
|
|
7939
|
+
}
|
|
7940
|
+
}
|
|
7941
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
7942
|
+
review.extendedProcedureReviews.forEach((extendedReview) => {
|
|
7943
|
+
const procedureId = extendedReview.procedureId;
|
|
7944
|
+
const procedureName = procedureId && procedureNameMap.get(procedureId) || extendedReview.procedureName || "Unknown Procedure";
|
|
7945
|
+
if (procedureId) {
|
|
7946
|
+
if (!entityMap.has(procedureId)) {
|
|
7947
|
+
entityMap.set(procedureId, { reviews: [], name: procedureName });
|
|
7948
|
+
}
|
|
7949
|
+
const reviewWithExtendedOnly = {
|
|
7950
|
+
...review,
|
|
7951
|
+
procedureReview: extendedReview,
|
|
7952
|
+
extendedProcedureReviews: void 0
|
|
7953
|
+
};
|
|
7954
|
+
entityMap.get(procedureId).reviews.push(reviewWithExtendedOnly);
|
|
7955
|
+
processedReviewCount++;
|
|
7956
|
+
}
|
|
7957
|
+
});
|
|
7958
|
+
}
|
|
7959
|
+
});
|
|
7960
|
+
console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} procedure groups`);
|
|
7961
|
+
entityMap.forEach((data, procId) => {
|
|
7962
|
+
console.log(`[ReviewAnalytics] - ${data.name} (${procId}): ${data.reviews.length} reviews`);
|
|
7963
|
+
});
|
|
7964
|
+
} else if (entityType === "practitioner" && practitionerNameMap) {
|
|
7965
|
+
reviews.forEach((review) => {
|
|
7966
|
+
if (review.practitionerReview) {
|
|
7967
|
+
const practitionerId = review.practitionerReview.practitionerId;
|
|
7968
|
+
const practitionerName = practitionerId && practitionerNameMap.get(practitionerId) || review.practitionerReview.practitionerName || "Unknown Practitioner";
|
|
7969
|
+
if (practitionerId) {
|
|
7970
|
+
if (!entityMap.has(practitionerId)) {
|
|
7971
|
+
entityMap.set(practitionerId, { reviews: [], name: practitionerName });
|
|
7972
|
+
}
|
|
7973
|
+
entityMap.get(practitionerId).reviews.push(review);
|
|
7974
|
+
}
|
|
7975
|
+
}
|
|
7976
|
+
});
|
|
7977
|
+
console.log(`[ReviewAnalytics] Processed ${reviews.length} reviews into ${entityMap.size} practitioner groups`);
|
|
7978
|
+
entityMap.forEach((data, practId) => {
|
|
7979
|
+
console.log(`[ReviewAnalytics] - ${data.name} (${practId}): ${data.reviews.length} reviews`);
|
|
7980
|
+
});
|
|
7981
|
+
} else {
|
|
7982
|
+
reviews.forEach((review) => {
|
|
7983
|
+
let entityId;
|
|
7984
|
+
let entityName;
|
|
7985
|
+
if (entityId) {
|
|
7986
|
+
if (!entityMap.has(entityId)) {
|
|
7987
|
+
entityMap.set(entityId, { reviews: [], name: entityName || entityId });
|
|
7988
|
+
}
|
|
7989
|
+
entityMap.get(entityId).reviews.push(review);
|
|
7990
|
+
}
|
|
7991
|
+
});
|
|
7992
|
+
}
|
|
7993
|
+
const metrics = [];
|
|
7994
|
+
for (const [entityId, data] of entityMap.entries()) {
|
|
7995
|
+
const metric = this.calculateReviewMetrics(data.reviews, entityType, entityId);
|
|
7996
|
+
if (metric) {
|
|
7997
|
+
metric.entityName = data.name;
|
|
7998
|
+
metrics.push(metric);
|
|
7999
|
+
}
|
|
8000
|
+
}
|
|
8001
|
+
return metrics;
|
|
8002
|
+
}
|
|
8003
|
+
/**
|
|
8004
|
+
* Calculates review metrics from a list of reviews
|
|
8005
|
+
*/
|
|
8006
|
+
calculateReviewMetrics(reviews, entityType, entityId) {
|
|
8007
|
+
if (reviews.length === 0) {
|
|
8008
|
+
return null;
|
|
8009
|
+
}
|
|
8010
|
+
let totalRating = 0;
|
|
8011
|
+
let recommendationCount = 0;
|
|
8012
|
+
let practitionerMetrics;
|
|
8013
|
+
let procedureMetrics;
|
|
8014
|
+
let entityName = entityId;
|
|
8015
|
+
if (entityType === "practitioner") {
|
|
8016
|
+
const practitionerReviews = reviews.filter((r) => r.practitionerReview).map((r) => r.practitionerReview);
|
|
8017
|
+
if (practitionerReviews.length === 0) {
|
|
8018
|
+
return null;
|
|
8019
|
+
}
|
|
8020
|
+
entityName = practitionerReviews[0].practitionerName || entityId;
|
|
8021
|
+
totalRating = practitionerReviews.reduce((sum, r) => sum + r.overallRating, 0);
|
|
8022
|
+
recommendationCount = practitionerReviews.filter((r) => r.wouldRecommend).length;
|
|
8023
|
+
practitionerMetrics = {
|
|
8024
|
+
averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map((r) => r.knowledgeAndExpertise)),
|
|
8025
|
+
averageCommunicationSkills: this.calculateAverage(practitionerReviews.map((r) => r.communicationSkills)),
|
|
8026
|
+
averageBedSideManner: this.calculateAverage(practitionerReviews.map((r) => r.bedSideManner)),
|
|
8027
|
+
averageThoroughness: this.calculateAverage(practitionerReviews.map((r) => r.thoroughness)),
|
|
8028
|
+
averageTrustworthiness: this.calculateAverage(practitionerReviews.map((r) => r.trustworthiness))
|
|
8029
|
+
};
|
|
8030
|
+
} else if (entityType === "procedure" || entityType === "technology") {
|
|
8031
|
+
const procedureReviews = reviews.filter((r) => r.procedureReview).map((r) => r.procedureReview);
|
|
8032
|
+
if (procedureReviews.length === 0) {
|
|
8033
|
+
return null;
|
|
8034
|
+
}
|
|
8035
|
+
if (entityType === "procedure") {
|
|
8036
|
+
entityName = procedureReviews[0].procedureName || entityId;
|
|
8037
|
+
}
|
|
8038
|
+
totalRating = procedureReviews.reduce((sum, r) => sum + r.overallRating, 0);
|
|
8039
|
+
recommendationCount = procedureReviews.filter((r) => r.wouldRecommend).length;
|
|
8040
|
+
procedureMetrics = {
|
|
8041
|
+
averageEffectiveness: this.calculateAverage(procedureReviews.map((r) => r.effectivenessOfTreatment)),
|
|
8042
|
+
averageOutcomeExplanation: this.calculateAverage(procedureReviews.map((r) => r.outcomeExplanation)),
|
|
8043
|
+
averagePainManagement: this.calculateAverage(procedureReviews.map((r) => r.painManagement)),
|
|
8044
|
+
averageFollowUpCare: this.calculateAverage(procedureReviews.map((r) => r.followUpCare)),
|
|
8045
|
+
averageValueForMoney: this.calculateAverage(procedureReviews.map((r) => r.valueForMoney))
|
|
8046
|
+
};
|
|
8047
|
+
}
|
|
8048
|
+
const averageRating = totalRating / reviews.length;
|
|
8049
|
+
const recommendationRate = recommendationCount / reviews.length * 100;
|
|
8050
|
+
const result = {
|
|
8051
|
+
entityId,
|
|
8052
|
+
entityName,
|
|
8053
|
+
entityType,
|
|
8054
|
+
totalReviews: reviews.length,
|
|
8055
|
+
averageRating,
|
|
8056
|
+
recommendationRate,
|
|
8057
|
+
practitionerMetrics,
|
|
8058
|
+
procedureMetrics,
|
|
8059
|
+
comparisonToOverall: {
|
|
8060
|
+
ratingDifference: 0,
|
|
8061
|
+
// Will be calculated when comparing to overall
|
|
8062
|
+
recommendationDifference: 0
|
|
8063
|
+
}
|
|
8064
|
+
};
|
|
8065
|
+
return result;
|
|
8066
|
+
}
|
|
8067
|
+
/**
|
|
8068
|
+
* Gets overall review averages for comparison
|
|
8069
|
+
*/
|
|
8070
|
+
async getOverallReviewAverages(dateRange, filters) {
|
|
8071
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
8072
|
+
const practitionerReviews = reviews.filter((r) => r.practitionerReview).map((r) => r.practitionerReview);
|
|
8073
|
+
const procedureReviews = reviews.filter((r) => r.procedureReview).map((r) => r.procedureReview);
|
|
8074
|
+
return {
|
|
8075
|
+
practitionerAverage: {
|
|
8076
|
+
totalReviews: practitionerReviews.length,
|
|
8077
|
+
averageRating: practitionerReviews.length > 0 ? this.calculateAverage(practitionerReviews.map((r) => r.overallRating)) : 0,
|
|
8078
|
+
recommendationRate: practitionerReviews.length > 0 ? practitionerReviews.filter((r) => r.wouldRecommend).length / practitionerReviews.length * 100 : 0,
|
|
8079
|
+
averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map((r) => r.knowledgeAndExpertise)),
|
|
8080
|
+
averageCommunicationSkills: this.calculateAverage(practitionerReviews.map((r) => r.communicationSkills)),
|
|
8081
|
+
averageBedSideManner: this.calculateAverage(practitionerReviews.map((r) => r.bedSideManner)),
|
|
8082
|
+
averageThoroughness: this.calculateAverage(practitionerReviews.map((r) => r.thoroughness)),
|
|
8083
|
+
averageTrustworthiness: this.calculateAverage(practitionerReviews.map((r) => r.trustworthiness))
|
|
8084
|
+
},
|
|
8085
|
+
procedureAverage: {
|
|
8086
|
+
totalReviews: procedureReviews.length,
|
|
8087
|
+
averageRating: procedureReviews.length > 0 ? this.calculateAverage(procedureReviews.map((r) => r.overallRating)) : 0,
|
|
8088
|
+
recommendationRate: procedureReviews.length > 0 ? procedureReviews.filter((r) => r.wouldRecommend).length / procedureReviews.length * 100 : 0,
|
|
8089
|
+
averageEffectiveness: this.calculateAverage(procedureReviews.map((r) => r.effectivenessOfTreatment)),
|
|
8090
|
+
averageOutcomeExplanation: this.calculateAverage(procedureReviews.map((r) => r.outcomeExplanation)),
|
|
8091
|
+
averagePainManagement: this.calculateAverage(procedureReviews.map((r) => r.painManagement)),
|
|
8092
|
+
averageFollowUpCare: this.calculateAverage(procedureReviews.map((r) => r.followUpCare)),
|
|
8093
|
+
averageValueForMoney: this.calculateAverage(procedureReviews.map((r) => r.valueForMoney))
|
|
8094
|
+
}
|
|
8095
|
+
};
|
|
8096
|
+
}
|
|
8097
|
+
/**
|
|
8098
|
+
* Gets review details for a specific entity
|
|
8099
|
+
*/
|
|
8100
|
+
async getReviewDetails(entityType, entityId, dateRange, filters) {
|
|
8101
|
+
var _a, _b, _c;
|
|
8102
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
8103
|
+
let relevantReviews = [];
|
|
8104
|
+
if (entityType === "practitioner") {
|
|
8105
|
+
relevantReviews = reviews.filter((r) => {
|
|
8106
|
+
var _a2;
|
|
8107
|
+
return ((_a2 = r.practitionerReview) == null ? void 0 : _a2.practitionerId) === entityId;
|
|
8108
|
+
});
|
|
8109
|
+
} else if (entityType === "procedure") {
|
|
8110
|
+
relevantReviews = reviews.filter((r) => {
|
|
8111
|
+
var _a2;
|
|
8112
|
+
return ((_a2 = r.procedureReview) == null ? void 0 : _a2.procedureId) === entityId;
|
|
8113
|
+
});
|
|
8114
|
+
}
|
|
8115
|
+
const details = [];
|
|
8116
|
+
for (const review of relevantReviews) {
|
|
8117
|
+
try {
|
|
8118
|
+
const appointmentDocRef = doc2(this.db, APPOINTMENTS_COLLECTION, review.appointmentId);
|
|
8119
|
+
const appointmentDoc = await getDoc2(appointmentDocRef);
|
|
8120
|
+
let appointment = null;
|
|
8121
|
+
if (appointmentDoc.exists()) {
|
|
8122
|
+
appointment = appointmentDoc.data();
|
|
8123
|
+
}
|
|
8124
|
+
const createdAt = review.createdAt instanceof Timestamp2 ? review.createdAt.toDate() : new Date(review.createdAt);
|
|
8125
|
+
const appointmentDate = (appointment == null ? void 0 : appointment.appointmentStartTime) ? appointment.appointmentStartTime instanceof Timestamp2 ? appointment.appointmentStartTime.toDate() : appointment.appointmentStartTime : createdAt;
|
|
8126
|
+
details.push({
|
|
8127
|
+
reviewId: review.id,
|
|
8128
|
+
appointmentId: review.appointmentId,
|
|
8129
|
+
patientId: review.patientId,
|
|
8130
|
+
patientName: review.patientName || ((_a = appointment == null ? void 0 : appointment.patientInfo) == null ? void 0 : _a.fullName),
|
|
8131
|
+
createdAt,
|
|
8132
|
+
practitionerReview: review.practitionerReview,
|
|
8133
|
+
procedureReview: review.procedureReview,
|
|
8134
|
+
procedureName: (_b = appointment == null ? void 0 : appointment.procedureInfo) == null ? void 0 : _b.name,
|
|
8135
|
+
practitionerName: (_c = appointment == null ? void 0 : appointment.practitionerInfo) == null ? void 0 : _c.name,
|
|
8136
|
+
appointmentDate
|
|
8137
|
+
});
|
|
8138
|
+
} catch (error) {
|
|
8139
|
+
console.warn(`Failed to enhance review ${review.id}:`, error);
|
|
8140
|
+
}
|
|
8141
|
+
}
|
|
8142
|
+
return details;
|
|
8143
|
+
}
|
|
8144
|
+
/**
|
|
8145
|
+
* Helper method to calculate average
|
|
8146
|
+
*/
|
|
8147
|
+
calculateAverage(values) {
|
|
8148
|
+
if (values.length === 0) return 0;
|
|
8149
|
+
const sum = values.reduce((acc, val) => acc + val, 0);
|
|
8150
|
+
return sum / values.length;
|
|
8151
|
+
}
|
|
8152
|
+
/**
|
|
8153
|
+
* Calculate review trends over time
|
|
8154
|
+
* Groups reviews by period and calculates rating and recommendation metrics
|
|
8155
|
+
*
|
|
8156
|
+
* @param dateRange - Date range for trend analysis (must align with period boundaries)
|
|
8157
|
+
* @param period - Period type (week, month, quarter, year)
|
|
8158
|
+
* @param filters - Optional filters for clinic, practitioner, procedure
|
|
8159
|
+
* @param entityType - Optional entity type to group trends by (practitioner, procedure, technology)
|
|
8160
|
+
* @returns Array of review trends with percentage changes
|
|
8161
|
+
*/
|
|
8162
|
+
async getReviewTrends(dateRange, period, filters, entityType) {
|
|
8163
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
8164
|
+
if (reviews.length === 0) {
|
|
8165
|
+
return [];
|
|
8166
|
+
}
|
|
8167
|
+
if (entityType) {
|
|
8168
|
+
return this.getGroupedReviewTrends(reviews, dateRange, period, entityType, filters);
|
|
8169
|
+
}
|
|
8170
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
8171
|
+
const trends = [];
|
|
8172
|
+
let previousAvgRating = 0;
|
|
8173
|
+
let previousRecRate = 0;
|
|
8174
|
+
periods.forEach((periodInfo) => {
|
|
8175
|
+
const periodReviews = reviews.filter((review) => {
|
|
8176
|
+
const reviewDate = review.createdAt instanceof Date ? review.createdAt : review.createdAt.toDate();
|
|
8177
|
+
return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
|
|
8178
|
+
});
|
|
8179
|
+
if (periodReviews.length === 0) {
|
|
8180
|
+
trends.push({
|
|
8181
|
+
period: periodInfo.period,
|
|
8182
|
+
startDate: periodInfo.startDate,
|
|
8183
|
+
endDate: periodInfo.endDate,
|
|
8184
|
+
averageRating: 0,
|
|
8185
|
+
recommendationRate: 0,
|
|
8186
|
+
totalReviews: 0,
|
|
8187
|
+
previousPeriod: void 0
|
|
8188
|
+
});
|
|
8189
|
+
previousAvgRating = 0;
|
|
8190
|
+
previousRecRate = 0;
|
|
8191
|
+
return;
|
|
8192
|
+
}
|
|
8193
|
+
let totalRatingSum = 0;
|
|
8194
|
+
let totalRatingCount = 0;
|
|
8195
|
+
let totalRecommendations = 0;
|
|
8196
|
+
let totalRecommendationCount = 0;
|
|
8197
|
+
periodReviews.forEach((review) => {
|
|
8198
|
+
if (review.practitionerReview) {
|
|
8199
|
+
totalRatingSum += review.practitionerReview.overallRating;
|
|
8200
|
+
totalRatingCount++;
|
|
8201
|
+
if (review.practitionerReview.wouldRecommend) {
|
|
8202
|
+
totalRecommendations++;
|
|
8203
|
+
}
|
|
8204
|
+
totalRecommendationCount++;
|
|
8205
|
+
}
|
|
8206
|
+
if (review.procedureReview) {
|
|
8207
|
+
totalRatingSum += review.procedureReview.overallRating;
|
|
8208
|
+
totalRatingCount++;
|
|
8209
|
+
if (review.procedureReview.wouldRecommend) {
|
|
8210
|
+
totalRecommendations++;
|
|
8211
|
+
}
|
|
8212
|
+
totalRecommendationCount++;
|
|
8213
|
+
}
|
|
8214
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
8215
|
+
review.extendedProcedureReviews.forEach((extReview) => {
|
|
8216
|
+
totalRatingSum += extReview.overallRating;
|
|
8217
|
+
totalRatingCount++;
|
|
8218
|
+
if (extReview.wouldRecommend) {
|
|
8219
|
+
totalRecommendations++;
|
|
8220
|
+
}
|
|
8221
|
+
totalRecommendationCount++;
|
|
8222
|
+
});
|
|
8223
|
+
}
|
|
8224
|
+
});
|
|
8225
|
+
const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
|
|
8226
|
+
const currentRecRate = totalRecommendationCount > 0 ? totalRecommendations / totalRecommendationCount * 100 : 0;
|
|
8227
|
+
const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
|
|
8228
|
+
trends.push({
|
|
8229
|
+
period: periodInfo.period,
|
|
8230
|
+
startDate: periodInfo.startDate,
|
|
8231
|
+
endDate: periodInfo.endDate,
|
|
8232
|
+
averageRating: currentAvgRating,
|
|
8233
|
+
recommendationRate: currentRecRate,
|
|
8234
|
+
totalReviews: periodReviews.length,
|
|
8235
|
+
previousPeriod: previousAvgRating > 0 ? {
|
|
8236
|
+
averageRating: previousAvgRating,
|
|
8237
|
+
recommendationRate: previousRecRate,
|
|
8238
|
+
percentageChange: Math.abs(trendChange.percentageChange),
|
|
8239
|
+
direction: trendChange.direction
|
|
8240
|
+
} : void 0
|
|
8241
|
+
});
|
|
8242
|
+
previousAvgRating = currentAvgRating;
|
|
8243
|
+
previousRecRate = currentRecRate;
|
|
8244
|
+
});
|
|
8245
|
+
return trends;
|
|
8246
|
+
}
|
|
8247
|
+
/**
|
|
8248
|
+
* Calculate grouped review trends (by practitioner, procedure, or technology)
|
|
8249
|
+
* Returns the AVERAGE across all entities of that type for each period
|
|
8250
|
+
* @private
|
|
8251
|
+
*/
|
|
8252
|
+
async getGroupedReviewTrends(reviews, dateRange, period, entityType, filters) {
|
|
8253
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
8254
|
+
const trends = [];
|
|
8255
|
+
let appointments = [];
|
|
8256
|
+
let procedureToTechnologyMap = /* @__PURE__ */ new Map();
|
|
8257
|
+
if (entityType === "technology" && this.appointmentService) {
|
|
8258
|
+
const searchParams = { ...filters };
|
|
8259
|
+
if (dateRange) {
|
|
8260
|
+
searchParams.startDate = dateRange.start;
|
|
8261
|
+
searchParams.endDate = dateRange.end;
|
|
8262
|
+
}
|
|
8263
|
+
const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
|
|
8264
|
+
appointments = appointmentsResult.appointments || [];
|
|
8265
|
+
appointments.forEach((appointment) => {
|
|
8266
|
+
var _a, _b;
|
|
8267
|
+
if (appointment.procedureId && ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId)) {
|
|
8268
|
+
procedureToTechnologyMap.set(appointment.procedureId, {
|
|
8269
|
+
id: appointment.procedureExtendedInfo.procedureTechnologyId,
|
|
8270
|
+
name: appointment.procedureExtendedInfo.procedureTechnologyName || "Unknown Technology"
|
|
8271
|
+
});
|
|
8272
|
+
}
|
|
8273
|
+
if ((_b = appointment.metadata) == null ? void 0 : _b.extendedProcedures) {
|
|
8274
|
+
appointment.metadata.extendedProcedures.forEach((extProc) => {
|
|
8275
|
+
if (extProc.procedureId && extProc.procedureTechnologyId) {
|
|
8276
|
+
procedureToTechnologyMap.set(extProc.procedureId, {
|
|
8277
|
+
id: extProc.procedureTechnologyId,
|
|
8278
|
+
name: extProc.procedureTechnologyName || "Unknown Technology"
|
|
8279
|
+
});
|
|
8280
|
+
}
|
|
8281
|
+
});
|
|
8282
|
+
}
|
|
8283
|
+
});
|
|
8284
|
+
}
|
|
8285
|
+
let previousAvgRating = 0;
|
|
8286
|
+
let previousRecRate = 0;
|
|
8287
|
+
periods.forEach((periodInfo) => {
|
|
8288
|
+
const periodReviews = reviews.filter((review) => {
|
|
8289
|
+
const reviewDate = review.createdAt instanceof Date ? review.createdAt : review.createdAt.toDate();
|
|
8290
|
+
return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
|
|
8291
|
+
});
|
|
8292
|
+
if (periodReviews.length === 0) {
|
|
8293
|
+
trends.push({
|
|
8294
|
+
period: periodInfo.period,
|
|
8295
|
+
startDate: periodInfo.startDate,
|
|
8296
|
+
endDate: periodInfo.endDate,
|
|
8297
|
+
averageRating: 0,
|
|
8298
|
+
recommendationRate: 0,
|
|
8299
|
+
totalReviews: 0,
|
|
8300
|
+
previousPeriod: void 0
|
|
8301
|
+
});
|
|
8302
|
+
previousAvgRating = 0;
|
|
8303
|
+
previousRecRate = 0;
|
|
8304
|
+
return;
|
|
8305
|
+
}
|
|
8306
|
+
let totalRatingSum = 0;
|
|
8307
|
+
let totalRatingCount = 0;
|
|
8308
|
+
let totalRecommendations = 0;
|
|
8309
|
+
let totalRecommendationCount = 0;
|
|
8310
|
+
periodReviews.forEach((review) => {
|
|
8311
|
+
var _a;
|
|
8312
|
+
if (entityType === "practitioner" && review.practitionerReview) {
|
|
8313
|
+
totalRatingSum += review.practitionerReview.overallRating;
|
|
8314
|
+
totalRatingCount++;
|
|
8315
|
+
if (review.practitionerReview.wouldRecommend) {
|
|
8316
|
+
totalRecommendations++;
|
|
8317
|
+
}
|
|
8318
|
+
totalRecommendationCount++;
|
|
8319
|
+
} else if (entityType === "procedure" && review.procedureReview) {
|
|
8320
|
+
totalRatingSum += review.procedureReview.overallRating;
|
|
8321
|
+
totalRatingCount++;
|
|
8322
|
+
if (review.procedureReview.wouldRecommend) {
|
|
8323
|
+
totalRecommendations++;
|
|
8324
|
+
}
|
|
8325
|
+
totalRecommendationCount++;
|
|
8326
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
8327
|
+
review.extendedProcedureReviews.forEach((extReview) => {
|
|
8328
|
+
totalRatingSum += extReview.overallRating;
|
|
8329
|
+
totalRatingCount++;
|
|
8330
|
+
if (extReview.wouldRecommend) {
|
|
8331
|
+
totalRecommendations++;
|
|
8332
|
+
}
|
|
8333
|
+
totalRecommendationCount++;
|
|
8334
|
+
});
|
|
8335
|
+
}
|
|
8336
|
+
} else if (entityType === "technology") {
|
|
8337
|
+
if ((_a = review.procedureReview) == null ? void 0 : _a.procedureId) {
|
|
8338
|
+
const tech = procedureToTechnologyMap.get(review.procedureReview.procedureId);
|
|
8339
|
+
if (tech) {
|
|
8340
|
+
totalRatingSum += review.procedureReview.overallRating;
|
|
8341
|
+
totalRatingCount++;
|
|
8342
|
+
if (review.procedureReview.wouldRecommend) {
|
|
8343
|
+
totalRecommendations++;
|
|
8344
|
+
}
|
|
8345
|
+
totalRecommendationCount++;
|
|
8346
|
+
}
|
|
8347
|
+
}
|
|
8348
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
8349
|
+
review.extendedProcedureReviews.forEach((extReview) => {
|
|
8350
|
+
if (extReview.procedureId) {
|
|
8351
|
+
const tech = procedureToTechnologyMap.get(extReview.procedureId);
|
|
8352
|
+
if (tech) {
|
|
8353
|
+
totalRatingSum += extReview.overallRating;
|
|
8354
|
+
totalRatingCount++;
|
|
8355
|
+
if (extReview.wouldRecommend) {
|
|
8356
|
+
totalRecommendations++;
|
|
8357
|
+
}
|
|
8358
|
+
totalRecommendationCount++;
|
|
8359
|
+
}
|
|
8360
|
+
}
|
|
8361
|
+
});
|
|
8362
|
+
}
|
|
8363
|
+
}
|
|
8364
|
+
});
|
|
8365
|
+
const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
|
|
8366
|
+
const currentRecRate = totalRecommendationCount > 0 ? totalRecommendations / totalRecommendationCount * 100 : 0;
|
|
8367
|
+
const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
|
|
8368
|
+
trends.push({
|
|
8369
|
+
period: periodInfo.period,
|
|
8370
|
+
startDate: periodInfo.startDate,
|
|
8371
|
+
endDate: periodInfo.endDate,
|
|
8372
|
+
averageRating: currentAvgRating,
|
|
8373
|
+
recommendationRate: currentRecRate,
|
|
8374
|
+
totalReviews: totalRatingCount,
|
|
8375
|
+
// Count of reviews for this entity type
|
|
8376
|
+
previousPeriod: previousAvgRating > 0 ? {
|
|
8377
|
+
averageRating: previousAvgRating,
|
|
8378
|
+
recommendationRate: previousRecRate,
|
|
8379
|
+
percentageChange: Math.abs(trendChange.percentageChange),
|
|
8380
|
+
direction: trendChange.direction
|
|
8381
|
+
} : void 0
|
|
8382
|
+
});
|
|
8383
|
+
previousAvgRating = currentAvgRating;
|
|
8384
|
+
previousRecRate = currentRecRate;
|
|
8385
|
+
});
|
|
8386
|
+
return trends;
|
|
8387
|
+
}
|
|
8388
|
+
};
|
|
8389
|
+
|
|
7590
8390
|
// src/services/analytics/analytics.service.ts
|
|
7591
8391
|
var AnalyticsService = class extends BaseService {
|
|
7592
8392
|
/**
|
|
@@ -7600,6 +8400,7 @@ var AnalyticsService = class extends BaseService {
|
|
|
7600
8400
|
constructor(db, auth, app, appointmentService) {
|
|
7601
8401
|
super(db, auth, app);
|
|
7602
8402
|
this.appointmentService = appointmentService;
|
|
8403
|
+
this.reviewAnalyticsService = new ReviewAnalyticsService(db, auth, app, appointmentService);
|
|
7603
8404
|
}
|
|
7604
8405
|
/**
|
|
7605
8406
|
* Fetches appointments with optional filters
|
|
@@ -7612,20 +8413,20 @@ var AnalyticsService = class extends BaseService {
|
|
|
7612
8413
|
try {
|
|
7613
8414
|
const constraints = [];
|
|
7614
8415
|
if (filters == null ? void 0 : filters.clinicBranchId) {
|
|
7615
|
-
constraints.push(
|
|
8416
|
+
constraints.push(where2("clinicBranchId", "==", filters.clinicBranchId));
|
|
7616
8417
|
}
|
|
7617
8418
|
if (filters == null ? void 0 : filters.practitionerId) {
|
|
7618
|
-
constraints.push(
|
|
8419
|
+
constraints.push(where2("practitionerId", "==", filters.practitionerId));
|
|
7619
8420
|
}
|
|
7620
8421
|
if (filters == null ? void 0 : filters.procedureId) {
|
|
7621
|
-
constraints.push(
|
|
8422
|
+
constraints.push(where2("procedureId", "==", filters.procedureId));
|
|
7622
8423
|
}
|
|
7623
8424
|
if (filters == null ? void 0 : filters.patientId) {
|
|
7624
|
-
constraints.push(
|
|
8425
|
+
constraints.push(where2("patientId", "==", filters.patientId));
|
|
7625
8426
|
}
|
|
7626
8427
|
if (dateRange) {
|
|
7627
|
-
constraints.push(
|
|
7628
|
-
constraints.push(
|
|
8428
|
+
constraints.push(where2("appointmentStartTime", ">=", Timestamp3.fromDate(dateRange.start)));
|
|
8429
|
+
constraints.push(where2("appointmentStartTime", "<=", Timestamp3.fromDate(dateRange.end)));
|
|
7629
8430
|
}
|
|
7630
8431
|
const searchParams = {};
|
|
7631
8432
|
if (filters == null ? void 0 : filters.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
|
|
@@ -8107,11 +8908,17 @@ var AnalyticsService = class extends BaseService {
|
|
|
8107
8908
|
groupCancellationsByProcedure(canceled, allAppointments) {
|
|
8108
8909
|
const procedureMap = /* @__PURE__ */ new Map();
|
|
8109
8910
|
allAppointments.forEach((appointment) => {
|
|
8110
|
-
var _a;
|
|
8911
|
+
var _a, _b;
|
|
8111
8912
|
const procedureId = appointment.procedureId;
|
|
8112
8913
|
const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8113
8914
|
if (!procedureMap.has(procedureId)) {
|
|
8114
|
-
procedureMap.set(procedureId, {
|
|
8915
|
+
procedureMap.set(procedureId, {
|
|
8916
|
+
name: procedureName,
|
|
8917
|
+
canceled: [],
|
|
8918
|
+
all: [],
|
|
8919
|
+
practitionerId: appointment.practitionerId,
|
|
8920
|
+
practitionerName: (_b = appointment.practitionerInfo) == null ? void 0 : _b.name
|
|
8921
|
+
});
|
|
8115
8922
|
}
|
|
8116
8923
|
procedureMap.get(procedureId).all.push(appointment);
|
|
8117
8924
|
});
|
|
@@ -8121,15 +8928,20 @@ var AnalyticsService = class extends BaseService {
|
|
|
8121
8928
|
procedureMap.get(procedureId).canceled.push(appointment);
|
|
8122
8929
|
}
|
|
8123
8930
|
});
|
|
8124
|
-
return Array.from(procedureMap.entries()).map(
|
|
8125
|
-
|
|
8931
|
+
return Array.from(procedureMap.entries()).map(([procedureId, data]) => {
|
|
8932
|
+
const metrics = this.calculateCancellationMetrics(
|
|
8126
8933
|
procedureId,
|
|
8127
8934
|
data.name,
|
|
8128
8935
|
"procedure",
|
|
8129
8936
|
data.canceled,
|
|
8130
8937
|
data.all
|
|
8131
|
-
)
|
|
8132
|
-
|
|
8938
|
+
);
|
|
8939
|
+
return {
|
|
8940
|
+
...metrics,
|
|
8941
|
+
...data.practitionerId && { practitionerId: data.practitionerId },
|
|
8942
|
+
...data.practitionerName && { practitionerName: data.practitionerName }
|
|
8943
|
+
};
|
|
8944
|
+
});
|
|
8133
8945
|
}
|
|
8134
8946
|
/**
|
|
8135
8947
|
* Group cancellations by technology
|
|
@@ -8334,11 +9146,17 @@ var AnalyticsService = class extends BaseService {
|
|
|
8334
9146
|
groupNoShowsByProcedure(noShow, allAppointments) {
|
|
8335
9147
|
const procedureMap = /* @__PURE__ */ new Map();
|
|
8336
9148
|
allAppointments.forEach((appointment) => {
|
|
8337
|
-
var _a;
|
|
9149
|
+
var _a, _b;
|
|
8338
9150
|
const procedureId = appointment.procedureId;
|
|
8339
9151
|
const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8340
9152
|
if (!procedureMap.has(procedureId)) {
|
|
8341
|
-
procedureMap.set(procedureId, {
|
|
9153
|
+
procedureMap.set(procedureId, {
|
|
9154
|
+
name: procedureName,
|
|
9155
|
+
noShow: [],
|
|
9156
|
+
all: [],
|
|
9157
|
+
practitionerId: appointment.practitionerId,
|
|
9158
|
+
practitionerName: (_b = appointment.practitionerInfo) == null ? void 0 : _b.name
|
|
9159
|
+
});
|
|
8342
9160
|
}
|
|
8343
9161
|
procedureMap.get(procedureId).all.push(appointment);
|
|
8344
9162
|
});
|
|
@@ -8354,7 +9172,9 @@ var AnalyticsService = class extends BaseService {
|
|
|
8354
9172
|
entityType: "procedure",
|
|
8355
9173
|
totalAppointments: data.all.length,
|
|
8356
9174
|
noShowAppointments: data.noShow.length,
|
|
8357
|
-
noShowRate: calculatePercentage(data.noShow.length, data.all.length)
|
|
9175
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
9176
|
+
...data.practitionerId && { practitionerId: data.practitionerId },
|
|
9177
|
+
...data.practitionerName && { practitionerName: data.practitionerName }
|
|
8358
9178
|
}));
|
|
8359
9179
|
}
|
|
8360
9180
|
/**
|
|
@@ -8407,6 +9227,10 @@ var AnalyticsService = class extends BaseService {
|
|
|
8407
9227
|
* Get revenue metrics
|
|
8408
9228
|
* First checks for stored analytics, then calculates if not available or stale
|
|
8409
9229
|
*
|
|
9230
|
+
* IMPORTANT: Financial calculations only consider COMPLETED appointments.
|
|
9231
|
+
* Confirmed, pending, canceled, and no-show appointments are NOT included in revenue calculations.
|
|
9232
|
+
* Only procedures that have been completed generate revenue.
|
|
9233
|
+
*
|
|
8410
9234
|
* @param filters - Optional filters
|
|
8411
9235
|
* @param dateRange - Optional date range filter
|
|
8412
9236
|
* @param options - Options for reading stored analytics
|
|
@@ -8429,11 +9253,8 @@ var AnalyticsService = class extends BaseService {
|
|
|
8429
9253
|
const completed = getCompletedAppointments(appointments);
|
|
8430
9254
|
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
8431
9255
|
const revenueByStatus = {};
|
|
8432
|
-
|
|
8433
|
-
|
|
8434
|
-
const { totalRevenue: statusRevenue } = calculateTotalRevenue(statusAppointments);
|
|
8435
|
-
revenueByStatus[status] = statusRevenue;
|
|
8436
|
-
});
|
|
9256
|
+
const { totalRevenue: completedRevenue } = calculateTotalRevenue(completed);
|
|
9257
|
+
revenueByStatus["completed" /* COMPLETED */] = completedRevenue;
|
|
8437
9258
|
const revenueByPaymentStatus = {};
|
|
8438
9259
|
Object.values(PaymentStatus).forEach((paymentStatus) => {
|
|
8439
9260
|
const paymentAppointments = completed.filter((a) => a.paymentStatus === paymentStatus);
|
|
@@ -8487,18 +9308,23 @@ var AnalyticsService = class extends BaseService {
|
|
|
8487
9308
|
/**
|
|
8488
9309
|
* Get product usage metrics
|
|
8489
9310
|
*
|
|
9311
|
+
* IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
|
|
9312
|
+
* Products are only considered "used" when the procedure has been completed.
|
|
9313
|
+
* Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
|
|
9314
|
+
*
|
|
8490
9315
|
* @param productId - Optional product ID (if not provided, returns all products)
|
|
8491
9316
|
* @param dateRange - Optional date range filter
|
|
9317
|
+
* @param filters - Optional filters (e.g., clinicBranchId)
|
|
8492
9318
|
* @returns Product usage metrics
|
|
8493
9319
|
*/
|
|
8494
|
-
async getProductUsageMetrics(productId, dateRange) {
|
|
8495
|
-
const appointments = await this.fetchAppointments(
|
|
9320
|
+
async getProductUsageMetrics(productId, dateRange, filters) {
|
|
9321
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
8496
9322
|
const completed = getCompletedAppointments(appointments);
|
|
8497
9323
|
const productMap = /* @__PURE__ */ new Map();
|
|
8498
9324
|
completed.forEach((appointment) => {
|
|
8499
9325
|
const products = extractProductUsage(appointment);
|
|
9326
|
+
const productsInThisAppointment = /* @__PURE__ */ new Set();
|
|
8500
9327
|
products.forEach((product) => {
|
|
8501
|
-
var _a;
|
|
8502
9328
|
if (productId && product.productId !== productId) {
|
|
8503
9329
|
return;
|
|
8504
9330
|
}
|
|
@@ -8510,25 +9336,37 @@ var AnalyticsService = class extends BaseService {
|
|
|
8510
9336
|
quantity: 0,
|
|
8511
9337
|
revenue: 0,
|
|
8512
9338
|
usageCount: 0,
|
|
9339
|
+
appointmentIds: /* @__PURE__ */ new Set(),
|
|
8513
9340
|
procedureMap: /* @__PURE__ */ new Map()
|
|
8514
9341
|
});
|
|
8515
9342
|
}
|
|
8516
9343
|
const productData = productMap.get(product.productId);
|
|
8517
9344
|
productData.quantity += product.quantity;
|
|
8518
9345
|
productData.revenue += product.subtotal;
|
|
8519
|
-
|
|
8520
|
-
|
|
8521
|
-
|
|
8522
|
-
|
|
8523
|
-
|
|
8524
|
-
|
|
8525
|
-
|
|
8526
|
-
|
|
8527
|
-
|
|
8528
|
-
|
|
8529
|
-
|
|
8530
|
-
|
|
8531
|
-
|
|
9346
|
+
productsInThisAppointment.add(product.productId);
|
|
9347
|
+
});
|
|
9348
|
+
productsInThisAppointment.forEach((productId2) => {
|
|
9349
|
+
var _a;
|
|
9350
|
+
const productData = productMap.get(productId2);
|
|
9351
|
+
if (!productData.appointmentIds.has(appointment.id)) {
|
|
9352
|
+
productData.appointmentIds.add(appointment.id);
|
|
9353
|
+
productData.usageCount++;
|
|
9354
|
+
const procId = appointment.procedureId;
|
|
9355
|
+
const procName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
9356
|
+
if (productData.procedureMap.has(procId)) {
|
|
9357
|
+
const procData = productData.procedureMap.get(procId);
|
|
9358
|
+
procData.count++;
|
|
9359
|
+
const appointmentProducts = products.filter((p) => p.productId === productId2);
|
|
9360
|
+
procData.quantity += appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
|
|
9361
|
+
} else {
|
|
9362
|
+
const appointmentProducts = products.filter((p) => p.productId === productId2);
|
|
9363
|
+
const totalQuantity = appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
|
|
9364
|
+
productData.procedureMap.set(procId, {
|
|
9365
|
+
name: procName,
|
|
9366
|
+
count: 1,
|
|
9367
|
+
quantity: totalQuantity
|
|
9368
|
+
});
|
|
9369
|
+
}
|
|
8532
9370
|
}
|
|
8533
9371
|
});
|
|
8534
9372
|
});
|
|
@@ -8766,6 +9604,341 @@ var AnalyticsService = class extends BaseService {
|
|
|
8766
9604
|
recentActivity
|
|
8767
9605
|
};
|
|
8768
9606
|
}
|
|
9607
|
+
/**
|
|
9608
|
+
* Calculate revenue trends over time
|
|
9609
|
+
* Groups appointments by week/month/quarter/year and calculates revenue metrics
|
|
9610
|
+
*
|
|
9611
|
+
* @param dateRange - Date range for trend analysis (must align with period boundaries)
|
|
9612
|
+
* @param period - Period type (week, month, quarter, year)
|
|
9613
|
+
* @param filters - Optional filters for clinic, practitioner, procedure, patient
|
|
9614
|
+
* @param groupBy - Optional entity type to group trends by (clinic, practitioner, procedure, technology, patient)
|
|
9615
|
+
* @returns Array of revenue trends with percentage changes
|
|
9616
|
+
*/
|
|
9617
|
+
async getRevenueTrends(dateRange, period, filters, groupBy) {
|
|
9618
|
+
const appointments = await this.fetchAppointments(filters);
|
|
9619
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
9620
|
+
if (filtered.length === 0) {
|
|
9621
|
+
return [];
|
|
9622
|
+
}
|
|
9623
|
+
if (groupBy) {
|
|
9624
|
+
return this.getGroupedRevenueTrends(filtered, dateRange, period, groupBy);
|
|
9625
|
+
}
|
|
9626
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period);
|
|
9627
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9628
|
+
const trends = [];
|
|
9629
|
+
let previousRevenue = 0;
|
|
9630
|
+
let previousAppointmentCount = 0;
|
|
9631
|
+
periods.forEach((periodInfo) => {
|
|
9632
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9633
|
+
const completed = getCompletedAppointments(periodAppointments);
|
|
9634
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
9635
|
+
const appointmentCount = completed.length;
|
|
9636
|
+
const averageRevenue = appointmentCount > 0 ? totalRevenue / appointmentCount : 0;
|
|
9637
|
+
const trend = {
|
|
9638
|
+
period: periodInfo.period,
|
|
9639
|
+
startDate: periodInfo.startDate,
|
|
9640
|
+
endDate: periodInfo.endDate,
|
|
9641
|
+
revenue: totalRevenue,
|
|
9642
|
+
appointmentCount,
|
|
9643
|
+
averageRevenue,
|
|
9644
|
+
currency
|
|
9645
|
+
};
|
|
9646
|
+
if (previousRevenue > 0 || previousAppointmentCount > 0) {
|
|
9647
|
+
const revenueChange = getTrendChange(totalRevenue, previousRevenue);
|
|
9648
|
+
trend.previousPeriod = {
|
|
9649
|
+
revenue: previousRevenue,
|
|
9650
|
+
appointmentCount: previousAppointmentCount,
|
|
9651
|
+
percentageChange: revenueChange.percentageChange,
|
|
9652
|
+
direction: revenueChange.direction
|
|
9653
|
+
};
|
|
9654
|
+
}
|
|
9655
|
+
trends.push(trend);
|
|
9656
|
+
previousRevenue = totalRevenue;
|
|
9657
|
+
previousAppointmentCount = appointmentCount;
|
|
9658
|
+
});
|
|
9659
|
+
return trends;
|
|
9660
|
+
}
|
|
9661
|
+
/**
|
|
9662
|
+
* Calculate revenue trends grouped by entity
|
|
9663
|
+
*/
|
|
9664
|
+
async getGroupedRevenueTrends(appointments, dateRange, period, groupBy) {
|
|
9665
|
+
const periodMap = groupAppointmentsByPeriod(appointments, period);
|
|
9666
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9667
|
+
const trends = [];
|
|
9668
|
+
periods.forEach((periodInfo) => {
|
|
9669
|
+
var _a;
|
|
9670
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9671
|
+
if (periodAppointments.length === 0) return;
|
|
9672
|
+
const groupedMetrics = calculateGroupedRevenueMetrics(periodAppointments, groupBy);
|
|
9673
|
+
const totalRevenue = groupedMetrics.reduce((sum, m) => sum + m.totalRevenue, 0);
|
|
9674
|
+
const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
|
|
9675
|
+
const currency = ((_a = groupedMetrics[0]) == null ? void 0 : _a.currency) || "CHF";
|
|
9676
|
+
const averageRevenue = totalAppointments > 0 ? totalRevenue / totalAppointments : 0;
|
|
9677
|
+
trends.push({
|
|
9678
|
+
period: periodInfo.period,
|
|
9679
|
+
startDate: periodInfo.startDate,
|
|
9680
|
+
endDate: periodInfo.endDate,
|
|
9681
|
+
revenue: totalRevenue,
|
|
9682
|
+
appointmentCount: totalAppointments,
|
|
9683
|
+
averageRevenue,
|
|
9684
|
+
currency
|
|
9685
|
+
});
|
|
9686
|
+
});
|
|
9687
|
+
for (let i = 1; i < trends.length; i++) {
|
|
9688
|
+
const current = trends[i];
|
|
9689
|
+
const previous = trends[i - 1];
|
|
9690
|
+
const revenueChange = getTrendChange(current.revenue, previous.revenue);
|
|
9691
|
+
current.previousPeriod = {
|
|
9692
|
+
revenue: previous.revenue,
|
|
9693
|
+
appointmentCount: previous.appointmentCount,
|
|
9694
|
+
percentageChange: revenueChange.percentageChange,
|
|
9695
|
+
direction: revenueChange.direction
|
|
9696
|
+
};
|
|
9697
|
+
}
|
|
9698
|
+
return trends;
|
|
9699
|
+
}
|
|
9700
|
+
/**
|
|
9701
|
+
* Calculate duration/efficiency trends over time
|
|
9702
|
+
*
|
|
9703
|
+
* @param dateRange - Date range for trend analysis
|
|
9704
|
+
* @param period - Period type (week, month, quarter, year)
|
|
9705
|
+
* @param filters - Optional filters
|
|
9706
|
+
* @param groupBy - Optional entity type to group trends by
|
|
9707
|
+
* @returns Array of duration trends with percentage changes
|
|
9708
|
+
*/
|
|
9709
|
+
async getDurationTrends(dateRange, period, filters, groupBy) {
|
|
9710
|
+
const appointments = await this.fetchAppointments(filters);
|
|
9711
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
9712
|
+
if (filtered.length === 0) {
|
|
9713
|
+
return [];
|
|
9714
|
+
}
|
|
9715
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period);
|
|
9716
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9717
|
+
const trends = [];
|
|
9718
|
+
let previousEfficiency = 0;
|
|
9719
|
+
let previousBookedDuration = 0;
|
|
9720
|
+
let previousActualDuration = 0;
|
|
9721
|
+
periods.forEach((periodInfo) => {
|
|
9722
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9723
|
+
const completed = getCompletedAppointments(periodAppointments);
|
|
9724
|
+
if (groupBy) {
|
|
9725
|
+
const groupedMetrics = calculateGroupedTimeEfficiencyMetrics(completed, groupBy);
|
|
9726
|
+
if (groupedMetrics.length === 0) return;
|
|
9727
|
+
const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
|
|
9728
|
+
const weightedBooked = groupedMetrics.reduce(
|
|
9729
|
+
(sum, m) => sum + m.averageBookedDuration * m.totalAppointments,
|
|
9730
|
+
0
|
|
9731
|
+
);
|
|
9732
|
+
const weightedActual = groupedMetrics.reduce(
|
|
9733
|
+
(sum, m) => sum + m.averageActualDuration * m.totalAppointments,
|
|
9734
|
+
0
|
|
9735
|
+
);
|
|
9736
|
+
const weightedEfficiency = groupedMetrics.reduce(
|
|
9737
|
+
(sum, m) => sum + m.averageEfficiency * m.totalAppointments,
|
|
9738
|
+
0
|
|
9739
|
+
);
|
|
9740
|
+
const averageBookedDuration = totalAppointments > 0 ? weightedBooked / totalAppointments : 0;
|
|
9741
|
+
const averageActualDuration = totalAppointments > 0 ? weightedActual / totalAppointments : 0;
|
|
9742
|
+
const averageEfficiency = totalAppointments > 0 ? weightedEfficiency / totalAppointments : 0;
|
|
9743
|
+
const trend = {
|
|
9744
|
+
period: periodInfo.period,
|
|
9745
|
+
startDate: periodInfo.startDate,
|
|
9746
|
+
endDate: periodInfo.endDate,
|
|
9747
|
+
averageBookedDuration,
|
|
9748
|
+
averageActualDuration,
|
|
9749
|
+
averageEfficiency,
|
|
9750
|
+
appointmentCount: totalAppointments
|
|
9751
|
+
};
|
|
9752
|
+
if (previousEfficiency > 0) {
|
|
9753
|
+
const efficiencyChange = getTrendChange(averageEfficiency, previousEfficiency);
|
|
9754
|
+
trend.previousPeriod = {
|
|
9755
|
+
averageBookedDuration: previousBookedDuration,
|
|
9756
|
+
averageActualDuration: previousActualDuration,
|
|
9757
|
+
averageEfficiency: previousEfficiency,
|
|
9758
|
+
efficiencyPercentageChange: efficiencyChange.percentageChange,
|
|
9759
|
+
direction: efficiencyChange.direction
|
|
9760
|
+
};
|
|
9761
|
+
}
|
|
9762
|
+
trends.push(trend);
|
|
9763
|
+
previousEfficiency = averageEfficiency;
|
|
9764
|
+
previousBookedDuration = averageBookedDuration;
|
|
9765
|
+
previousActualDuration = averageActualDuration;
|
|
9766
|
+
} else {
|
|
9767
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
9768
|
+
const trend = {
|
|
9769
|
+
period: periodInfo.period,
|
|
9770
|
+
startDate: periodInfo.startDate,
|
|
9771
|
+
endDate: periodInfo.endDate,
|
|
9772
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
9773
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
9774
|
+
averageEfficiency: timeMetrics.averageEfficiency,
|
|
9775
|
+
appointmentCount: timeMetrics.appointmentsWithActualTime
|
|
9776
|
+
};
|
|
9777
|
+
if (previousEfficiency > 0) {
|
|
9778
|
+
const efficiencyChange = getTrendChange(timeMetrics.averageEfficiency, previousEfficiency);
|
|
9779
|
+
trend.previousPeriod = {
|
|
9780
|
+
averageBookedDuration: previousBookedDuration,
|
|
9781
|
+
averageActualDuration: previousActualDuration,
|
|
9782
|
+
averageEfficiency: previousEfficiency,
|
|
9783
|
+
efficiencyPercentageChange: efficiencyChange.percentageChange,
|
|
9784
|
+
direction: efficiencyChange.direction
|
|
9785
|
+
};
|
|
9786
|
+
}
|
|
9787
|
+
trends.push(trend);
|
|
9788
|
+
previousEfficiency = timeMetrics.averageEfficiency;
|
|
9789
|
+
previousBookedDuration = timeMetrics.averageBookedDuration;
|
|
9790
|
+
previousActualDuration = timeMetrics.averageActualDuration;
|
|
9791
|
+
}
|
|
9792
|
+
});
|
|
9793
|
+
return trends;
|
|
9794
|
+
}
|
|
9795
|
+
/**
|
|
9796
|
+
* Calculate appointment count trends over time
|
|
9797
|
+
*
|
|
9798
|
+
* @param dateRange - Date range for trend analysis
|
|
9799
|
+
* @param period - Period type (week, month, quarter, year)
|
|
9800
|
+
* @param filters - Optional filters
|
|
9801
|
+
* @param groupBy - Optional entity type to group trends by
|
|
9802
|
+
* @returns Array of appointment trends with percentage changes
|
|
9803
|
+
*/
|
|
9804
|
+
async getAppointmentTrends(dateRange, period, filters, groupBy) {
|
|
9805
|
+
const appointments = await this.fetchAppointments(filters);
|
|
9806
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
9807
|
+
if (filtered.length === 0) {
|
|
9808
|
+
return [];
|
|
9809
|
+
}
|
|
9810
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period);
|
|
9811
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9812
|
+
const trends = [];
|
|
9813
|
+
let previousTotal = 0;
|
|
9814
|
+
let previousCompleted = 0;
|
|
9815
|
+
periods.forEach((periodInfo) => {
|
|
9816
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9817
|
+
const completed = getCompletedAppointments(periodAppointments);
|
|
9818
|
+
const canceled = getCanceledAppointments(periodAppointments);
|
|
9819
|
+
const noShow = getNoShowAppointments(periodAppointments);
|
|
9820
|
+
const pending = periodAppointments.filter((a) => a.status === "pending" /* PENDING */);
|
|
9821
|
+
const confirmed = periodAppointments.filter((a) => a.status === "confirmed" /* CONFIRMED */);
|
|
9822
|
+
const trend = {
|
|
9823
|
+
period: periodInfo.period,
|
|
9824
|
+
startDate: periodInfo.startDate,
|
|
9825
|
+
endDate: periodInfo.endDate,
|
|
9826
|
+
totalAppointments: periodAppointments.length,
|
|
9827
|
+
completedAppointments: completed.length,
|
|
9828
|
+
canceledAppointments: canceled.length,
|
|
9829
|
+
noShowAppointments: noShow.length,
|
|
9830
|
+
pendingAppointments: pending.length,
|
|
9831
|
+
confirmedAppointments: confirmed.length
|
|
9832
|
+
};
|
|
9833
|
+
if (previousTotal > 0) {
|
|
9834
|
+
const totalChange = getTrendChange(periodAppointments.length, previousTotal);
|
|
9835
|
+
trend.previousPeriod = {
|
|
9836
|
+
totalAppointments: previousTotal,
|
|
9837
|
+
completedAppointments: previousCompleted,
|
|
9838
|
+
percentageChange: totalChange.percentageChange,
|
|
9839
|
+
direction: totalChange.direction
|
|
9840
|
+
};
|
|
9841
|
+
}
|
|
9842
|
+
trends.push(trend);
|
|
9843
|
+
previousTotal = periodAppointments.length;
|
|
9844
|
+
previousCompleted = completed.length;
|
|
9845
|
+
});
|
|
9846
|
+
return trends;
|
|
9847
|
+
}
|
|
9848
|
+
/**
|
|
9849
|
+
* Calculate cancellation and no-show rate trends over time
|
|
9850
|
+
*
|
|
9851
|
+
* @param dateRange - Date range for trend analysis
|
|
9852
|
+
* @param period - Period type (week, month, quarter, year)
|
|
9853
|
+
* @param filters - Optional filters
|
|
9854
|
+
* @param groupBy - Optional entity type to group trends by
|
|
9855
|
+
* @returns Array of cancellation rate trends with percentage changes
|
|
9856
|
+
*/
|
|
9857
|
+
async getCancellationRateTrends(dateRange, period, filters, groupBy) {
|
|
9858
|
+
const appointments = await this.fetchAppointments(filters);
|
|
9859
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
9860
|
+
if (filtered.length === 0) {
|
|
9861
|
+
return [];
|
|
9862
|
+
}
|
|
9863
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period);
|
|
9864
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9865
|
+
const trends = [];
|
|
9866
|
+
let previousCancellationRate = 0;
|
|
9867
|
+
let previousNoShowRate = 0;
|
|
9868
|
+
periods.forEach((periodInfo) => {
|
|
9869
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9870
|
+
const canceled = getCanceledAppointments(periodAppointments);
|
|
9871
|
+
const noShow = getNoShowAppointments(periodAppointments);
|
|
9872
|
+
const cancellationRate = calculatePercentage(canceled.length, periodAppointments.length);
|
|
9873
|
+
const noShowRate = calculatePercentage(noShow.length, periodAppointments.length);
|
|
9874
|
+
const trend = {
|
|
9875
|
+
period: periodInfo.period,
|
|
9876
|
+
startDate: periodInfo.startDate,
|
|
9877
|
+
endDate: periodInfo.endDate,
|
|
9878
|
+
cancellationRate,
|
|
9879
|
+
noShowRate,
|
|
9880
|
+
totalAppointments: periodAppointments.length,
|
|
9881
|
+
canceledAppointments: canceled.length,
|
|
9882
|
+
noShowAppointments: noShow.length
|
|
9883
|
+
};
|
|
9884
|
+
if (previousCancellationRate > 0 || previousNoShowRate > 0) {
|
|
9885
|
+
const cancellationChange = getTrendChange(cancellationRate, previousCancellationRate);
|
|
9886
|
+
const noShowChange = getTrendChange(noShowRate, previousNoShowRate);
|
|
9887
|
+
trend.previousPeriod = {
|
|
9888
|
+
cancellationRate: previousCancellationRate,
|
|
9889
|
+
noShowRate: previousNoShowRate,
|
|
9890
|
+
cancellationRateChange: cancellationChange.percentageChange,
|
|
9891
|
+
noShowRateChange: noShowChange.percentageChange,
|
|
9892
|
+
direction: cancellationChange.direction
|
|
9893
|
+
// Use cancellation direction as primary
|
|
9894
|
+
};
|
|
9895
|
+
}
|
|
9896
|
+
trends.push(trend);
|
|
9897
|
+
previousCancellationRate = cancellationRate;
|
|
9898
|
+
previousNoShowRate = noShowRate;
|
|
9899
|
+
});
|
|
9900
|
+
return trends;
|
|
9901
|
+
}
|
|
9902
|
+
// ==========================================
|
|
9903
|
+
// Review Analytics Methods
|
|
9904
|
+
// ==========================================
|
|
9905
|
+
/**
|
|
9906
|
+
* Get review metrics for a specific entity (practitioner, procedure, etc.)
|
|
9907
|
+
*/
|
|
9908
|
+
async getReviewMetricsByEntity(entityType, entityId, dateRange, filters) {
|
|
9909
|
+
return this.reviewAnalyticsService.getReviewMetricsByEntity(entityType, entityId, dateRange, filters);
|
|
9910
|
+
}
|
|
9911
|
+
/**
|
|
9912
|
+
* Get review metrics for multiple entities (grouped)
|
|
9913
|
+
*/
|
|
9914
|
+
async getReviewMetricsByEntities(entityType, dateRange, filters) {
|
|
9915
|
+
return this.reviewAnalyticsService.getReviewMetricsByEntities(entityType, dateRange, filters);
|
|
9916
|
+
}
|
|
9917
|
+
/**
|
|
9918
|
+
* Get overall review averages for comparison
|
|
9919
|
+
*/
|
|
9920
|
+
async getOverallReviewAverages(dateRange, filters) {
|
|
9921
|
+
return this.reviewAnalyticsService.getOverallReviewAverages(dateRange, filters);
|
|
9922
|
+
}
|
|
9923
|
+
/**
|
|
9924
|
+
* Get review details for a specific entity
|
|
9925
|
+
*/
|
|
9926
|
+
async getReviewDetails(entityType, entityId, dateRange, filters) {
|
|
9927
|
+
return this.reviewAnalyticsService.getReviewDetails(entityType, entityId, dateRange, filters);
|
|
9928
|
+
}
|
|
9929
|
+
/**
|
|
9930
|
+
* Calculate review trends over time
|
|
9931
|
+
* Groups reviews by period and calculates rating and recommendation metrics
|
|
9932
|
+
*
|
|
9933
|
+
* @param dateRange - Date range for trend analysis
|
|
9934
|
+
* @param period - Period type (week, month, quarter, year)
|
|
9935
|
+
* @param filters - Optional filters for clinic, practitioner, procedure
|
|
9936
|
+
* @param entityType - Optional entity type to group trends by
|
|
9937
|
+
* @returns Array of review trends with percentage changes
|
|
9938
|
+
*/
|
|
9939
|
+
async getReviewTrends(dateRange, period, filters, entityType) {
|
|
9940
|
+
return this.reviewAnalyticsService.getReviewTrends(dateRange, period, filters, entityType);
|
|
9941
|
+
}
|
|
8769
9942
|
};
|
|
8770
9943
|
|
|
8771
9944
|
// src/admin/analytics/analytics.admin.service.ts
|
|
@@ -8798,33 +9971,33 @@ var AnalyticsAdminService = class {
|
|
|
8798
9971
|
createAppointmentServiceAdapter() {
|
|
8799
9972
|
return {
|
|
8800
9973
|
searchAppointments: async (params) => {
|
|
8801
|
-
let
|
|
9974
|
+
let query3 = this.db.collection(APPOINTMENTS_COLLECTION);
|
|
8802
9975
|
if (params.clinicBranchId) {
|
|
8803
|
-
|
|
9976
|
+
query3 = query3.where("clinicBranchId", "==", params.clinicBranchId);
|
|
8804
9977
|
}
|
|
8805
9978
|
if (params.practitionerId) {
|
|
8806
|
-
|
|
9979
|
+
query3 = query3.where("practitionerId", "==", params.practitionerId);
|
|
8807
9980
|
}
|
|
8808
9981
|
if (params.procedureId) {
|
|
8809
|
-
|
|
9982
|
+
query3 = query3.where("procedureId", "==", params.procedureId);
|
|
8810
9983
|
}
|
|
8811
9984
|
if (params.patientId) {
|
|
8812
|
-
|
|
9985
|
+
query3 = query3.where("patientId", "==", params.patientId);
|
|
8813
9986
|
}
|
|
8814
9987
|
if (params.startDate) {
|
|
8815
9988
|
const startDate = params.startDate instanceof Date ? params.startDate : params.startDate.toDate();
|
|
8816
9989
|
const startTimestamp = admin14.firestore.Timestamp.fromDate(startDate);
|
|
8817
|
-
|
|
9990
|
+
query3 = query3.where("appointmentStartTime", ">=", startTimestamp);
|
|
8818
9991
|
}
|
|
8819
9992
|
if (params.endDate) {
|
|
8820
9993
|
const endDate = params.endDate instanceof Date ? params.endDate : params.endDate.toDate();
|
|
8821
9994
|
const endTimestamp = admin14.firestore.Timestamp.fromDate(endDate);
|
|
8822
|
-
|
|
9995
|
+
query3 = query3.where("appointmentStartTime", "<=", endTimestamp);
|
|
8823
9996
|
}
|
|
8824
|
-
const snapshot = await
|
|
8825
|
-
const appointments = snapshot.docs.map((
|
|
8826
|
-
id:
|
|
8827
|
-
...
|
|
9997
|
+
const snapshot = await query3.get();
|
|
9998
|
+
const appointments = snapshot.docs.map((doc3) => ({
|
|
9999
|
+
id: doc3.id,
|
|
10000
|
+
...doc3.data()
|
|
8828
10001
|
}));
|
|
8829
10002
|
return {
|
|
8830
10003
|
appointments,
|
|
@@ -8921,7 +10094,7 @@ var AnalyticsAdminService = class {
|
|
|
8921
10094
|
};
|
|
8922
10095
|
|
|
8923
10096
|
// src/admin/booking/booking.calculator.ts
|
|
8924
|
-
import { Timestamp as
|
|
10097
|
+
import { Timestamp as Timestamp4 } from "firebase/firestore";
|
|
8925
10098
|
import { DateTime as DateTime2 } from "luxon";
|
|
8926
10099
|
var BookingAvailabilityCalculator = class {
|
|
8927
10100
|
/**
|
|
@@ -9047,8 +10220,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
9047
10220
|
const intervalStart = workStart < DateTime2.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
|
|
9048
10221
|
const intervalEnd = workEnd > DateTime2.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
|
|
9049
10222
|
workingIntervals.push({
|
|
9050
|
-
start:
|
|
9051
|
-
end:
|
|
10223
|
+
start: Timestamp4.fromMillis(intervalStart.toMillis()),
|
|
10224
|
+
end: Timestamp4.fromMillis(intervalEnd.toMillis())
|
|
9052
10225
|
});
|
|
9053
10226
|
if (daySchedule.breaks && daySchedule.breaks.length > 0) {
|
|
9054
10227
|
for (const breakTime of daySchedule.breaks) {
|
|
@@ -9068,8 +10241,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
9068
10241
|
...this.subtractInterval(
|
|
9069
10242
|
workingIntervals[workingIntervals.length - 1],
|
|
9070
10243
|
{
|
|
9071
|
-
start:
|
|
9072
|
-
end:
|
|
10244
|
+
start: Timestamp4.fromMillis(breakStart.toMillis()),
|
|
10245
|
+
end: Timestamp4.fromMillis(breakEnd.toMillis())
|
|
9073
10246
|
}
|
|
9074
10247
|
)
|
|
9075
10248
|
);
|
|
@@ -9179,8 +10352,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
9179
10352
|
const intervalStart = workStart < DateTime2.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
|
|
9180
10353
|
const intervalEnd = workEnd > DateTime2.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
|
|
9181
10354
|
workingIntervals.push({
|
|
9182
|
-
start:
|
|
9183
|
-
end:
|
|
10355
|
+
start: Timestamp4.fromMillis(intervalStart.toMillis()),
|
|
10356
|
+
end: Timestamp4.fromMillis(intervalEnd.toMillis())
|
|
9184
10357
|
});
|
|
9185
10358
|
}
|
|
9186
10359
|
}
|
|
@@ -9260,7 +10433,7 @@ var BookingAvailabilityCalculator = class {
|
|
|
9260
10433
|
const isInFuture = slotStart >= earliestBookableTime;
|
|
9261
10434
|
if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
|
|
9262
10435
|
slots.push({
|
|
9263
|
-
start:
|
|
10436
|
+
start: Timestamp4.fromMillis(slotStart.toMillis())
|
|
9264
10437
|
});
|
|
9265
10438
|
}
|
|
9266
10439
|
slotStart = slotStart.plus({ minutes: intervalMinutes });
|
|
@@ -9490,8 +10663,8 @@ var DocumentManagerAdminService = class {
|
|
|
9490
10663
|
const templateIds = technologyTemplates.map((t) => t.templateId);
|
|
9491
10664
|
const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(admin15.firestore.FieldPath.documentId(), "in", templateIds).get();
|
|
9492
10665
|
const templatesMap = /* @__PURE__ */ new Map();
|
|
9493
|
-
templatesSnapshot.forEach((
|
|
9494
|
-
templatesMap.set(
|
|
10666
|
+
templatesSnapshot.forEach((doc3) => {
|
|
10667
|
+
templatesMap.set(doc3.id, doc3.data());
|
|
9495
10668
|
});
|
|
9496
10669
|
for (const templateRef of technologyTemplates) {
|
|
9497
10670
|
const template = templatesMap.get(templateRef.templateId);
|
|
@@ -9732,9 +10905,9 @@ var BookingAdmin = class {
|
|
|
9732
10905
|
);
|
|
9733
10906
|
const eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
9734
10907
|
const snapshot = await eventsRef.get();
|
|
9735
|
-
const events = snapshot.docs.map((
|
|
9736
|
-
...
|
|
9737
|
-
id:
|
|
10908
|
+
const events = snapshot.docs.map((doc3) => ({
|
|
10909
|
+
...doc3.data(),
|
|
10910
|
+
id: doc3.id
|
|
9738
10911
|
})).filter((event) => {
|
|
9739
10912
|
return event.eventTime.end.toMillis() > start.toMillis();
|
|
9740
10913
|
});
|
|
@@ -9778,9 +10951,9 @@ var BookingAdmin = class {
|
|
|
9778
10951
|
);
|
|
9779
10952
|
const eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
9780
10953
|
const snapshot = await eventsRef.get();
|
|
9781
|
-
const events = snapshot.docs.map((
|
|
9782
|
-
...
|
|
9783
|
-
id:
|
|
10954
|
+
const events = snapshot.docs.map((doc3) => ({
|
|
10955
|
+
...doc3.data(),
|
|
10956
|
+
id: doc3.id
|
|
9784
10957
|
})).filter((event) => {
|
|
9785
10958
|
return event.eventTime.end.toMillis() > start.toMillis();
|
|
9786
10959
|
});
|
|
@@ -11635,8 +12808,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
|
|
|
11635
12808
|
*/
|
|
11636
12809
|
async fetchPractitionerById(practitionerId) {
|
|
11637
12810
|
try {
|
|
11638
|
-
const
|
|
11639
|
-
return
|
|
12811
|
+
const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
|
|
12812
|
+
return doc3.exists ? doc3.data() : null;
|
|
11640
12813
|
} catch (error) {
|
|
11641
12814
|
Logger.error(
|
|
11642
12815
|
"[ExistingPractitionerInviteMailingService] Error fetching practitioner:",
|
|
@@ -11652,8 +12825,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
|
|
|
11652
12825
|
*/
|
|
11653
12826
|
async fetchClinicById(clinicId) {
|
|
11654
12827
|
try {
|
|
11655
|
-
const
|
|
11656
|
-
return
|
|
12828
|
+
const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
|
|
12829
|
+
return doc3.exists ? doc3.data() : null;
|
|
11657
12830
|
} catch (error) {
|
|
11658
12831
|
Logger.error(
|
|
11659
12832
|
"[ExistingPractitionerInviteMailingService] Error fetching clinic:",
|