@blackcode_sa/metaestetics-api 1.13.0 → 1.13.1
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 +326 -2
- package/dist/index.d.ts +326 -2
- package/dist/index.js +3188 -1888
- package/dist/index.mjs +2909 -1610
- 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 +117 -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.js
CHANGED
|
@@ -689,8 +689,8 @@ var NotificationsAdmin = class {
|
|
|
689
689
|
* Dohvata notifikaciju po ID-u
|
|
690
690
|
*/
|
|
691
691
|
async getNotification(id) {
|
|
692
|
-
const
|
|
693
|
-
return
|
|
692
|
+
const doc3 = await this.db.collection("notifications").doc(id).get();
|
|
693
|
+
return doc3.exists ? { id: doc3.id, ...doc3.data() } : null;
|
|
694
694
|
}
|
|
695
695
|
/**
|
|
696
696
|
* Kreira novu notifikaciju
|
|
@@ -877,10 +877,10 @@ var NotificationsAdmin = class {
|
|
|
877
877
|
return;
|
|
878
878
|
}
|
|
879
879
|
const results = await Promise.allSettled(
|
|
880
|
-
pendingNotifications.docs.map(async (
|
|
880
|
+
pendingNotifications.docs.map(async (doc3) => {
|
|
881
881
|
const notification = {
|
|
882
|
-
id:
|
|
883
|
-
...
|
|
882
|
+
id: doc3.id,
|
|
883
|
+
...doc3.data()
|
|
884
884
|
};
|
|
885
885
|
Logger.info(
|
|
886
886
|
`[NotificationsAdmin] Processing notification ${notification.id} of type ${notification.notificationType}`
|
|
@@ -921,8 +921,8 @@ var NotificationsAdmin = class {
|
|
|
921
921
|
break;
|
|
922
922
|
}
|
|
923
923
|
const batch = this.db.batch();
|
|
924
|
-
oldNotifications.docs.forEach((
|
|
925
|
-
batch.delete(
|
|
924
|
+
oldNotifications.docs.forEach((doc3) => {
|
|
925
|
+
batch.delete(doc3.ref);
|
|
926
926
|
});
|
|
927
927
|
await batch.commit();
|
|
928
928
|
totalDeleted += oldNotifications.size;
|
|
@@ -3610,10 +3610,10 @@ var AppointmentAggregationService = class {
|
|
|
3610
3610
|
}
|
|
3611
3611
|
const batch = this.db.batch();
|
|
3612
3612
|
let instancesUpdatedCount = 0;
|
|
3613
|
-
instancesSnapshot.docs.forEach((
|
|
3614
|
-
const instance =
|
|
3613
|
+
instancesSnapshot.docs.forEach((doc3) => {
|
|
3614
|
+
const instance = doc3.data();
|
|
3615
3615
|
if (instance.overallStatus !== newOverallStatus && instance.overallStatus !== "failedToProcess" /* FAILED_TO_PROCESS */) {
|
|
3616
|
-
batch.update(
|
|
3616
|
+
batch.update(doc3.ref, {
|
|
3617
3617
|
overallStatus: newOverallStatus,
|
|
3618
3618
|
updatedAt: admin6.firestore.FieldValue.serverTimestamp()
|
|
3619
3619
|
// Cast for now
|
|
@@ -3622,7 +3622,7 @@ var AppointmentAggregationService = class {
|
|
|
3622
3622
|
});
|
|
3623
3623
|
instancesUpdatedCount++;
|
|
3624
3624
|
Logger.debug(
|
|
3625
|
-
`[AggService] Added update for PatientRequirementInstance ${
|
|
3625
|
+
`[AggService] Added update for PatientRequirementInstance ${doc3.id} to batch. New status: ${newOverallStatus}`
|
|
3626
3626
|
);
|
|
3627
3627
|
}
|
|
3628
3628
|
});
|
|
@@ -3797,8 +3797,8 @@ var AppointmentAggregationService = class {
|
|
|
3797
3797
|
// --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
|
|
3798
3798
|
async fetchPatientProfile(patientId) {
|
|
3799
3799
|
try {
|
|
3800
|
-
const
|
|
3801
|
-
return
|
|
3800
|
+
const doc3 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
|
|
3801
|
+
return doc3.exists ? doc3.data() : null;
|
|
3802
3802
|
} catch (error) {
|
|
3803
3803
|
Logger.error(`[AggService] Error fetching patient profile ${patientId}:`, error);
|
|
3804
3804
|
return null;
|
|
@@ -3811,12 +3811,12 @@ var AppointmentAggregationService = class {
|
|
|
3811
3811
|
*/
|
|
3812
3812
|
async fetchPatientSensitiveInfo(patientId) {
|
|
3813
3813
|
try {
|
|
3814
|
-
const
|
|
3815
|
-
if (!
|
|
3814
|
+
const doc3 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
|
|
3815
|
+
if (!doc3.exists) {
|
|
3816
3816
|
Logger.warn(`[AggService] No sensitive info found for patient ${patientId}`);
|
|
3817
3817
|
return null;
|
|
3818
3818
|
}
|
|
3819
|
-
return
|
|
3819
|
+
return doc3.data();
|
|
3820
3820
|
} catch (error) {
|
|
3821
3821
|
Logger.error(`[AggService] Error fetching patient sensitive info ${patientId}:`, error);
|
|
3822
3822
|
return null;
|
|
@@ -3833,12 +3833,12 @@ var AppointmentAggregationService = class {
|
|
|
3833
3833
|
return null;
|
|
3834
3834
|
}
|
|
3835
3835
|
try {
|
|
3836
|
-
const
|
|
3837
|
-
if (!
|
|
3836
|
+
const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
|
|
3837
|
+
if (!doc3.exists) {
|
|
3838
3838
|
Logger.warn(`[AggService] No practitioner profile found for ID ${practitionerId}`);
|
|
3839
3839
|
return null;
|
|
3840
3840
|
}
|
|
3841
|
-
return
|
|
3841
|
+
return doc3.data();
|
|
3842
3842
|
} catch (error) {
|
|
3843
3843
|
Logger.error(`[AggService] Error fetching practitioner profile ${practitionerId}:`, error);
|
|
3844
3844
|
return null;
|
|
@@ -3855,12 +3855,12 @@ var AppointmentAggregationService = class {
|
|
|
3855
3855
|
return null;
|
|
3856
3856
|
}
|
|
3857
3857
|
try {
|
|
3858
|
-
const
|
|
3859
|
-
if (!
|
|
3858
|
+
const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
|
|
3859
|
+
if (!doc3.exists) {
|
|
3860
3860
|
Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
|
|
3861
3861
|
return null;
|
|
3862
3862
|
}
|
|
3863
|
-
return
|
|
3863
|
+
return doc3.data();
|
|
3864
3864
|
} catch (error) {
|
|
3865
3865
|
Logger.error(`[AggService] Error fetching clinic info ${clinicId}:`, error);
|
|
3866
3866
|
return null;
|
|
@@ -4327,11 +4327,11 @@ var ClinicAggregationService = class {
|
|
|
4327
4327
|
return;
|
|
4328
4328
|
}
|
|
4329
4329
|
const batch = this.db.batch();
|
|
4330
|
-
snapshot.docs.forEach((
|
|
4330
|
+
snapshot.docs.forEach((doc3) => {
|
|
4331
4331
|
console.log(
|
|
4332
|
-
`[ClinicAggregationService] Updating location for calendar event ${
|
|
4332
|
+
`[ClinicAggregationService] Updating location for calendar event ${doc3.ref.path}`
|
|
4333
4333
|
);
|
|
4334
|
-
batch.update(
|
|
4334
|
+
batch.update(doc3.ref, {
|
|
4335
4335
|
eventLocation: newLocation,
|
|
4336
4336
|
updatedAt: admin7.firestore.FieldValue.serverTimestamp()
|
|
4337
4337
|
});
|
|
@@ -4374,11 +4374,11 @@ var ClinicAggregationService = class {
|
|
|
4374
4374
|
return;
|
|
4375
4375
|
}
|
|
4376
4376
|
const batch = this.db.batch();
|
|
4377
|
-
snapshot.docs.forEach((
|
|
4377
|
+
snapshot.docs.forEach((doc3) => {
|
|
4378
4378
|
console.log(
|
|
4379
|
-
`[ClinicAggregationService] Updating clinic info for calendar event ${
|
|
4379
|
+
`[ClinicAggregationService] Updating clinic info for calendar event ${doc3.ref.path}`
|
|
4380
4380
|
);
|
|
4381
|
-
batch.update(
|
|
4381
|
+
batch.update(doc3.ref, {
|
|
4382
4382
|
clinicInfo,
|
|
4383
4383
|
updatedAt: admin7.firestore.FieldValue.serverTimestamp()
|
|
4384
4384
|
});
|
|
@@ -4589,11 +4589,11 @@ var ClinicAggregationService = class {
|
|
|
4589
4589
|
return;
|
|
4590
4590
|
}
|
|
4591
4591
|
const batch = this.db.batch();
|
|
4592
|
-
snapshot.docs.forEach((
|
|
4592
|
+
snapshot.docs.forEach((doc3) => {
|
|
4593
4593
|
console.log(
|
|
4594
|
-
`[ClinicAggregationService] Canceling calendar event ${
|
|
4594
|
+
`[ClinicAggregationService] Canceling calendar event ${doc3.ref.path}`
|
|
4595
4595
|
);
|
|
4596
|
-
batch.update(
|
|
4596
|
+
batch.update(doc3.ref, {
|
|
4597
4597
|
status: "CANCELED",
|
|
4598
4598
|
cancelReason: "Clinic deleted",
|
|
4599
4599
|
updatedAt: admin7.firestore.FieldValue.serverTimestamp()
|
|
@@ -4861,11 +4861,11 @@ var PatientAggregationService = class {
|
|
|
4861
4861
|
return;
|
|
4862
4862
|
}
|
|
4863
4863
|
const batch = this.db.batch();
|
|
4864
|
-
snapshot.docs.forEach((
|
|
4864
|
+
snapshot.docs.forEach((doc3) => {
|
|
4865
4865
|
console.log(
|
|
4866
|
-
`[PatientAggregationService] Updating patient info for calendar event ${
|
|
4866
|
+
`[PatientAggregationService] Updating patient info for calendar event ${doc3.ref.path}`
|
|
4867
4867
|
);
|
|
4868
|
-
batch.update(
|
|
4868
|
+
batch.update(doc3.ref, {
|
|
4869
4869
|
patientInfo,
|
|
4870
4870
|
updatedAt: admin9.firestore.FieldValue.serverTimestamp()
|
|
4871
4871
|
});
|
|
@@ -4909,11 +4909,11 @@ var PatientAggregationService = class {
|
|
|
4909
4909
|
return;
|
|
4910
4910
|
}
|
|
4911
4911
|
const batch = this.db.batch();
|
|
4912
|
-
snapshot.docs.forEach((
|
|
4912
|
+
snapshot.docs.forEach((doc3) => {
|
|
4913
4913
|
console.log(
|
|
4914
|
-
`[PatientAggregationService] Canceling calendar event ${
|
|
4914
|
+
`[PatientAggregationService] Canceling calendar event ${doc3.ref.path}`
|
|
4915
4915
|
);
|
|
4916
|
-
batch.update(
|
|
4916
|
+
batch.update(doc3.ref, {
|
|
4917
4917
|
status: "CANCELED",
|
|
4918
4918
|
cancelReason: "Patient deleted",
|
|
4919
4919
|
updatedAt: admin9.firestore.FieldValue.serverTimestamp()
|
|
@@ -5084,11 +5084,11 @@ var PractitionerAggregationService = class {
|
|
|
5084
5084
|
return;
|
|
5085
5085
|
}
|
|
5086
5086
|
const batch = this.db.batch();
|
|
5087
|
-
snapshot.docs.forEach((
|
|
5087
|
+
snapshot.docs.forEach((doc3) => {
|
|
5088
5088
|
console.log(
|
|
5089
|
-
`[PractitionerAggregationService] Updating practitioner info for calendar event ${
|
|
5089
|
+
`[PractitionerAggregationService] Updating practitioner info for calendar event ${doc3.ref.path}`
|
|
5090
5090
|
);
|
|
5091
|
-
batch.update(
|
|
5091
|
+
batch.update(doc3.ref, {
|
|
5092
5092
|
practitionerInfo,
|
|
5093
5093
|
updatedAt: admin10.firestore.FieldValue.serverTimestamp()
|
|
5094
5094
|
});
|
|
@@ -5172,11 +5172,11 @@ var PractitionerAggregationService = class {
|
|
|
5172
5172
|
return;
|
|
5173
5173
|
}
|
|
5174
5174
|
const batch = this.db.batch();
|
|
5175
|
-
snapshot.docs.forEach((
|
|
5175
|
+
snapshot.docs.forEach((doc3) => {
|
|
5176
5176
|
console.log(
|
|
5177
|
-
`[PractitionerAggregationService] Canceling calendar event ${
|
|
5177
|
+
`[PractitionerAggregationService] Canceling calendar event ${doc3.ref.path}`
|
|
5178
5178
|
);
|
|
5179
|
-
batch.update(
|
|
5179
|
+
batch.update(doc3.ref, {
|
|
5180
5180
|
status: "CANCELED",
|
|
5181
5181
|
cancelReason: "Practitioner deleted",
|
|
5182
5182
|
updatedAt: admin10.firestore.FieldValue.serverTimestamp()
|
|
@@ -5728,8 +5728,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5728
5728
|
*/
|
|
5729
5729
|
async fetchClinicAdminById(adminId) {
|
|
5730
5730
|
try {
|
|
5731
|
-
const
|
|
5732
|
-
return
|
|
5731
|
+
const doc3 = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
|
|
5732
|
+
return doc3.exists ? doc3.data() : null;
|
|
5733
5733
|
} catch (error) {
|
|
5734
5734
|
Logger.error(
|
|
5735
5735
|
`[PractitionerInviteAggService] Error fetching clinic admin ${adminId}:`,
|
|
@@ -5745,8 +5745,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5745
5745
|
*/
|
|
5746
5746
|
async fetchPractitionerById(practitionerId) {
|
|
5747
5747
|
try {
|
|
5748
|
-
const
|
|
5749
|
-
return
|
|
5748
|
+
const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
|
|
5749
|
+
return doc3.exists ? doc3.data() : null;
|
|
5750
5750
|
} catch (error) {
|
|
5751
5751
|
Logger.error(
|
|
5752
5752
|
`[PractitionerInviteAggService] Error fetching practitioner ${practitionerId}:`,
|
|
@@ -5762,8 +5762,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5762
5762
|
*/
|
|
5763
5763
|
async fetchClinicById(clinicId) {
|
|
5764
5764
|
try {
|
|
5765
|
-
const
|
|
5766
|
-
return
|
|
5765
|
+
const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
|
|
5766
|
+
return doc3.exists ? doc3.data() : null;
|
|
5767
5767
|
} catch (error) {
|
|
5768
5768
|
Logger.error(
|
|
5769
5769
|
`[PractitionerInviteAggService] Error fetching clinic ${clinicId}:`,
|
|
@@ -6198,11 +6198,11 @@ var ProcedureAggregationService = class {
|
|
|
6198
6198
|
return;
|
|
6199
6199
|
}
|
|
6200
6200
|
const batch = this.db.batch();
|
|
6201
|
-
snapshot.docs.forEach((
|
|
6201
|
+
snapshot.docs.forEach((doc3) => {
|
|
6202
6202
|
console.log(
|
|
6203
|
-
`[ProcedureAggregationService] Updating procedure info for calendar event ${
|
|
6203
|
+
`[ProcedureAggregationService] Updating procedure info for calendar event ${doc3.ref.path}`
|
|
6204
6204
|
);
|
|
6205
|
-
batch.update(
|
|
6205
|
+
batch.update(doc3.ref, {
|
|
6206
6206
|
procedureInfo,
|
|
6207
6207
|
updatedAt: admin12.firestore.FieldValue.serverTimestamp()
|
|
6208
6208
|
});
|
|
@@ -6245,11 +6245,11 @@ var ProcedureAggregationService = class {
|
|
|
6245
6245
|
return;
|
|
6246
6246
|
}
|
|
6247
6247
|
const batch = this.db.batch();
|
|
6248
|
-
snapshot.docs.forEach((
|
|
6248
|
+
snapshot.docs.forEach((doc3) => {
|
|
6249
6249
|
console.log(
|
|
6250
|
-
`[ProcedureAggregationService] Canceling calendar event ${
|
|
6250
|
+
`[ProcedureAggregationService] Canceling calendar event ${doc3.ref.path}`
|
|
6251
6251
|
);
|
|
6252
|
-
batch.update(
|
|
6252
|
+
batch.update(doc3.ref, {
|
|
6253
6253
|
status: "CANCELED",
|
|
6254
6254
|
cancelReason: "Procedure deleted or inactivated",
|
|
6255
6255
|
updatedAt: admin12.firestore.FieldValue.serverTimestamp()
|
|
@@ -6630,7 +6630,7 @@ var ReviewsAggregationService = class {
|
|
|
6630
6630
|
);
|
|
6631
6631
|
return updatedReviewInfo2;
|
|
6632
6632
|
}
|
|
6633
|
-
const reviews = reviewsQuery.docs.map((
|
|
6633
|
+
const reviews = reviewsQuery.docs.map((doc3) => doc3.data());
|
|
6634
6634
|
const clinicReviews = reviews.map((review) => review.clinicReview).filter((review) => review !== void 0);
|
|
6635
6635
|
let totalRating = 0;
|
|
6636
6636
|
let totalCleanliness = 0;
|
|
@@ -6720,7 +6720,7 @@ var ReviewsAggregationService = class {
|
|
|
6720
6720
|
);
|
|
6721
6721
|
return updatedReviewInfo2;
|
|
6722
6722
|
}
|
|
6723
|
-
const reviews = reviewsQuery.docs.map((
|
|
6723
|
+
const reviews = reviewsQuery.docs.map((doc3) => doc3.data());
|
|
6724
6724
|
const practitionerReviews = reviews.map((review) => review.practitionerReview).filter((review) => review !== void 0);
|
|
6725
6725
|
let totalRating = 0;
|
|
6726
6726
|
let totalKnowledgeAndExpertise = 0;
|
|
@@ -6793,7 +6793,7 @@ var ReviewsAggregationService = class {
|
|
|
6793
6793
|
recommendationPercentage: 0
|
|
6794
6794
|
};
|
|
6795
6795
|
const allReviewsQuery = await this.db.collection(REVIEWS_COLLECTION).get();
|
|
6796
|
-
const reviews = allReviewsQuery.docs.map((
|
|
6796
|
+
const reviews = allReviewsQuery.docs.map((doc3) => doc3.data());
|
|
6797
6797
|
const procedureReviews = [];
|
|
6798
6798
|
reviews.forEach((review) => {
|
|
6799
6799
|
if (review.procedureReview && review.procedureReview.procedureId === procedureId) {
|
|
@@ -6963,7 +6963,7 @@ var ReviewsAggregationService = class {
|
|
|
6963
6963
|
var admin14 = __toESM(require("firebase-admin"));
|
|
6964
6964
|
|
|
6965
6965
|
// src/services/analytics/analytics.service.ts
|
|
6966
|
-
var
|
|
6966
|
+
var import_firestore4 = require("firebase/firestore");
|
|
6967
6967
|
|
|
6968
6968
|
// src/services/base.service.ts
|
|
6969
6969
|
var import_storage = require("firebase/storage");
|
|
@@ -7063,7 +7063,9 @@ function extractProductUsage(appointment) {
|
|
|
7063
7063
|
if (item.type === "item" && item.productId) {
|
|
7064
7064
|
const price = item.priceOverrideAmount || item.price || 0;
|
|
7065
7065
|
const quantity = item.quantity || 1;
|
|
7066
|
-
const
|
|
7066
|
+
const calculatedSubtotal = price * quantity;
|
|
7067
|
+
const storedSubtotal = item.subtotal || 0;
|
|
7068
|
+
const subtotal = Math.abs(storedSubtotal - calculatedSubtotal) < 0.01 ? storedSubtotal : calculatedSubtotal;
|
|
7067
7069
|
products.push({
|
|
7068
7070
|
productId: item.productId,
|
|
7069
7071
|
productName: item.productName || "Unknown Product",
|
|
@@ -7192,6 +7194,17 @@ function calculateCancellationLeadTime(appointment) {
|
|
|
7192
7194
|
}
|
|
7193
7195
|
|
|
7194
7196
|
// src/services/analytics/utils/appointment-filtering.utils.ts
|
|
7197
|
+
function filterByDateRange(appointments, dateRange) {
|
|
7198
|
+
if (!dateRange) {
|
|
7199
|
+
return appointments;
|
|
7200
|
+
}
|
|
7201
|
+
const startTime = dateRange.start.getTime();
|
|
7202
|
+
const endTime = dateRange.end.getTime();
|
|
7203
|
+
return appointments.filter((appointment) => {
|
|
7204
|
+
const appointmentTime = appointment.appointmentStartTime.toMillis();
|
|
7205
|
+
return appointmentTime >= startTime && appointmentTime <= endTime;
|
|
7206
|
+
});
|
|
7207
|
+
}
|
|
7195
7208
|
function filterAppointments(appointments, filters) {
|
|
7196
7209
|
if (!filters) {
|
|
7197
7210
|
return appointments;
|
|
@@ -7467,6 +7480,7 @@ function calculateGroupedRevenueMetrics(appointments, entityType) {
|
|
|
7467
7480
|
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7468
7481
|
const completed = getCompletedAppointments(appointments);
|
|
7469
7482
|
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7483
|
+
var _a;
|
|
7470
7484
|
const entityAppointments = data.appointments;
|
|
7471
7485
|
const entityCompleted = entityAppointments.filter(
|
|
7472
7486
|
(a) => completed.some((c) => c.id === a.id)
|
|
@@ -7490,6 +7504,13 @@ function calculateGroupedRevenueMetrics(appointments, entityType) {
|
|
|
7490
7504
|
refundedRevenue += costData.cost;
|
|
7491
7505
|
}
|
|
7492
7506
|
});
|
|
7507
|
+
let practitionerId;
|
|
7508
|
+
let practitionerName;
|
|
7509
|
+
if (entityType === "procedure" && entityAppointments.length > 0) {
|
|
7510
|
+
const firstAppointment = entityAppointments[0];
|
|
7511
|
+
practitionerId = firstAppointment.practitionerId;
|
|
7512
|
+
practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
|
|
7513
|
+
}
|
|
7493
7514
|
return {
|
|
7494
7515
|
entityId,
|
|
7495
7516
|
entityName: data.name,
|
|
@@ -7502,7 +7523,9 @@ function calculateGroupedRevenueMetrics(appointments, entityType) {
|
|
|
7502
7523
|
unpaidRevenue,
|
|
7503
7524
|
refundedRevenue,
|
|
7504
7525
|
totalTax,
|
|
7505
|
-
totalSubtotal
|
|
7526
|
+
totalSubtotal,
|
|
7527
|
+
...practitionerId && { practitionerId },
|
|
7528
|
+
...practitionerName && { practitionerName }
|
|
7506
7529
|
};
|
|
7507
7530
|
});
|
|
7508
7531
|
}
|
|
@@ -7510,6 +7533,7 @@ function calculateGroupedProductUsageMetrics(appointments, entityType) {
|
|
|
7510
7533
|
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7511
7534
|
const completed = getCompletedAppointments(appointments);
|
|
7512
7535
|
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7536
|
+
var _a;
|
|
7513
7537
|
const entityAppointments = data.appointments;
|
|
7514
7538
|
const entityCompleted = entityAppointments.filter(
|
|
7515
7539
|
(a) => completed.some((c) => c.id === a.id)
|
|
@@ -7544,6 +7568,13 @@ function calculateGroupedProductUsageMetrics(appointments, entityType) {
|
|
|
7544
7568
|
})).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 10);
|
|
7545
7569
|
const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
|
|
7546
7570
|
const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
|
|
7571
|
+
let practitionerId;
|
|
7572
|
+
let practitionerName;
|
|
7573
|
+
if (entityType === "procedure" && entityAppointments.length > 0) {
|
|
7574
|
+
const firstAppointment = entityAppointments[0];
|
|
7575
|
+
practitionerId = firstAppointment.practitionerId;
|
|
7576
|
+
practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
|
|
7577
|
+
}
|
|
7547
7578
|
return {
|
|
7548
7579
|
entityId,
|
|
7549
7580
|
entityName: data.name,
|
|
@@ -7553,7 +7584,9 @@ function calculateGroupedProductUsageMetrics(appointments, entityType) {
|
|
|
7553
7584
|
totalProductRevenue,
|
|
7554
7585
|
totalProductQuantity,
|
|
7555
7586
|
averageProductsPerAppointment: entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
|
|
7556
|
-
topProducts
|
|
7587
|
+
topProducts,
|
|
7588
|
+
...practitionerId && { practitionerId },
|
|
7589
|
+
...practitionerName && { practitionerName }
|
|
7557
7590
|
};
|
|
7558
7591
|
});
|
|
7559
7592
|
}
|
|
@@ -7561,11 +7594,19 @@ function calculateGroupedTimeEfficiencyMetrics(appointments, entityType) {
|
|
|
7561
7594
|
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7562
7595
|
const completed = getCompletedAppointments(appointments);
|
|
7563
7596
|
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7597
|
+
var _a;
|
|
7564
7598
|
const entityAppointments = data.appointments;
|
|
7565
7599
|
const entityCompleted = entityAppointments.filter(
|
|
7566
7600
|
(a) => completed.some((c) => c.id === a.id)
|
|
7567
7601
|
);
|
|
7568
7602
|
const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
|
|
7603
|
+
let practitionerId;
|
|
7604
|
+
let practitionerName;
|
|
7605
|
+
if (entityType === "procedure" && entityAppointments.length > 0) {
|
|
7606
|
+
const firstAppointment = entityAppointments[0];
|
|
7607
|
+
practitionerId = firstAppointment.practitionerId;
|
|
7608
|
+
practitionerName = (_a = firstAppointment.practitionerInfo) == null ? void 0 : _a.name;
|
|
7609
|
+
}
|
|
7569
7610
|
return {
|
|
7570
7611
|
entityId,
|
|
7571
7612
|
entityName: data.name,
|
|
@@ -7578,7 +7619,9 @@ function calculateGroupedTimeEfficiencyMetrics(appointments, entityType) {
|
|
|
7578
7619
|
totalOverrun: timeMetrics.totalOverrun,
|
|
7579
7620
|
totalUnderutilization: timeMetrics.totalUnderutilization,
|
|
7580
7621
|
averageOverrun: timeMetrics.averageOverrun,
|
|
7581
|
-
averageUnderutilization: timeMetrics.averageUnderutilization
|
|
7622
|
+
averageUnderutilization: timeMetrics.averageUnderutilization,
|
|
7623
|
+
...practitionerId && { practitionerId },
|
|
7624
|
+
...practitionerName && { practitionerName }
|
|
7582
7625
|
};
|
|
7583
7626
|
});
|
|
7584
7627
|
}
|
|
@@ -7660,6 +7703,763 @@ function calculateGroupedPatientBehaviorMetrics(appointments, entityType) {
|
|
|
7660
7703
|
});
|
|
7661
7704
|
}
|
|
7662
7705
|
|
|
7706
|
+
// src/services/analytics/utils/trend-calculation.utils.ts
|
|
7707
|
+
function getPeriodDates(date, period) {
|
|
7708
|
+
const year = date.getFullYear();
|
|
7709
|
+
const month = date.getMonth();
|
|
7710
|
+
const day = date.getDate();
|
|
7711
|
+
let startDate;
|
|
7712
|
+
let endDate;
|
|
7713
|
+
let periodString;
|
|
7714
|
+
switch (period) {
|
|
7715
|
+
case "week": {
|
|
7716
|
+
const dayOfWeek = date.getDay();
|
|
7717
|
+
const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
|
|
7718
|
+
startDate = new Date(year, month, diff);
|
|
7719
|
+
startDate.setHours(0, 0, 0, 0);
|
|
7720
|
+
endDate = new Date(startDate);
|
|
7721
|
+
endDate.setDate(endDate.getDate() + 6);
|
|
7722
|
+
endDate.setHours(23, 59, 59, 999);
|
|
7723
|
+
const weekNumber = getWeekNumber(date);
|
|
7724
|
+
periodString = `${year}-W${weekNumber.toString().padStart(2, "0")}`;
|
|
7725
|
+
break;
|
|
7726
|
+
}
|
|
7727
|
+
case "month": {
|
|
7728
|
+
startDate = new Date(year, month, 1);
|
|
7729
|
+
startDate.setHours(0, 0, 0, 0);
|
|
7730
|
+
endDate = new Date(year, month + 1, 0);
|
|
7731
|
+
endDate.setHours(23, 59, 59, 999);
|
|
7732
|
+
periodString = `${year}-${(month + 1).toString().padStart(2, "0")}`;
|
|
7733
|
+
break;
|
|
7734
|
+
}
|
|
7735
|
+
case "quarter": {
|
|
7736
|
+
const quarter = Math.floor(month / 3);
|
|
7737
|
+
const quarterStartMonth = quarter * 3;
|
|
7738
|
+
startDate = new Date(year, quarterStartMonth, 1);
|
|
7739
|
+
startDate.setHours(0, 0, 0, 0);
|
|
7740
|
+
endDate = new Date(year, quarterStartMonth + 3, 0);
|
|
7741
|
+
endDate.setHours(23, 59, 59, 999);
|
|
7742
|
+
periodString = `${year}-Q${quarter + 1}`;
|
|
7743
|
+
break;
|
|
7744
|
+
}
|
|
7745
|
+
case "year": {
|
|
7746
|
+
startDate = new Date(year, 0, 1);
|
|
7747
|
+
startDate.setHours(0, 0, 0, 0);
|
|
7748
|
+
endDate = new Date(year, 11, 31);
|
|
7749
|
+
endDate.setHours(23, 59, 59, 999);
|
|
7750
|
+
periodString = `${year}`;
|
|
7751
|
+
break;
|
|
7752
|
+
}
|
|
7753
|
+
}
|
|
7754
|
+
return {
|
|
7755
|
+
period: periodString,
|
|
7756
|
+
startDate,
|
|
7757
|
+
endDate
|
|
7758
|
+
};
|
|
7759
|
+
}
|
|
7760
|
+
function getWeekNumber(date) {
|
|
7761
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
7762
|
+
const dayNum = d.getUTCDay() || 7;
|
|
7763
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
7764
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
7765
|
+
return Math.ceil(((d.getTime() - yearStart.getTime()) / 864e5 + 1) / 7);
|
|
7766
|
+
}
|
|
7767
|
+
function groupAppointmentsByPeriod(appointments, period) {
|
|
7768
|
+
const periodMap = /* @__PURE__ */ new Map();
|
|
7769
|
+
appointments.forEach((appointment) => {
|
|
7770
|
+
const appointmentDate = appointment.appointmentStartTime.toDate();
|
|
7771
|
+
const periodInfo = getPeriodDates(appointmentDate, period);
|
|
7772
|
+
const periodKey = periodInfo.period;
|
|
7773
|
+
if (!periodMap.has(periodKey)) {
|
|
7774
|
+
periodMap.set(periodKey, []);
|
|
7775
|
+
}
|
|
7776
|
+
periodMap.get(periodKey).push(appointment);
|
|
7777
|
+
});
|
|
7778
|
+
return periodMap;
|
|
7779
|
+
}
|
|
7780
|
+
function generatePeriods(startDate, endDate, period) {
|
|
7781
|
+
const periods = [];
|
|
7782
|
+
const current = new Date(startDate);
|
|
7783
|
+
while (current <= endDate) {
|
|
7784
|
+
const periodInfo = getPeriodDates(current, period);
|
|
7785
|
+
if (periodInfo.endDate >= startDate && periodInfo.startDate <= endDate) {
|
|
7786
|
+
periods.push(periodInfo);
|
|
7787
|
+
}
|
|
7788
|
+
switch (period) {
|
|
7789
|
+
case "week":
|
|
7790
|
+
current.setDate(current.getDate() + 7);
|
|
7791
|
+
break;
|
|
7792
|
+
case "month":
|
|
7793
|
+
current.setMonth(current.getMonth() + 1);
|
|
7794
|
+
break;
|
|
7795
|
+
case "quarter":
|
|
7796
|
+
current.setMonth(current.getMonth() + 3);
|
|
7797
|
+
break;
|
|
7798
|
+
case "year":
|
|
7799
|
+
current.setFullYear(current.getFullYear() + 1);
|
|
7800
|
+
break;
|
|
7801
|
+
}
|
|
7802
|
+
}
|
|
7803
|
+
return periods.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
|
7804
|
+
}
|
|
7805
|
+
function calculatePercentageChange(current, previous) {
|
|
7806
|
+
if (previous === 0) {
|
|
7807
|
+
return current > 0 ? 100 : 0;
|
|
7808
|
+
}
|
|
7809
|
+
return (current - previous) / previous * 100;
|
|
7810
|
+
}
|
|
7811
|
+
function getTrendChange(current, previous) {
|
|
7812
|
+
const percentageChange = calculatePercentageChange(current, previous);
|
|
7813
|
+
const direction = percentageChange > 0.01 ? "up" : percentageChange < -0.01 ? "down" : "stable";
|
|
7814
|
+
return {
|
|
7815
|
+
value: current,
|
|
7816
|
+
previousValue: previous,
|
|
7817
|
+
percentageChange: Math.abs(percentageChange),
|
|
7818
|
+
direction
|
|
7819
|
+
};
|
|
7820
|
+
}
|
|
7821
|
+
|
|
7822
|
+
// src/services/analytics/review-analytics.service.ts
|
|
7823
|
+
var import_firestore3 = require("firebase/firestore");
|
|
7824
|
+
var ReviewAnalyticsService = class extends BaseService {
|
|
7825
|
+
constructor(db, auth, app, appointmentService) {
|
|
7826
|
+
super(db, auth, app);
|
|
7827
|
+
this.appointmentService = appointmentService;
|
|
7828
|
+
}
|
|
7829
|
+
/**
|
|
7830
|
+
* Fetches reviews filtered by date range and optional filters
|
|
7831
|
+
* Properly filters by clinic branch by checking appointment's clinicId
|
|
7832
|
+
*/
|
|
7833
|
+
async fetchReviews(dateRange, filters) {
|
|
7834
|
+
let q = (0, import_firestore3.query)((0, import_firestore3.collection)(this.db, REVIEWS_COLLECTION));
|
|
7835
|
+
if (dateRange) {
|
|
7836
|
+
const startTimestamp = import_firestore3.Timestamp.fromDate(dateRange.start);
|
|
7837
|
+
const endTimestamp = import_firestore3.Timestamp.fromDate(dateRange.end);
|
|
7838
|
+
q = (0, import_firestore3.query)(q, (0, import_firestore3.where)("createdAt", ">=", startTimestamp), (0, import_firestore3.where)("createdAt", "<=", endTimestamp));
|
|
7839
|
+
}
|
|
7840
|
+
const snapshot = await (0, import_firestore3.getDocs)(q);
|
|
7841
|
+
const reviews = snapshot.docs.map((doc3) => {
|
|
7842
|
+
var _a, _b;
|
|
7843
|
+
const data = doc3.data();
|
|
7844
|
+
return {
|
|
7845
|
+
...data,
|
|
7846
|
+
id: doc3.id,
|
|
7847
|
+
createdAt: ((_a = data.createdAt) == null ? void 0 : _a.toDate) ? data.createdAt.toDate() : new Date(data.createdAt),
|
|
7848
|
+
updatedAt: ((_b = data.updatedAt) == null ? void 0 : _b.toDate) ? data.updatedAt.toDate() : new Date(data.updatedAt)
|
|
7849
|
+
};
|
|
7850
|
+
});
|
|
7851
|
+
console.log(`[ReviewAnalytics] Fetched ${reviews.length} reviews in date range`);
|
|
7852
|
+
if ((filters == null ? void 0 : filters.clinicBranchId) && reviews.length > 0) {
|
|
7853
|
+
const appointmentIds = [...new Set(reviews.map((r) => r.appointmentId))];
|
|
7854
|
+
console.log(`[ReviewAnalytics] Filtering by clinic ${filters.clinicBranchId}, checking ${appointmentIds.length} appointments`);
|
|
7855
|
+
const validAppointmentIds = /* @__PURE__ */ new Set();
|
|
7856
|
+
for (let i = 0; i < appointmentIds.length; i += 10) {
|
|
7857
|
+
const batch = appointmentIds.slice(i, i + 10);
|
|
7858
|
+
const appointmentsQuery = (0, import_firestore3.query)(
|
|
7859
|
+
(0, import_firestore3.collection)(this.db, APPOINTMENTS_COLLECTION),
|
|
7860
|
+
(0, import_firestore3.where)("id", "in", batch)
|
|
7861
|
+
);
|
|
7862
|
+
const appointmentSnapshot = await (0, import_firestore3.getDocs)(appointmentsQuery);
|
|
7863
|
+
appointmentSnapshot.docs.forEach((doc3) => {
|
|
7864
|
+
const appointment = doc3.data();
|
|
7865
|
+
if (appointment.clinicBranchId === filters.clinicBranchId) {
|
|
7866
|
+
validAppointmentIds.add(doc3.id);
|
|
7867
|
+
}
|
|
7868
|
+
});
|
|
7869
|
+
}
|
|
7870
|
+
const filteredReviews = reviews.filter((review) => validAppointmentIds.has(review.appointmentId));
|
|
7871
|
+
console.log(`[ReviewAnalytics] After clinic filter: ${filteredReviews.length} reviews (from ${validAppointmentIds.size} valid appointments)`);
|
|
7872
|
+
return filteredReviews;
|
|
7873
|
+
}
|
|
7874
|
+
return reviews;
|
|
7875
|
+
}
|
|
7876
|
+
/**
|
|
7877
|
+
* Gets review metrics for a specific entity
|
|
7878
|
+
*/
|
|
7879
|
+
async getReviewMetricsByEntity(entityType, entityId, dateRange, filters) {
|
|
7880
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
7881
|
+
let relevantReviews = [];
|
|
7882
|
+
if (entityType === "practitioner") {
|
|
7883
|
+
relevantReviews = reviews.filter((r) => {
|
|
7884
|
+
var _a;
|
|
7885
|
+
return ((_a = r.practitionerReview) == null ? void 0 : _a.practitionerId) === entityId;
|
|
7886
|
+
});
|
|
7887
|
+
} else if (entityType === "procedure") {
|
|
7888
|
+
relevantReviews = reviews.filter((r) => {
|
|
7889
|
+
var _a;
|
|
7890
|
+
return ((_a = r.procedureReview) == null ? void 0 : _a.procedureId) === entityId;
|
|
7891
|
+
});
|
|
7892
|
+
} else if (entityType === "category" || entityType === "subcategory") {
|
|
7893
|
+
relevantReviews = reviews;
|
|
7894
|
+
}
|
|
7895
|
+
if (relevantReviews.length === 0) {
|
|
7896
|
+
return null;
|
|
7897
|
+
}
|
|
7898
|
+
return this.calculateReviewMetrics(relevantReviews, entityType, entityId);
|
|
7899
|
+
}
|
|
7900
|
+
/**
|
|
7901
|
+
* Gets review metrics for multiple entities (grouped)
|
|
7902
|
+
*/
|
|
7903
|
+
async getReviewMetricsByEntities(entityType, dateRange, filters) {
|
|
7904
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
7905
|
+
const entityMap = /* @__PURE__ */ new Map();
|
|
7906
|
+
let practitionerNameMap = null;
|
|
7907
|
+
let procedureNameMap = null;
|
|
7908
|
+
let procedureToTechnologyMap = null;
|
|
7909
|
+
if (entityType === "practitioner" || entityType === "procedure" || entityType === "technology") {
|
|
7910
|
+
if (!this.appointmentService) {
|
|
7911
|
+
console.warn(`[ReviewAnalytics] AppointmentService not available for ${entityType} name resolution`);
|
|
7912
|
+
return [];
|
|
7913
|
+
}
|
|
7914
|
+
console.log(`[ReviewAnalytics] Grouping by ${entityType}, fetching appointments for name resolution...`);
|
|
7915
|
+
const searchParams = {
|
|
7916
|
+
...filters
|
|
7917
|
+
};
|
|
7918
|
+
if (dateRange) {
|
|
7919
|
+
searchParams.startDate = dateRange.start;
|
|
7920
|
+
searchParams.endDate = dateRange.end;
|
|
7921
|
+
}
|
|
7922
|
+
const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
|
|
7923
|
+
const appointments = appointmentsResult.appointments || [];
|
|
7924
|
+
console.log(`[ReviewAnalytics] Found ${appointments.length} appointments for name resolution`);
|
|
7925
|
+
practitionerNameMap = /* @__PURE__ */ new Map();
|
|
7926
|
+
procedureNameMap = /* @__PURE__ */ new Map();
|
|
7927
|
+
procedureToTechnologyMap = /* @__PURE__ */ new Map();
|
|
7928
|
+
appointments.forEach((appointment) => {
|
|
7929
|
+
var _a, _b, _c, _d, _e, _f;
|
|
7930
|
+
if (appointment.practitionerId && ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name)) {
|
|
7931
|
+
practitionerNameMap.set(appointment.practitionerId, appointment.practitionerInfo.name);
|
|
7932
|
+
}
|
|
7933
|
+
if (appointment.procedureId) {
|
|
7934
|
+
if ((_b = appointment.procedureInfo) == null ? void 0 : _b.name) {
|
|
7935
|
+
procedureNameMap.set(appointment.procedureId, appointment.procedureInfo.name);
|
|
7936
|
+
}
|
|
7937
|
+
const mainTechnologyId = ((_c = appointment.procedureExtendedInfo) == null ? void 0 : _c.procedureTechnologyId) || "unknown-technology";
|
|
7938
|
+
const mainTechnologyName = ((_d = appointment.procedureExtendedInfo) == null ? void 0 : _d.procedureTechnologyName) || ((_e = appointment.procedureInfo) == null ? void 0 : _e.name) || "Unknown Technology";
|
|
7939
|
+
procedureToTechnologyMap.set(appointment.procedureId, {
|
|
7940
|
+
id: mainTechnologyId,
|
|
7941
|
+
name: mainTechnologyName
|
|
7942
|
+
});
|
|
7943
|
+
}
|
|
7944
|
+
if ((_f = appointment.metadata) == null ? void 0 : _f.extendedProcedures) {
|
|
7945
|
+
appointment.metadata.extendedProcedures.forEach((extendedProc) => {
|
|
7946
|
+
if (extendedProc.procedureId) {
|
|
7947
|
+
if (extendedProc.procedureName) {
|
|
7948
|
+
procedureNameMap.set(extendedProc.procedureId, extendedProc.procedureName);
|
|
7949
|
+
}
|
|
7950
|
+
const extTechnologyId = extendedProc.procedureTechnologyId || "unknown-technology";
|
|
7951
|
+
const extTechnologyName = extendedProc.procedureTechnologyName || "Unknown Technology";
|
|
7952
|
+
procedureToTechnologyMap.set(extendedProc.procedureId, {
|
|
7953
|
+
id: extTechnologyId,
|
|
7954
|
+
name: extTechnologyName
|
|
7955
|
+
});
|
|
7956
|
+
}
|
|
7957
|
+
});
|
|
7958
|
+
}
|
|
7959
|
+
});
|
|
7960
|
+
console.log(`[ReviewAnalytics] Built name maps: ${practitionerNameMap.size} practitioners, ${procedureNameMap.size} procedures, ${procedureToTechnologyMap.size} technologies`);
|
|
7961
|
+
}
|
|
7962
|
+
if (entityType === "technology" && procedureToTechnologyMap) {
|
|
7963
|
+
let processedReviewCount = 0;
|
|
7964
|
+
reviews.forEach((review) => {
|
|
7965
|
+
var _a;
|
|
7966
|
+
if ((_a = review.procedureReview) == null ? void 0 : _a.procedureId) {
|
|
7967
|
+
const techInfo = procedureToTechnologyMap.get(review.procedureReview.procedureId);
|
|
7968
|
+
if (techInfo) {
|
|
7969
|
+
if (!entityMap.has(techInfo.id)) {
|
|
7970
|
+
entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
|
|
7971
|
+
}
|
|
7972
|
+
entityMap.get(techInfo.id).reviews.push(review);
|
|
7973
|
+
processedReviewCount++;
|
|
7974
|
+
}
|
|
7975
|
+
}
|
|
7976
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
7977
|
+
review.extendedProcedureReviews.forEach((extendedReview) => {
|
|
7978
|
+
if (extendedReview.procedureId) {
|
|
7979
|
+
const techInfo = procedureToTechnologyMap.get(extendedReview.procedureId);
|
|
7980
|
+
if (techInfo) {
|
|
7981
|
+
if (!entityMap.has(techInfo.id)) {
|
|
7982
|
+
entityMap.set(techInfo.id, { reviews: [], name: techInfo.name });
|
|
7983
|
+
}
|
|
7984
|
+
const reviewWithExtendedOnly = {
|
|
7985
|
+
...review,
|
|
7986
|
+
procedureReview: extendedReview,
|
|
7987
|
+
extendedProcedureReviews: void 0
|
|
7988
|
+
};
|
|
7989
|
+
entityMap.get(techInfo.id).reviews.push(reviewWithExtendedOnly);
|
|
7990
|
+
processedReviewCount++;
|
|
7991
|
+
}
|
|
7992
|
+
}
|
|
7993
|
+
});
|
|
7994
|
+
}
|
|
7995
|
+
});
|
|
7996
|
+
console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} technology groups`);
|
|
7997
|
+
entityMap.forEach((data, techId) => {
|
|
7998
|
+
console.log(`[ReviewAnalytics] - ${data.name} (${techId}): ${data.reviews.length} reviews`);
|
|
7999
|
+
});
|
|
8000
|
+
} else if (entityType === "procedure" && procedureNameMap) {
|
|
8001
|
+
let processedReviewCount = 0;
|
|
8002
|
+
reviews.forEach((review) => {
|
|
8003
|
+
if (review.procedureReview) {
|
|
8004
|
+
const procedureId = review.procedureReview.procedureId;
|
|
8005
|
+
const procedureName = procedureId && procedureNameMap.get(procedureId) || review.procedureReview.procedureName || "Unknown Procedure";
|
|
8006
|
+
if (procedureId) {
|
|
8007
|
+
if (!entityMap.has(procedureId)) {
|
|
8008
|
+
entityMap.set(procedureId, { reviews: [], name: procedureName });
|
|
8009
|
+
}
|
|
8010
|
+
entityMap.get(procedureId).reviews.push(review);
|
|
8011
|
+
processedReviewCount++;
|
|
8012
|
+
}
|
|
8013
|
+
}
|
|
8014
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
8015
|
+
review.extendedProcedureReviews.forEach((extendedReview) => {
|
|
8016
|
+
const procedureId = extendedReview.procedureId;
|
|
8017
|
+
const procedureName = procedureId && procedureNameMap.get(procedureId) || extendedReview.procedureName || "Unknown Procedure";
|
|
8018
|
+
if (procedureId) {
|
|
8019
|
+
if (!entityMap.has(procedureId)) {
|
|
8020
|
+
entityMap.set(procedureId, { reviews: [], name: procedureName });
|
|
8021
|
+
}
|
|
8022
|
+
const reviewWithExtendedOnly = {
|
|
8023
|
+
...review,
|
|
8024
|
+
procedureReview: extendedReview,
|
|
8025
|
+
extendedProcedureReviews: void 0
|
|
8026
|
+
};
|
|
8027
|
+
entityMap.get(procedureId).reviews.push(reviewWithExtendedOnly);
|
|
8028
|
+
processedReviewCount++;
|
|
8029
|
+
}
|
|
8030
|
+
});
|
|
8031
|
+
}
|
|
8032
|
+
});
|
|
8033
|
+
console.log(`[ReviewAnalytics] Processed ${processedReviewCount} procedure reviews into ${entityMap.size} procedure groups`);
|
|
8034
|
+
entityMap.forEach((data, procId) => {
|
|
8035
|
+
console.log(`[ReviewAnalytics] - ${data.name} (${procId}): ${data.reviews.length} reviews`);
|
|
8036
|
+
});
|
|
8037
|
+
} else if (entityType === "practitioner" && practitionerNameMap) {
|
|
8038
|
+
reviews.forEach((review) => {
|
|
8039
|
+
if (review.practitionerReview) {
|
|
8040
|
+
const practitionerId = review.practitionerReview.practitionerId;
|
|
8041
|
+
const practitionerName = practitionerId && practitionerNameMap.get(practitionerId) || review.practitionerReview.practitionerName || "Unknown Practitioner";
|
|
8042
|
+
if (practitionerId) {
|
|
8043
|
+
if (!entityMap.has(practitionerId)) {
|
|
8044
|
+
entityMap.set(practitionerId, { reviews: [], name: practitionerName });
|
|
8045
|
+
}
|
|
8046
|
+
entityMap.get(practitionerId).reviews.push(review);
|
|
8047
|
+
}
|
|
8048
|
+
}
|
|
8049
|
+
});
|
|
8050
|
+
console.log(`[ReviewAnalytics] Processed ${reviews.length} reviews into ${entityMap.size} practitioner groups`);
|
|
8051
|
+
entityMap.forEach((data, practId) => {
|
|
8052
|
+
console.log(`[ReviewAnalytics] - ${data.name} (${practId}): ${data.reviews.length} reviews`);
|
|
8053
|
+
});
|
|
8054
|
+
} else {
|
|
8055
|
+
reviews.forEach((review) => {
|
|
8056
|
+
let entityId;
|
|
8057
|
+
let entityName;
|
|
8058
|
+
if (entityId) {
|
|
8059
|
+
if (!entityMap.has(entityId)) {
|
|
8060
|
+
entityMap.set(entityId, { reviews: [], name: entityName || entityId });
|
|
8061
|
+
}
|
|
8062
|
+
entityMap.get(entityId).reviews.push(review);
|
|
8063
|
+
}
|
|
8064
|
+
});
|
|
8065
|
+
}
|
|
8066
|
+
const metrics = [];
|
|
8067
|
+
for (const [entityId, data] of entityMap.entries()) {
|
|
8068
|
+
const metric = this.calculateReviewMetrics(data.reviews, entityType, entityId);
|
|
8069
|
+
if (metric) {
|
|
8070
|
+
metric.entityName = data.name;
|
|
8071
|
+
metrics.push(metric);
|
|
8072
|
+
}
|
|
8073
|
+
}
|
|
8074
|
+
return metrics;
|
|
8075
|
+
}
|
|
8076
|
+
/**
|
|
8077
|
+
* Calculates review metrics from a list of reviews
|
|
8078
|
+
*/
|
|
8079
|
+
calculateReviewMetrics(reviews, entityType, entityId) {
|
|
8080
|
+
if (reviews.length === 0) {
|
|
8081
|
+
return null;
|
|
8082
|
+
}
|
|
8083
|
+
let totalRating = 0;
|
|
8084
|
+
let recommendationCount = 0;
|
|
8085
|
+
let practitionerMetrics;
|
|
8086
|
+
let procedureMetrics;
|
|
8087
|
+
let entityName = entityId;
|
|
8088
|
+
if (entityType === "practitioner") {
|
|
8089
|
+
const practitionerReviews = reviews.filter((r) => r.practitionerReview).map((r) => r.practitionerReview);
|
|
8090
|
+
if (practitionerReviews.length === 0) {
|
|
8091
|
+
return null;
|
|
8092
|
+
}
|
|
8093
|
+
entityName = practitionerReviews[0].practitionerName || entityId;
|
|
8094
|
+
totalRating = practitionerReviews.reduce((sum, r) => sum + r.overallRating, 0);
|
|
8095
|
+
recommendationCount = practitionerReviews.filter((r) => r.wouldRecommend).length;
|
|
8096
|
+
practitionerMetrics = {
|
|
8097
|
+
averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map((r) => r.knowledgeAndExpertise)),
|
|
8098
|
+
averageCommunicationSkills: this.calculateAverage(practitionerReviews.map((r) => r.communicationSkills)),
|
|
8099
|
+
averageBedSideManner: this.calculateAverage(practitionerReviews.map((r) => r.bedSideManner)),
|
|
8100
|
+
averageThoroughness: this.calculateAverage(practitionerReviews.map((r) => r.thoroughness)),
|
|
8101
|
+
averageTrustworthiness: this.calculateAverage(practitionerReviews.map((r) => r.trustworthiness))
|
|
8102
|
+
};
|
|
8103
|
+
} else if (entityType === "procedure" || entityType === "technology") {
|
|
8104
|
+
const procedureReviews = reviews.filter((r) => r.procedureReview).map((r) => r.procedureReview);
|
|
8105
|
+
if (procedureReviews.length === 0) {
|
|
8106
|
+
return null;
|
|
8107
|
+
}
|
|
8108
|
+
if (entityType === "procedure") {
|
|
8109
|
+
entityName = procedureReviews[0].procedureName || entityId;
|
|
8110
|
+
}
|
|
8111
|
+
totalRating = procedureReviews.reduce((sum, r) => sum + r.overallRating, 0);
|
|
8112
|
+
recommendationCount = procedureReviews.filter((r) => r.wouldRecommend).length;
|
|
8113
|
+
procedureMetrics = {
|
|
8114
|
+
averageEffectiveness: this.calculateAverage(procedureReviews.map((r) => r.effectivenessOfTreatment)),
|
|
8115
|
+
averageOutcomeExplanation: this.calculateAverage(procedureReviews.map((r) => r.outcomeExplanation)),
|
|
8116
|
+
averagePainManagement: this.calculateAverage(procedureReviews.map((r) => r.painManagement)),
|
|
8117
|
+
averageFollowUpCare: this.calculateAverage(procedureReviews.map((r) => r.followUpCare)),
|
|
8118
|
+
averageValueForMoney: this.calculateAverage(procedureReviews.map((r) => r.valueForMoney))
|
|
8119
|
+
};
|
|
8120
|
+
}
|
|
8121
|
+
const averageRating = totalRating / reviews.length;
|
|
8122
|
+
const recommendationRate = recommendationCount / reviews.length * 100;
|
|
8123
|
+
const result = {
|
|
8124
|
+
entityId,
|
|
8125
|
+
entityName,
|
|
8126
|
+
entityType,
|
|
8127
|
+
totalReviews: reviews.length,
|
|
8128
|
+
averageRating,
|
|
8129
|
+
recommendationRate,
|
|
8130
|
+
practitionerMetrics,
|
|
8131
|
+
procedureMetrics,
|
|
8132
|
+
comparisonToOverall: {
|
|
8133
|
+
ratingDifference: 0,
|
|
8134
|
+
// Will be calculated when comparing to overall
|
|
8135
|
+
recommendationDifference: 0
|
|
8136
|
+
}
|
|
8137
|
+
};
|
|
8138
|
+
return result;
|
|
8139
|
+
}
|
|
8140
|
+
/**
|
|
8141
|
+
* Gets overall review averages for comparison
|
|
8142
|
+
*/
|
|
8143
|
+
async getOverallReviewAverages(dateRange, filters) {
|
|
8144
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
8145
|
+
const practitionerReviews = reviews.filter((r) => r.practitionerReview).map((r) => r.practitionerReview);
|
|
8146
|
+
const procedureReviews = reviews.filter((r) => r.procedureReview).map((r) => r.procedureReview);
|
|
8147
|
+
return {
|
|
8148
|
+
practitionerAverage: {
|
|
8149
|
+
totalReviews: practitionerReviews.length,
|
|
8150
|
+
averageRating: practitionerReviews.length > 0 ? this.calculateAverage(practitionerReviews.map((r) => r.overallRating)) : 0,
|
|
8151
|
+
recommendationRate: practitionerReviews.length > 0 ? practitionerReviews.filter((r) => r.wouldRecommend).length / practitionerReviews.length * 100 : 0,
|
|
8152
|
+
averageKnowledgeAndExpertise: this.calculateAverage(practitionerReviews.map((r) => r.knowledgeAndExpertise)),
|
|
8153
|
+
averageCommunicationSkills: this.calculateAverage(practitionerReviews.map((r) => r.communicationSkills)),
|
|
8154
|
+
averageBedSideManner: this.calculateAverage(practitionerReviews.map((r) => r.bedSideManner)),
|
|
8155
|
+
averageThoroughness: this.calculateAverage(practitionerReviews.map((r) => r.thoroughness)),
|
|
8156
|
+
averageTrustworthiness: this.calculateAverage(practitionerReviews.map((r) => r.trustworthiness))
|
|
8157
|
+
},
|
|
8158
|
+
procedureAverage: {
|
|
8159
|
+
totalReviews: procedureReviews.length,
|
|
8160
|
+
averageRating: procedureReviews.length > 0 ? this.calculateAverage(procedureReviews.map((r) => r.overallRating)) : 0,
|
|
8161
|
+
recommendationRate: procedureReviews.length > 0 ? procedureReviews.filter((r) => r.wouldRecommend).length / procedureReviews.length * 100 : 0,
|
|
8162
|
+
averageEffectiveness: this.calculateAverage(procedureReviews.map((r) => r.effectivenessOfTreatment)),
|
|
8163
|
+
averageOutcomeExplanation: this.calculateAverage(procedureReviews.map((r) => r.outcomeExplanation)),
|
|
8164
|
+
averagePainManagement: this.calculateAverage(procedureReviews.map((r) => r.painManagement)),
|
|
8165
|
+
averageFollowUpCare: this.calculateAverage(procedureReviews.map((r) => r.followUpCare)),
|
|
8166
|
+
averageValueForMoney: this.calculateAverage(procedureReviews.map((r) => r.valueForMoney))
|
|
8167
|
+
}
|
|
8168
|
+
};
|
|
8169
|
+
}
|
|
8170
|
+
/**
|
|
8171
|
+
* Gets review details for a specific entity
|
|
8172
|
+
*/
|
|
8173
|
+
async getReviewDetails(entityType, entityId, dateRange, filters) {
|
|
8174
|
+
var _a, _b, _c;
|
|
8175
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
8176
|
+
let relevantReviews = [];
|
|
8177
|
+
if (entityType === "practitioner") {
|
|
8178
|
+
relevantReviews = reviews.filter((r) => {
|
|
8179
|
+
var _a2;
|
|
8180
|
+
return ((_a2 = r.practitionerReview) == null ? void 0 : _a2.practitionerId) === entityId;
|
|
8181
|
+
});
|
|
8182
|
+
} else if (entityType === "procedure") {
|
|
8183
|
+
relevantReviews = reviews.filter((r) => {
|
|
8184
|
+
var _a2;
|
|
8185
|
+
return ((_a2 = r.procedureReview) == null ? void 0 : _a2.procedureId) === entityId;
|
|
8186
|
+
});
|
|
8187
|
+
}
|
|
8188
|
+
const details = [];
|
|
8189
|
+
for (const review of relevantReviews) {
|
|
8190
|
+
try {
|
|
8191
|
+
const appointmentDocRef = (0, import_firestore3.doc)(this.db, APPOINTMENTS_COLLECTION, review.appointmentId);
|
|
8192
|
+
const appointmentDoc = await (0, import_firestore3.getDoc)(appointmentDocRef);
|
|
8193
|
+
let appointment = null;
|
|
8194
|
+
if (appointmentDoc.exists()) {
|
|
8195
|
+
appointment = appointmentDoc.data();
|
|
8196
|
+
}
|
|
8197
|
+
const createdAt = review.createdAt instanceof import_firestore3.Timestamp ? review.createdAt.toDate() : new Date(review.createdAt);
|
|
8198
|
+
const appointmentDate = (appointment == null ? void 0 : appointment.appointmentStartTime) ? appointment.appointmentStartTime instanceof import_firestore3.Timestamp ? appointment.appointmentStartTime.toDate() : appointment.appointmentStartTime : createdAt;
|
|
8199
|
+
details.push({
|
|
8200
|
+
reviewId: review.id,
|
|
8201
|
+
appointmentId: review.appointmentId,
|
|
8202
|
+
patientId: review.patientId,
|
|
8203
|
+
patientName: review.patientName || ((_a = appointment == null ? void 0 : appointment.patientInfo) == null ? void 0 : _a.fullName),
|
|
8204
|
+
createdAt,
|
|
8205
|
+
practitionerReview: review.practitionerReview,
|
|
8206
|
+
procedureReview: review.procedureReview,
|
|
8207
|
+
procedureName: (_b = appointment == null ? void 0 : appointment.procedureInfo) == null ? void 0 : _b.name,
|
|
8208
|
+
practitionerName: (_c = appointment == null ? void 0 : appointment.practitionerInfo) == null ? void 0 : _c.name,
|
|
8209
|
+
appointmentDate
|
|
8210
|
+
});
|
|
8211
|
+
} catch (error) {
|
|
8212
|
+
console.warn(`Failed to enhance review ${review.id}:`, error);
|
|
8213
|
+
}
|
|
8214
|
+
}
|
|
8215
|
+
return details;
|
|
8216
|
+
}
|
|
8217
|
+
/**
|
|
8218
|
+
* Helper method to calculate average
|
|
8219
|
+
*/
|
|
8220
|
+
calculateAverage(values) {
|
|
8221
|
+
if (values.length === 0) return 0;
|
|
8222
|
+
const sum = values.reduce((acc, val) => acc + val, 0);
|
|
8223
|
+
return sum / values.length;
|
|
8224
|
+
}
|
|
8225
|
+
/**
|
|
8226
|
+
* Calculate review trends over time
|
|
8227
|
+
* Groups reviews by period and calculates rating and recommendation metrics
|
|
8228
|
+
*
|
|
8229
|
+
* @param dateRange - Date range for trend analysis (must align with period boundaries)
|
|
8230
|
+
* @param period - Period type (week, month, quarter, year)
|
|
8231
|
+
* @param filters - Optional filters for clinic, practitioner, procedure
|
|
8232
|
+
* @param entityType - Optional entity type to group trends by (practitioner, procedure, technology)
|
|
8233
|
+
* @returns Array of review trends with percentage changes
|
|
8234
|
+
*/
|
|
8235
|
+
async getReviewTrends(dateRange, period, filters, entityType) {
|
|
8236
|
+
const reviews = await this.fetchReviews(dateRange, filters);
|
|
8237
|
+
if (reviews.length === 0) {
|
|
8238
|
+
return [];
|
|
8239
|
+
}
|
|
8240
|
+
if (entityType) {
|
|
8241
|
+
return this.getGroupedReviewTrends(reviews, dateRange, period, entityType, filters);
|
|
8242
|
+
}
|
|
8243
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
8244
|
+
const trends = [];
|
|
8245
|
+
let previousAvgRating = 0;
|
|
8246
|
+
let previousRecRate = 0;
|
|
8247
|
+
periods.forEach((periodInfo) => {
|
|
8248
|
+
const periodReviews = reviews.filter((review) => {
|
|
8249
|
+
const reviewDate = review.createdAt instanceof Date ? review.createdAt : review.createdAt.toDate();
|
|
8250
|
+
return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
|
|
8251
|
+
});
|
|
8252
|
+
if (periodReviews.length === 0) {
|
|
8253
|
+
trends.push({
|
|
8254
|
+
period: periodInfo.period,
|
|
8255
|
+
startDate: periodInfo.startDate,
|
|
8256
|
+
endDate: periodInfo.endDate,
|
|
8257
|
+
averageRating: 0,
|
|
8258
|
+
recommendationRate: 0,
|
|
8259
|
+
totalReviews: 0,
|
|
8260
|
+
previousPeriod: void 0
|
|
8261
|
+
});
|
|
8262
|
+
previousAvgRating = 0;
|
|
8263
|
+
previousRecRate = 0;
|
|
8264
|
+
return;
|
|
8265
|
+
}
|
|
8266
|
+
let totalRatingSum = 0;
|
|
8267
|
+
let totalRatingCount = 0;
|
|
8268
|
+
let totalRecommendations = 0;
|
|
8269
|
+
let totalRecommendationCount = 0;
|
|
8270
|
+
periodReviews.forEach((review) => {
|
|
8271
|
+
if (review.practitionerReview) {
|
|
8272
|
+
totalRatingSum += review.practitionerReview.overallRating;
|
|
8273
|
+
totalRatingCount++;
|
|
8274
|
+
if (review.practitionerReview.wouldRecommend) {
|
|
8275
|
+
totalRecommendations++;
|
|
8276
|
+
}
|
|
8277
|
+
totalRecommendationCount++;
|
|
8278
|
+
}
|
|
8279
|
+
if (review.procedureReview) {
|
|
8280
|
+
totalRatingSum += review.procedureReview.overallRating;
|
|
8281
|
+
totalRatingCount++;
|
|
8282
|
+
if (review.procedureReview.wouldRecommend) {
|
|
8283
|
+
totalRecommendations++;
|
|
8284
|
+
}
|
|
8285
|
+
totalRecommendationCount++;
|
|
8286
|
+
}
|
|
8287
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
8288
|
+
review.extendedProcedureReviews.forEach((extReview) => {
|
|
8289
|
+
totalRatingSum += extReview.overallRating;
|
|
8290
|
+
totalRatingCount++;
|
|
8291
|
+
if (extReview.wouldRecommend) {
|
|
8292
|
+
totalRecommendations++;
|
|
8293
|
+
}
|
|
8294
|
+
totalRecommendationCount++;
|
|
8295
|
+
});
|
|
8296
|
+
}
|
|
8297
|
+
});
|
|
8298
|
+
const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
|
|
8299
|
+
const currentRecRate = totalRecommendationCount > 0 ? totalRecommendations / totalRecommendationCount * 100 : 0;
|
|
8300
|
+
const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
|
|
8301
|
+
trends.push({
|
|
8302
|
+
period: periodInfo.period,
|
|
8303
|
+
startDate: periodInfo.startDate,
|
|
8304
|
+
endDate: periodInfo.endDate,
|
|
8305
|
+
averageRating: currentAvgRating,
|
|
8306
|
+
recommendationRate: currentRecRate,
|
|
8307
|
+
totalReviews: periodReviews.length,
|
|
8308
|
+
previousPeriod: previousAvgRating > 0 ? {
|
|
8309
|
+
averageRating: previousAvgRating,
|
|
8310
|
+
recommendationRate: previousRecRate,
|
|
8311
|
+
percentageChange: Math.abs(trendChange.percentageChange),
|
|
8312
|
+
direction: trendChange.direction
|
|
8313
|
+
} : void 0
|
|
8314
|
+
});
|
|
8315
|
+
previousAvgRating = currentAvgRating;
|
|
8316
|
+
previousRecRate = currentRecRate;
|
|
8317
|
+
});
|
|
8318
|
+
return trends;
|
|
8319
|
+
}
|
|
8320
|
+
/**
|
|
8321
|
+
* Calculate grouped review trends (by practitioner, procedure, or technology)
|
|
8322
|
+
* Returns the AVERAGE across all entities of that type for each period
|
|
8323
|
+
* @private
|
|
8324
|
+
*/
|
|
8325
|
+
async getGroupedReviewTrends(reviews, dateRange, period, entityType, filters) {
|
|
8326
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
8327
|
+
const trends = [];
|
|
8328
|
+
let appointments = [];
|
|
8329
|
+
let procedureToTechnologyMap = /* @__PURE__ */ new Map();
|
|
8330
|
+
if (entityType === "technology" && this.appointmentService) {
|
|
8331
|
+
const searchParams = { ...filters };
|
|
8332
|
+
if (dateRange) {
|
|
8333
|
+
searchParams.startDate = dateRange.start;
|
|
8334
|
+
searchParams.endDate = dateRange.end;
|
|
8335
|
+
}
|
|
8336
|
+
const appointmentsResult = await this.appointmentService.searchAppointments(searchParams);
|
|
8337
|
+
appointments = appointmentsResult.appointments || [];
|
|
8338
|
+
appointments.forEach((appointment) => {
|
|
8339
|
+
var _a, _b;
|
|
8340
|
+
if (appointment.procedureId && ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId)) {
|
|
8341
|
+
procedureToTechnologyMap.set(appointment.procedureId, {
|
|
8342
|
+
id: appointment.procedureExtendedInfo.procedureTechnologyId,
|
|
8343
|
+
name: appointment.procedureExtendedInfo.procedureTechnologyName || "Unknown Technology"
|
|
8344
|
+
});
|
|
8345
|
+
}
|
|
8346
|
+
if ((_b = appointment.metadata) == null ? void 0 : _b.extendedProcedures) {
|
|
8347
|
+
appointment.metadata.extendedProcedures.forEach((extProc) => {
|
|
8348
|
+
if (extProc.procedureId && extProc.procedureTechnologyId) {
|
|
8349
|
+
procedureToTechnologyMap.set(extProc.procedureId, {
|
|
8350
|
+
id: extProc.procedureTechnologyId,
|
|
8351
|
+
name: extProc.procedureTechnologyName || "Unknown Technology"
|
|
8352
|
+
});
|
|
8353
|
+
}
|
|
8354
|
+
});
|
|
8355
|
+
}
|
|
8356
|
+
});
|
|
8357
|
+
}
|
|
8358
|
+
let previousAvgRating = 0;
|
|
8359
|
+
let previousRecRate = 0;
|
|
8360
|
+
periods.forEach((periodInfo) => {
|
|
8361
|
+
const periodReviews = reviews.filter((review) => {
|
|
8362
|
+
const reviewDate = review.createdAt instanceof Date ? review.createdAt : review.createdAt.toDate();
|
|
8363
|
+
return reviewDate >= periodInfo.startDate && reviewDate <= periodInfo.endDate;
|
|
8364
|
+
});
|
|
8365
|
+
if (periodReviews.length === 0) {
|
|
8366
|
+
trends.push({
|
|
8367
|
+
period: periodInfo.period,
|
|
8368
|
+
startDate: periodInfo.startDate,
|
|
8369
|
+
endDate: periodInfo.endDate,
|
|
8370
|
+
averageRating: 0,
|
|
8371
|
+
recommendationRate: 0,
|
|
8372
|
+
totalReviews: 0,
|
|
8373
|
+
previousPeriod: void 0
|
|
8374
|
+
});
|
|
8375
|
+
previousAvgRating = 0;
|
|
8376
|
+
previousRecRate = 0;
|
|
8377
|
+
return;
|
|
8378
|
+
}
|
|
8379
|
+
let totalRatingSum = 0;
|
|
8380
|
+
let totalRatingCount = 0;
|
|
8381
|
+
let totalRecommendations = 0;
|
|
8382
|
+
let totalRecommendationCount = 0;
|
|
8383
|
+
periodReviews.forEach((review) => {
|
|
8384
|
+
var _a;
|
|
8385
|
+
if (entityType === "practitioner" && review.practitionerReview) {
|
|
8386
|
+
totalRatingSum += review.practitionerReview.overallRating;
|
|
8387
|
+
totalRatingCount++;
|
|
8388
|
+
if (review.practitionerReview.wouldRecommend) {
|
|
8389
|
+
totalRecommendations++;
|
|
8390
|
+
}
|
|
8391
|
+
totalRecommendationCount++;
|
|
8392
|
+
} else if (entityType === "procedure" && review.procedureReview) {
|
|
8393
|
+
totalRatingSum += review.procedureReview.overallRating;
|
|
8394
|
+
totalRatingCount++;
|
|
8395
|
+
if (review.procedureReview.wouldRecommend) {
|
|
8396
|
+
totalRecommendations++;
|
|
8397
|
+
}
|
|
8398
|
+
totalRecommendationCount++;
|
|
8399
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
8400
|
+
review.extendedProcedureReviews.forEach((extReview) => {
|
|
8401
|
+
totalRatingSum += extReview.overallRating;
|
|
8402
|
+
totalRatingCount++;
|
|
8403
|
+
if (extReview.wouldRecommend) {
|
|
8404
|
+
totalRecommendations++;
|
|
8405
|
+
}
|
|
8406
|
+
totalRecommendationCount++;
|
|
8407
|
+
});
|
|
8408
|
+
}
|
|
8409
|
+
} else if (entityType === "technology") {
|
|
8410
|
+
if ((_a = review.procedureReview) == null ? void 0 : _a.procedureId) {
|
|
8411
|
+
const tech = procedureToTechnologyMap.get(review.procedureReview.procedureId);
|
|
8412
|
+
if (tech) {
|
|
8413
|
+
totalRatingSum += review.procedureReview.overallRating;
|
|
8414
|
+
totalRatingCount++;
|
|
8415
|
+
if (review.procedureReview.wouldRecommend) {
|
|
8416
|
+
totalRecommendations++;
|
|
8417
|
+
}
|
|
8418
|
+
totalRecommendationCount++;
|
|
8419
|
+
}
|
|
8420
|
+
}
|
|
8421
|
+
if (review.extendedProcedureReviews && review.extendedProcedureReviews.length > 0) {
|
|
8422
|
+
review.extendedProcedureReviews.forEach((extReview) => {
|
|
8423
|
+
if (extReview.procedureId) {
|
|
8424
|
+
const tech = procedureToTechnologyMap.get(extReview.procedureId);
|
|
8425
|
+
if (tech) {
|
|
8426
|
+
totalRatingSum += extReview.overallRating;
|
|
8427
|
+
totalRatingCount++;
|
|
8428
|
+
if (extReview.wouldRecommend) {
|
|
8429
|
+
totalRecommendations++;
|
|
8430
|
+
}
|
|
8431
|
+
totalRecommendationCount++;
|
|
8432
|
+
}
|
|
8433
|
+
}
|
|
8434
|
+
});
|
|
8435
|
+
}
|
|
8436
|
+
}
|
|
8437
|
+
});
|
|
8438
|
+
const currentAvgRating = totalRatingCount > 0 ? totalRatingSum / totalRatingCount : 0;
|
|
8439
|
+
const currentRecRate = totalRecommendationCount > 0 ? totalRecommendations / totalRecommendationCount * 100 : 0;
|
|
8440
|
+
const trendChange = getTrendChange(currentAvgRating, previousAvgRating);
|
|
8441
|
+
trends.push({
|
|
8442
|
+
period: periodInfo.period,
|
|
8443
|
+
startDate: periodInfo.startDate,
|
|
8444
|
+
endDate: periodInfo.endDate,
|
|
8445
|
+
averageRating: currentAvgRating,
|
|
8446
|
+
recommendationRate: currentRecRate,
|
|
8447
|
+
totalReviews: totalRatingCount,
|
|
8448
|
+
// Count of reviews for this entity type
|
|
8449
|
+
previousPeriod: previousAvgRating > 0 ? {
|
|
8450
|
+
averageRating: previousAvgRating,
|
|
8451
|
+
recommendationRate: previousRecRate,
|
|
8452
|
+
percentageChange: Math.abs(trendChange.percentageChange),
|
|
8453
|
+
direction: trendChange.direction
|
|
8454
|
+
} : void 0
|
|
8455
|
+
});
|
|
8456
|
+
previousAvgRating = currentAvgRating;
|
|
8457
|
+
previousRecRate = currentRecRate;
|
|
8458
|
+
});
|
|
8459
|
+
return trends;
|
|
8460
|
+
}
|
|
8461
|
+
};
|
|
8462
|
+
|
|
7663
8463
|
// src/services/analytics/analytics.service.ts
|
|
7664
8464
|
var AnalyticsService = class extends BaseService {
|
|
7665
8465
|
/**
|
|
@@ -7673,6 +8473,7 @@ var AnalyticsService = class extends BaseService {
|
|
|
7673
8473
|
constructor(db, auth, app, appointmentService) {
|
|
7674
8474
|
super(db, auth, app);
|
|
7675
8475
|
this.appointmentService = appointmentService;
|
|
8476
|
+
this.reviewAnalyticsService = new ReviewAnalyticsService(db, auth, app, appointmentService);
|
|
7676
8477
|
}
|
|
7677
8478
|
/**
|
|
7678
8479
|
* Fetches appointments with optional filters
|
|
@@ -7685,20 +8486,20 @@ var AnalyticsService = class extends BaseService {
|
|
|
7685
8486
|
try {
|
|
7686
8487
|
const constraints = [];
|
|
7687
8488
|
if (filters == null ? void 0 : filters.clinicBranchId) {
|
|
7688
|
-
constraints.push((0,
|
|
8489
|
+
constraints.push((0, import_firestore4.where)("clinicBranchId", "==", filters.clinicBranchId));
|
|
7689
8490
|
}
|
|
7690
8491
|
if (filters == null ? void 0 : filters.practitionerId) {
|
|
7691
|
-
constraints.push((0,
|
|
8492
|
+
constraints.push((0, import_firestore4.where)("practitionerId", "==", filters.practitionerId));
|
|
7692
8493
|
}
|
|
7693
8494
|
if (filters == null ? void 0 : filters.procedureId) {
|
|
7694
|
-
constraints.push((0,
|
|
8495
|
+
constraints.push((0, import_firestore4.where)("procedureId", "==", filters.procedureId));
|
|
7695
8496
|
}
|
|
7696
8497
|
if (filters == null ? void 0 : filters.patientId) {
|
|
7697
|
-
constraints.push((0,
|
|
8498
|
+
constraints.push((0, import_firestore4.where)("patientId", "==", filters.patientId));
|
|
7698
8499
|
}
|
|
7699
8500
|
if (dateRange) {
|
|
7700
|
-
constraints.push((0,
|
|
7701
|
-
constraints.push((0,
|
|
8501
|
+
constraints.push((0, import_firestore4.where)("appointmentStartTime", ">=", import_firestore4.Timestamp.fromDate(dateRange.start)));
|
|
8502
|
+
constraints.push((0, import_firestore4.where)("appointmentStartTime", "<=", import_firestore4.Timestamp.fromDate(dateRange.end)));
|
|
7702
8503
|
}
|
|
7703
8504
|
const searchParams = {};
|
|
7704
8505
|
if (filters == null ? void 0 : filters.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
|
|
@@ -8180,11 +8981,17 @@ var AnalyticsService = class extends BaseService {
|
|
|
8180
8981
|
groupCancellationsByProcedure(canceled, allAppointments) {
|
|
8181
8982
|
const procedureMap = /* @__PURE__ */ new Map();
|
|
8182
8983
|
allAppointments.forEach((appointment) => {
|
|
8183
|
-
var _a;
|
|
8984
|
+
var _a, _b;
|
|
8184
8985
|
const procedureId = appointment.procedureId;
|
|
8185
8986
|
const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8186
8987
|
if (!procedureMap.has(procedureId)) {
|
|
8187
|
-
procedureMap.set(procedureId, {
|
|
8988
|
+
procedureMap.set(procedureId, {
|
|
8989
|
+
name: procedureName,
|
|
8990
|
+
canceled: [],
|
|
8991
|
+
all: [],
|
|
8992
|
+
practitionerId: appointment.practitionerId,
|
|
8993
|
+
practitionerName: (_b = appointment.practitionerInfo) == null ? void 0 : _b.name
|
|
8994
|
+
});
|
|
8188
8995
|
}
|
|
8189
8996
|
procedureMap.get(procedureId).all.push(appointment);
|
|
8190
8997
|
});
|
|
@@ -8194,15 +9001,20 @@ var AnalyticsService = class extends BaseService {
|
|
|
8194
9001
|
procedureMap.get(procedureId).canceled.push(appointment);
|
|
8195
9002
|
}
|
|
8196
9003
|
});
|
|
8197
|
-
return Array.from(procedureMap.entries()).map(
|
|
8198
|
-
|
|
9004
|
+
return Array.from(procedureMap.entries()).map(([procedureId, data]) => {
|
|
9005
|
+
const metrics = this.calculateCancellationMetrics(
|
|
8199
9006
|
procedureId,
|
|
8200
9007
|
data.name,
|
|
8201
9008
|
"procedure",
|
|
8202
9009
|
data.canceled,
|
|
8203
9010
|
data.all
|
|
8204
|
-
)
|
|
8205
|
-
|
|
9011
|
+
);
|
|
9012
|
+
return {
|
|
9013
|
+
...metrics,
|
|
9014
|
+
...data.practitionerId && { practitionerId: data.practitionerId },
|
|
9015
|
+
...data.practitionerName && { practitionerName: data.practitionerName }
|
|
9016
|
+
};
|
|
9017
|
+
});
|
|
8206
9018
|
}
|
|
8207
9019
|
/**
|
|
8208
9020
|
* Group cancellations by technology
|
|
@@ -8407,11 +9219,17 @@ var AnalyticsService = class extends BaseService {
|
|
|
8407
9219
|
groupNoShowsByProcedure(noShow, allAppointments) {
|
|
8408
9220
|
const procedureMap = /* @__PURE__ */ new Map();
|
|
8409
9221
|
allAppointments.forEach((appointment) => {
|
|
8410
|
-
var _a;
|
|
9222
|
+
var _a, _b;
|
|
8411
9223
|
const procedureId = appointment.procedureId;
|
|
8412
9224
|
const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8413
9225
|
if (!procedureMap.has(procedureId)) {
|
|
8414
|
-
procedureMap.set(procedureId, {
|
|
9226
|
+
procedureMap.set(procedureId, {
|
|
9227
|
+
name: procedureName,
|
|
9228
|
+
noShow: [],
|
|
9229
|
+
all: [],
|
|
9230
|
+
practitionerId: appointment.practitionerId,
|
|
9231
|
+
practitionerName: (_b = appointment.practitionerInfo) == null ? void 0 : _b.name
|
|
9232
|
+
});
|
|
8415
9233
|
}
|
|
8416
9234
|
procedureMap.get(procedureId).all.push(appointment);
|
|
8417
9235
|
});
|
|
@@ -8427,7 +9245,9 @@ var AnalyticsService = class extends BaseService {
|
|
|
8427
9245
|
entityType: "procedure",
|
|
8428
9246
|
totalAppointments: data.all.length,
|
|
8429
9247
|
noShowAppointments: data.noShow.length,
|
|
8430
|
-
noShowRate: calculatePercentage(data.noShow.length, data.all.length)
|
|
9248
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length),
|
|
9249
|
+
...data.practitionerId && { practitionerId: data.practitionerId },
|
|
9250
|
+
...data.practitionerName && { practitionerName: data.practitionerName }
|
|
8431
9251
|
}));
|
|
8432
9252
|
}
|
|
8433
9253
|
/**
|
|
@@ -8480,6 +9300,10 @@ var AnalyticsService = class extends BaseService {
|
|
|
8480
9300
|
* Get revenue metrics
|
|
8481
9301
|
* First checks for stored analytics, then calculates if not available or stale
|
|
8482
9302
|
*
|
|
9303
|
+
* IMPORTANT: Financial calculations only consider COMPLETED appointments.
|
|
9304
|
+
* Confirmed, pending, canceled, and no-show appointments are NOT included in revenue calculations.
|
|
9305
|
+
* Only procedures that have been completed generate revenue.
|
|
9306
|
+
*
|
|
8483
9307
|
* @param filters - Optional filters
|
|
8484
9308
|
* @param dateRange - Optional date range filter
|
|
8485
9309
|
* @param options - Options for reading stored analytics
|
|
@@ -8502,11 +9326,8 @@ var AnalyticsService = class extends BaseService {
|
|
|
8502
9326
|
const completed = getCompletedAppointments(appointments);
|
|
8503
9327
|
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
8504
9328
|
const revenueByStatus = {};
|
|
8505
|
-
|
|
8506
|
-
|
|
8507
|
-
const { totalRevenue: statusRevenue } = calculateTotalRevenue(statusAppointments);
|
|
8508
|
-
revenueByStatus[status] = statusRevenue;
|
|
8509
|
-
});
|
|
9329
|
+
const { totalRevenue: completedRevenue } = calculateTotalRevenue(completed);
|
|
9330
|
+
revenueByStatus["completed" /* COMPLETED */] = completedRevenue;
|
|
8510
9331
|
const revenueByPaymentStatus = {};
|
|
8511
9332
|
Object.values(PaymentStatus).forEach((paymentStatus) => {
|
|
8512
9333
|
const paymentAppointments = completed.filter((a) => a.paymentStatus === paymentStatus);
|
|
@@ -8560,18 +9381,23 @@ var AnalyticsService = class extends BaseService {
|
|
|
8560
9381
|
/**
|
|
8561
9382
|
* Get product usage metrics
|
|
8562
9383
|
*
|
|
9384
|
+
* IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
|
|
9385
|
+
* Products are only considered "used" when the procedure has been completed.
|
|
9386
|
+
* Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
|
|
9387
|
+
*
|
|
8563
9388
|
* @param productId - Optional product ID (if not provided, returns all products)
|
|
8564
9389
|
* @param dateRange - Optional date range filter
|
|
9390
|
+
* @param filters - Optional filters (e.g., clinicBranchId)
|
|
8565
9391
|
* @returns Product usage metrics
|
|
8566
9392
|
*/
|
|
8567
|
-
async getProductUsageMetrics(productId, dateRange) {
|
|
8568
|
-
const appointments = await this.fetchAppointments(
|
|
9393
|
+
async getProductUsageMetrics(productId, dateRange, filters) {
|
|
9394
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
8569
9395
|
const completed = getCompletedAppointments(appointments);
|
|
8570
9396
|
const productMap = /* @__PURE__ */ new Map();
|
|
8571
9397
|
completed.forEach((appointment) => {
|
|
8572
9398
|
const products = extractProductUsage(appointment);
|
|
9399
|
+
const productsInThisAppointment = /* @__PURE__ */ new Set();
|
|
8573
9400
|
products.forEach((product) => {
|
|
8574
|
-
var _a;
|
|
8575
9401
|
if (productId && product.productId !== productId) {
|
|
8576
9402
|
return;
|
|
8577
9403
|
}
|
|
@@ -8583,25 +9409,37 @@ var AnalyticsService = class extends BaseService {
|
|
|
8583
9409
|
quantity: 0,
|
|
8584
9410
|
revenue: 0,
|
|
8585
9411
|
usageCount: 0,
|
|
9412
|
+
appointmentIds: /* @__PURE__ */ new Set(),
|
|
8586
9413
|
procedureMap: /* @__PURE__ */ new Map()
|
|
8587
9414
|
});
|
|
8588
9415
|
}
|
|
8589
9416
|
const productData = productMap.get(product.productId);
|
|
8590
9417
|
productData.quantity += product.quantity;
|
|
8591
9418
|
productData.revenue += product.subtotal;
|
|
8592
|
-
|
|
8593
|
-
|
|
8594
|
-
|
|
8595
|
-
|
|
8596
|
-
|
|
8597
|
-
|
|
8598
|
-
|
|
8599
|
-
|
|
8600
|
-
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
9419
|
+
productsInThisAppointment.add(product.productId);
|
|
9420
|
+
});
|
|
9421
|
+
productsInThisAppointment.forEach((productId2) => {
|
|
9422
|
+
var _a;
|
|
9423
|
+
const productData = productMap.get(productId2);
|
|
9424
|
+
if (!productData.appointmentIds.has(appointment.id)) {
|
|
9425
|
+
productData.appointmentIds.add(appointment.id);
|
|
9426
|
+
productData.usageCount++;
|
|
9427
|
+
const procId = appointment.procedureId;
|
|
9428
|
+
const procName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
9429
|
+
if (productData.procedureMap.has(procId)) {
|
|
9430
|
+
const procData = productData.procedureMap.get(procId);
|
|
9431
|
+
procData.count++;
|
|
9432
|
+
const appointmentProducts = products.filter((p) => p.productId === productId2);
|
|
9433
|
+
procData.quantity += appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
|
|
9434
|
+
} else {
|
|
9435
|
+
const appointmentProducts = products.filter((p) => p.productId === productId2);
|
|
9436
|
+
const totalQuantity = appointmentProducts.reduce((sum, p) => sum + p.quantity, 0);
|
|
9437
|
+
productData.procedureMap.set(procId, {
|
|
9438
|
+
name: procName,
|
|
9439
|
+
count: 1,
|
|
9440
|
+
quantity: totalQuantity
|
|
9441
|
+
});
|
|
9442
|
+
}
|
|
8605
9443
|
}
|
|
8606
9444
|
});
|
|
8607
9445
|
});
|
|
@@ -8839,6 +9677,341 @@ var AnalyticsService = class extends BaseService {
|
|
|
8839
9677
|
recentActivity
|
|
8840
9678
|
};
|
|
8841
9679
|
}
|
|
9680
|
+
/**
|
|
9681
|
+
* Calculate revenue trends over time
|
|
9682
|
+
* Groups appointments by week/month/quarter/year and calculates revenue metrics
|
|
9683
|
+
*
|
|
9684
|
+
* @param dateRange - Date range for trend analysis (must align with period boundaries)
|
|
9685
|
+
* @param period - Period type (week, month, quarter, year)
|
|
9686
|
+
* @param filters - Optional filters for clinic, practitioner, procedure, patient
|
|
9687
|
+
* @param groupBy - Optional entity type to group trends by (clinic, practitioner, procedure, technology, patient)
|
|
9688
|
+
* @returns Array of revenue trends with percentage changes
|
|
9689
|
+
*/
|
|
9690
|
+
async getRevenueTrends(dateRange, period, filters, groupBy) {
|
|
9691
|
+
const appointments = await this.fetchAppointments(filters);
|
|
9692
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
9693
|
+
if (filtered.length === 0) {
|
|
9694
|
+
return [];
|
|
9695
|
+
}
|
|
9696
|
+
if (groupBy) {
|
|
9697
|
+
return this.getGroupedRevenueTrends(filtered, dateRange, period, groupBy);
|
|
9698
|
+
}
|
|
9699
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period);
|
|
9700
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9701
|
+
const trends = [];
|
|
9702
|
+
let previousRevenue = 0;
|
|
9703
|
+
let previousAppointmentCount = 0;
|
|
9704
|
+
periods.forEach((periodInfo) => {
|
|
9705
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9706
|
+
const completed = getCompletedAppointments(periodAppointments);
|
|
9707
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
9708
|
+
const appointmentCount = completed.length;
|
|
9709
|
+
const averageRevenue = appointmentCount > 0 ? totalRevenue / appointmentCount : 0;
|
|
9710
|
+
const trend = {
|
|
9711
|
+
period: periodInfo.period,
|
|
9712
|
+
startDate: periodInfo.startDate,
|
|
9713
|
+
endDate: periodInfo.endDate,
|
|
9714
|
+
revenue: totalRevenue,
|
|
9715
|
+
appointmentCount,
|
|
9716
|
+
averageRevenue,
|
|
9717
|
+
currency
|
|
9718
|
+
};
|
|
9719
|
+
if (previousRevenue > 0 || previousAppointmentCount > 0) {
|
|
9720
|
+
const revenueChange = getTrendChange(totalRevenue, previousRevenue);
|
|
9721
|
+
trend.previousPeriod = {
|
|
9722
|
+
revenue: previousRevenue,
|
|
9723
|
+
appointmentCount: previousAppointmentCount,
|
|
9724
|
+
percentageChange: revenueChange.percentageChange,
|
|
9725
|
+
direction: revenueChange.direction
|
|
9726
|
+
};
|
|
9727
|
+
}
|
|
9728
|
+
trends.push(trend);
|
|
9729
|
+
previousRevenue = totalRevenue;
|
|
9730
|
+
previousAppointmentCount = appointmentCount;
|
|
9731
|
+
});
|
|
9732
|
+
return trends;
|
|
9733
|
+
}
|
|
9734
|
+
/**
|
|
9735
|
+
* Calculate revenue trends grouped by entity
|
|
9736
|
+
*/
|
|
9737
|
+
async getGroupedRevenueTrends(appointments, dateRange, period, groupBy) {
|
|
9738
|
+
const periodMap = groupAppointmentsByPeriod(appointments, period);
|
|
9739
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9740
|
+
const trends = [];
|
|
9741
|
+
periods.forEach((periodInfo) => {
|
|
9742
|
+
var _a;
|
|
9743
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9744
|
+
if (periodAppointments.length === 0) return;
|
|
9745
|
+
const groupedMetrics = calculateGroupedRevenueMetrics(periodAppointments, groupBy);
|
|
9746
|
+
const totalRevenue = groupedMetrics.reduce((sum, m) => sum + m.totalRevenue, 0);
|
|
9747
|
+
const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
|
|
9748
|
+
const currency = ((_a = groupedMetrics[0]) == null ? void 0 : _a.currency) || "CHF";
|
|
9749
|
+
const averageRevenue = totalAppointments > 0 ? totalRevenue / totalAppointments : 0;
|
|
9750
|
+
trends.push({
|
|
9751
|
+
period: periodInfo.period,
|
|
9752
|
+
startDate: periodInfo.startDate,
|
|
9753
|
+
endDate: periodInfo.endDate,
|
|
9754
|
+
revenue: totalRevenue,
|
|
9755
|
+
appointmentCount: totalAppointments,
|
|
9756
|
+
averageRevenue,
|
|
9757
|
+
currency
|
|
9758
|
+
});
|
|
9759
|
+
});
|
|
9760
|
+
for (let i = 1; i < trends.length; i++) {
|
|
9761
|
+
const current = trends[i];
|
|
9762
|
+
const previous = trends[i - 1];
|
|
9763
|
+
const revenueChange = getTrendChange(current.revenue, previous.revenue);
|
|
9764
|
+
current.previousPeriod = {
|
|
9765
|
+
revenue: previous.revenue,
|
|
9766
|
+
appointmentCount: previous.appointmentCount,
|
|
9767
|
+
percentageChange: revenueChange.percentageChange,
|
|
9768
|
+
direction: revenueChange.direction
|
|
9769
|
+
};
|
|
9770
|
+
}
|
|
9771
|
+
return trends;
|
|
9772
|
+
}
|
|
9773
|
+
/**
|
|
9774
|
+
* Calculate duration/efficiency trends over time
|
|
9775
|
+
*
|
|
9776
|
+
* @param dateRange - Date range for trend analysis
|
|
9777
|
+
* @param period - Period type (week, month, quarter, year)
|
|
9778
|
+
* @param filters - Optional filters
|
|
9779
|
+
* @param groupBy - Optional entity type to group trends by
|
|
9780
|
+
* @returns Array of duration trends with percentage changes
|
|
9781
|
+
*/
|
|
9782
|
+
async getDurationTrends(dateRange, period, filters, groupBy) {
|
|
9783
|
+
const appointments = await this.fetchAppointments(filters);
|
|
9784
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
9785
|
+
if (filtered.length === 0) {
|
|
9786
|
+
return [];
|
|
9787
|
+
}
|
|
9788
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period);
|
|
9789
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9790
|
+
const trends = [];
|
|
9791
|
+
let previousEfficiency = 0;
|
|
9792
|
+
let previousBookedDuration = 0;
|
|
9793
|
+
let previousActualDuration = 0;
|
|
9794
|
+
periods.forEach((periodInfo) => {
|
|
9795
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9796
|
+
const completed = getCompletedAppointments(periodAppointments);
|
|
9797
|
+
if (groupBy) {
|
|
9798
|
+
const groupedMetrics = calculateGroupedTimeEfficiencyMetrics(completed, groupBy);
|
|
9799
|
+
if (groupedMetrics.length === 0) return;
|
|
9800
|
+
const totalAppointments = groupedMetrics.reduce((sum, m) => sum + m.totalAppointments, 0);
|
|
9801
|
+
const weightedBooked = groupedMetrics.reduce(
|
|
9802
|
+
(sum, m) => sum + m.averageBookedDuration * m.totalAppointments,
|
|
9803
|
+
0
|
|
9804
|
+
);
|
|
9805
|
+
const weightedActual = groupedMetrics.reduce(
|
|
9806
|
+
(sum, m) => sum + m.averageActualDuration * m.totalAppointments,
|
|
9807
|
+
0
|
|
9808
|
+
);
|
|
9809
|
+
const weightedEfficiency = groupedMetrics.reduce(
|
|
9810
|
+
(sum, m) => sum + m.averageEfficiency * m.totalAppointments,
|
|
9811
|
+
0
|
|
9812
|
+
);
|
|
9813
|
+
const averageBookedDuration = totalAppointments > 0 ? weightedBooked / totalAppointments : 0;
|
|
9814
|
+
const averageActualDuration = totalAppointments > 0 ? weightedActual / totalAppointments : 0;
|
|
9815
|
+
const averageEfficiency = totalAppointments > 0 ? weightedEfficiency / totalAppointments : 0;
|
|
9816
|
+
const trend = {
|
|
9817
|
+
period: periodInfo.period,
|
|
9818
|
+
startDate: periodInfo.startDate,
|
|
9819
|
+
endDate: periodInfo.endDate,
|
|
9820
|
+
averageBookedDuration,
|
|
9821
|
+
averageActualDuration,
|
|
9822
|
+
averageEfficiency,
|
|
9823
|
+
appointmentCount: totalAppointments
|
|
9824
|
+
};
|
|
9825
|
+
if (previousEfficiency > 0) {
|
|
9826
|
+
const efficiencyChange = getTrendChange(averageEfficiency, previousEfficiency);
|
|
9827
|
+
trend.previousPeriod = {
|
|
9828
|
+
averageBookedDuration: previousBookedDuration,
|
|
9829
|
+
averageActualDuration: previousActualDuration,
|
|
9830
|
+
averageEfficiency: previousEfficiency,
|
|
9831
|
+
efficiencyPercentageChange: efficiencyChange.percentageChange,
|
|
9832
|
+
direction: efficiencyChange.direction
|
|
9833
|
+
};
|
|
9834
|
+
}
|
|
9835
|
+
trends.push(trend);
|
|
9836
|
+
previousEfficiency = averageEfficiency;
|
|
9837
|
+
previousBookedDuration = averageBookedDuration;
|
|
9838
|
+
previousActualDuration = averageActualDuration;
|
|
9839
|
+
} else {
|
|
9840
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
9841
|
+
const trend = {
|
|
9842
|
+
period: periodInfo.period,
|
|
9843
|
+
startDate: periodInfo.startDate,
|
|
9844
|
+
endDate: periodInfo.endDate,
|
|
9845
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
9846
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
9847
|
+
averageEfficiency: timeMetrics.averageEfficiency,
|
|
9848
|
+
appointmentCount: timeMetrics.appointmentsWithActualTime
|
|
9849
|
+
};
|
|
9850
|
+
if (previousEfficiency > 0) {
|
|
9851
|
+
const efficiencyChange = getTrendChange(timeMetrics.averageEfficiency, previousEfficiency);
|
|
9852
|
+
trend.previousPeriod = {
|
|
9853
|
+
averageBookedDuration: previousBookedDuration,
|
|
9854
|
+
averageActualDuration: previousActualDuration,
|
|
9855
|
+
averageEfficiency: previousEfficiency,
|
|
9856
|
+
efficiencyPercentageChange: efficiencyChange.percentageChange,
|
|
9857
|
+
direction: efficiencyChange.direction
|
|
9858
|
+
};
|
|
9859
|
+
}
|
|
9860
|
+
trends.push(trend);
|
|
9861
|
+
previousEfficiency = timeMetrics.averageEfficiency;
|
|
9862
|
+
previousBookedDuration = timeMetrics.averageBookedDuration;
|
|
9863
|
+
previousActualDuration = timeMetrics.averageActualDuration;
|
|
9864
|
+
}
|
|
9865
|
+
});
|
|
9866
|
+
return trends;
|
|
9867
|
+
}
|
|
9868
|
+
/**
|
|
9869
|
+
* Calculate appointment count trends over time
|
|
9870
|
+
*
|
|
9871
|
+
* @param dateRange - Date range for trend analysis
|
|
9872
|
+
* @param period - Period type (week, month, quarter, year)
|
|
9873
|
+
* @param filters - Optional filters
|
|
9874
|
+
* @param groupBy - Optional entity type to group trends by
|
|
9875
|
+
* @returns Array of appointment trends with percentage changes
|
|
9876
|
+
*/
|
|
9877
|
+
async getAppointmentTrends(dateRange, period, filters, groupBy) {
|
|
9878
|
+
const appointments = await this.fetchAppointments(filters);
|
|
9879
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
9880
|
+
if (filtered.length === 0) {
|
|
9881
|
+
return [];
|
|
9882
|
+
}
|
|
9883
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period);
|
|
9884
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9885
|
+
const trends = [];
|
|
9886
|
+
let previousTotal = 0;
|
|
9887
|
+
let previousCompleted = 0;
|
|
9888
|
+
periods.forEach((periodInfo) => {
|
|
9889
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9890
|
+
const completed = getCompletedAppointments(periodAppointments);
|
|
9891
|
+
const canceled = getCanceledAppointments(periodAppointments);
|
|
9892
|
+
const noShow = getNoShowAppointments(periodAppointments);
|
|
9893
|
+
const pending = periodAppointments.filter((a) => a.status === "pending" /* PENDING */);
|
|
9894
|
+
const confirmed = periodAppointments.filter((a) => a.status === "confirmed" /* CONFIRMED */);
|
|
9895
|
+
const trend = {
|
|
9896
|
+
period: periodInfo.period,
|
|
9897
|
+
startDate: periodInfo.startDate,
|
|
9898
|
+
endDate: periodInfo.endDate,
|
|
9899
|
+
totalAppointments: periodAppointments.length,
|
|
9900
|
+
completedAppointments: completed.length,
|
|
9901
|
+
canceledAppointments: canceled.length,
|
|
9902
|
+
noShowAppointments: noShow.length,
|
|
9903
|
+
pendingAppointments: pending.length,
|
|
9904
|
+
confirmedAppointments: confirmed.length
|
|
9905
|
+
};
|
|
9906
|
+
if (previousTotal > 0) {
|
|
9907
|
+
const totalChange = getTrendChange(periodAppointments.length, previousTotal);
|
|
9908
|
+
trend.previousPeriod = {
|
|
9909
|
+
totalAppointments: previousTotal,
|
|
9910
|
+
completedAppointments: previousCompleted,
|
|
9911
|
+
percentageChange: totalChange.percentageChange,
|
|
9912
|
+
direction: totalChange.direction
|
|
9913
|
+
};
|
|
9914
|
+
}
|
|
9915
|
+
trends.push(trend);
|
|
9916
|
+
previousTotal = periodAppointments.length;
|
|
9917
|
+
previousCompleted = completed.length;
|
|
9918
|
+
});
|
|
9919
|
+
return trends;
|
|
9920
|
+
}
|
|
9921
|
+
/**
|
|
9922
|
+
* Calculate cancellation and no-show rate trends over time
|
|
9923
|
+
*
|
|
9924
|
+
* @param dateRange - Date range for trend analysis
|
|
9925
|
+
* @param period - Period type (week, month, quarter, year)
|
|
9926
|
+
* @param filters - Optional filters
|
|
9927
|
+
* @param groupBy - Optional entity type to group trends by
|
|
9928
|
+
* @returns Array of cancellation rate trends with percentage changes
|
|
9929
|
+
*/
|
|
9930
|
+
async getCancellationRateTrends(dateRange, period, filters, groupBy) {
|
|
9931
|
+
const appointments = await this.fetchAppointments(filters);
|
|
9932
|
+
const filtered = filterByDateRange(appointments, dateRange);
|
|
9933
|
+
if (filtered.length === 0) {
|
|
9934
|
+
return [];
|
|
9935
|
+
}
|
|
9936
|
+
const periodMap = groupAppointmentsByPeriod(filtered, period);
|
|
9937
|
+
const periods = generatePeriods(dateRange.start, dateRange.end, period);
|
|
9938
|
+
const trends = [];
|
|
9939
|
+
let previousCancellationRate = 0;
|
|
9940
|
+
let previousNoShowRate = 0;
|
|
9941
|
+
periods.forEach((periodInfo) => {
|
|
9942
|
+
const periodAppointments = periodMap.get(periodInfo.period) || [];
|
|
9943
|
+
const canceled = getCanceledAppointments(periodAppointments);
|
|
9944
|
+
const noShow = getNoShowAppointments(periodAppointments);
|
|
9945
|
+
const cancellationRate = calculatePercentage(canceled.length, periodAppointments.length);
|
|
9946
|
+
const noShowRate = calculatePercentage(noShow.length, periodAppointments.length);
|
|
9947
|
+
const trend = {
|
|
9948
|
+
period: periodInfo.period,
|
|
9949
|
+
startDate: periodInfo.startDate,
|
|
9950
|
+
endDate: periodInfo.endDate,
|
|
9951
|
+
cancellationRate,
|
|
9952
|
+
noShowRate,
|
|
9953
|
+
totalAppointments: periodAppointments.length,
|
|
9954
|
+
canceledAppointments: canceled.length,
|
|
9955
|
+
noShowAppointments: noShow.length
|
|
9956
|
+
};
|
|
9957
|
+
if (previousCancellationRate > 0 || previousNoShowRate > 0) {
|
|
9958
|
+
const cancellationChange = getTrendChange(cancellationRate, previousCancellationRate);
|
|
9959
|
+
const noShowChange = getTrendChange(noShowRate, previousNoShowRate);
|
|
9960
|
+
trend.previousPeriod = {
|
|
9961
|
+
cancellationRate: previousCancellationRate,
|
|
9962
|
+
noShowRate: previousNoShowRate,
|
|
9963
|
+
cancellationRateChange: cancellationChange.percentageChange,
|
|
9964
|
+
noShowRateChange: noShowChange.percentageChange,
|
|
9965
|
+
direction: cancellationChange.direction
|
|
9966
|
+
// Use cancellation direction as primary
|
|
9967
|
+
};
|
|
9968
|
+
}
|
|
9969
|
+
trends.push(trend);
|
|
9970
|
+
previousCancellationRate = cancellationRate;
|
|
9971
|
+
previousNoShowRate = noShowRate;
|
|
9972
|
+
});
|
|
9973
|
+
return trends;
|
|
9974
|
+
}
|
|
9975
|
+
// ==========================================
|
|
9976
|
+
// Review Analytics Methods
|
|
9977
|
+
// ==========================================
|
|
9978
|
+
/**
|
|
9979
|
+
* Get review metrics for a specific entity (practitioner, procedure, etc.)
|
|
9980
|
+
*/
|
|
9981
|
+
async getReviewMetricsByEntity(entityType, entityId, dateRange, filters) {
|
|
9982
|
+
return this.reviewAnalyticsService.getReviewMetricsByEntity(entityType, entityId, dateRange, filters);
|
|
9983
|
+
}
|
|
9984
|
+
/**
|
|
9985
|
+
* Get review metrics for multiple entities (grouped)
|
|
9986
|
+
*/
|
|
9987
|
+
async getReviewMetricsByEntities(entityType, dateRange, filters) {
|
|
9988
|
+
return this.reviewAnalyticsService.getReviewMetricsByEntities(entityType, dateRange, filters);
|
|
9989
|
+
}
|
|
9990
|
+
/**
|
|
9991
|
+
* Get overall review averages for comparison
|
|
9992
|
+
*/
|
|
9993
|
+
async getOverallReviewAverages(dateRange, filters) {
|
|
9994
|
+
return this.reviewAnalyticsService.getOverallReviewAverages(dateRange, filters);
|
|
9995
|
+
}
|
|
9996
|
+
/**
|
|
9997
|
+
* Get review details for a specific entity
|
|
9998
|
+
*/
|
|
9999
|
+
async getReviewDetails(entityType, entityId, dateRange, filters) {
|
|
10000
|
+
return this.reviewAnalyticsService.getReviewDetails(entityType, entityId, dateRange, filters);
|
|
10001
|
+
}
|
|
10002
|
+
/**
|
|
10003
|
+
* Calculate review trends over time
|
|
10004
|
+
* Groups reviews by period and calculates rating and recommendation metrics
|
|
10005
|
+
*
|
|
10006
|
+
* @param dateRange - Date range for trend analysis
|
|
10007
|
+
* @param period - Period type (week, month, quarter, year)
|
|
10008
|
+
* @param filters - Optional filters for clinic, practitioner, procedure
|
|
10009
|
+
* @param entityType - Optional entity type to group trends by
|
|
10010
|
+
* @returns Array of review trends with percentage changes
|
|
10011
|
+
*/
|
|
10012
|
+
async getReviewTrends(dateRange, period, filters, entityType) {
|
|
10013
|
+
return this.reviewAnalyticsService.getReviewTrends(dateRange, period, filters, entityType);
|
|
10014
|
+
}
|
|
8842
10015
|
};
|
|
8843
10016
|
|
|
8844
10017
|
// src/admin/analytics/analytics.admin.service.ts
|
|
@@ -8871,33 +10044,33 @@ var AnalyticsAdminService = class {
|
|
|
8871
10044
|
createAppointmentServiceAdapter() {
|
|
8872
10045
|
return {
|
|
8873
10046
|
searchAppointments: async (params) => {
|
|
8874
|
-
let
|
|
10047
|
+
let query3 = this.db.collection(APPOINTMENTS_COLLECTION);
|
|
8875
10048
|
if (params.clinicBranchId) {
|
|
8876
|
-
|
|
10049
|
+
query3 = query3.where("clinicBranchId", "==", params.clinicBranchId);
|
|
8877
10050
|
}
|
|
8878
10051
|
if (params.practitionerId) {
|
|
8879
|
-
|
|
10052
|
+
query3 = query3.where("practitionerId", "==", params.practitionerId);
|
|
8880
10053
|
}
|
|
8881
10054
|
if (params.procedureId) {
|
|
8882
|
-
|
|
10055
|
+
query3 = query3.where("procedureId", "==", params.procedureId);
|
|
8883
10056
|
}
|
|
8884
10057
|
if (params.patientId) {
|
|
8885
|
-
|
|
10058
|
+
query3 = query3.where("patientId", "==", params.patientId);
|
|
8886
10059
|
}
|
|
8887
10060
|
if (params.startDate) {
|
|
8888
10061
|
const startDate = params.startDate instanceof Date ? params.startDate : params.startDate.toDate();
|
|
8889
10062
|
const startTimestamp = admin14.firestore.Timestamp.fromDate(startDate);
|
|
8890
|
-
|
|
10063
|
+
query3 = query3.where("appointmentStartTime", ">=", startTimestamp);
|
|
8891
10064
|
}
|
|
8892
10065
|
if (params.endDate) {
|
|
8893
10066
|
const endDate = params.endDate instanceof Date ? params.endDate : params.endDate.toDate();
|
|
8894
10067
|
const endTimestamp = admin14.firestore.Timestamp.fromDate(endDate);
|
|
8895
|
-
|
|
10068
|
+
query3 = query3.where("appointmentStartTime", "<=", endTimestamp);
|
|
8896
10069
|
}
|
|
8897
|
-
const snapshot = await
|
|
8898
|
-
const appointments = snapshot.docs.map((
|
|
8899
|
-
id:
|
|
8900
|
-
...
|
|
10070
|
+
const snapshot = await query3.get();
|
|
10071
|
+
const appointments = snapshot.docs.map((doc3) => ({
|
|
10072
|
+
id: doc3.id,
|
|
10073
|
+
...doc3.data()
|
|
8901
10074
|
}));
|
|
8902
10075
|
return {
|
|
8903
10076
|
appointments,
|
|
@@ -8994,7 +10167,7 @@ var AnalyticsAdminService = class {
|
|
|
8994
10167
|
};
|
|
8995
10168
|
|
|
8996
10169
|
// src/admin/booking/booking.calculator.ts
|
|
8997
|
-
var
|
|
10170
|
+
var import_firestore5 = require("firebase/firestore");
|
|
8998
10171
|
var import_luxon2 = require("luxon");
|
|
8999
10172
|
var BookingAvailabilityCalculator = class {
|
|
9000
10173
|
/**
|
|
@@ -9120,8 +10293,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
9120
10293
|
const intervalStart = workStart < import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
|
|
9121
10294
|
const intervalEnd = workEnd > import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
|
|
9122
10295
|
workingIntervals.push({
|
|
9123
|
-
start:
|
|
9124
|
-
end:
|
|
10296
|
+
start: import_firestore5.Timestamp.fromMillis(intervalStart.toMillis()),
|
|
10297
|
+
end: import_firestore5.Timestamp.fromMillis(intervalEnd.toMillis())
|
|
9125
10298
|
});
|
|
9126
10299
|
if (daySchedule.breaks && daySchedule.breaks.length > 0) {
|
|
9127
10300
|
for (const breakTime of daySchedule.breaks) {
|
|
@@ -9141,8 +10314,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
9141
10314
|
...this.subtractInterval(
|
|
9142
10315
|
workingIntervals[workingIntervals.length - 1],
|
|
9143
10316
|
{
|
|
9144
|
-
start:
|
|
9145
|
-
end:
|
|
10317
|
+
start: import_firestore5.Timestamp.fromMillis(breakStart.toMillis()),
|
|
10318
|
+
end: import_firestore5.Timestamp.fromMillis(breakEnd.toMillis())
|
|
9146
10319
|
}
|
|
9147
10320
|
)
|
|
9148
10321
|
);
|
|
@@ -9252,8 +10425,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
9252
10425
|
const intervalStart = workStart < import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
|
|
9253
10426
|
const intervalEnd = workEnd > import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
|
|
9254
10427
|
workingIntervals.push({
|
|
9255
|
-
start:
|
|
9256
|
-
end:
|
|
10428
|
+
start: import_firestore5.Timestamp.fromMillis(intervalStart.toMillis()),
|
|
10429
|
+
end: import_firestore5.Timestamp.fromMillis(intervalEnd.toMillis())
|
|
9257
10430
|
});
|
|
9258
10431
|
}
|
|
9259
10432
|
}
|
|
@@ -9333,7 +10506,7 @@ var BookingAvailabilityCalculator = class {
|
|
|
9333
10506
|
const isInFuture = slotStart >= earliestBookableTime;
|
|
9334
10507
|
if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
|
|
9335
10508
|
slots.push({
|
|
9336
|
-
start:
|
|
10509
|
+
start: import_firestore5.Timestamp.fromMillis(slotStart.toMillis())
|
|
9337
10510
|
});
|
|
9338
10511
|
}
|
|
9339
10512
|
slotStart = slotStart.plus({ minutes: intervalMinutes });
|
|
@@ -9563,8 +10736,8 @@ var DocumentManagerAdminService = class {
|
|
|
9563
10736
|
const templateIds = technologyTemplates.map((t) => t.templateId);
|
|
9564
10737
|
const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(admin15.firestore.FieldPath.documentId(), "in", templateIds).get();
|
|
9565
10738
|
const templatesMap = /* @__PURE__ */ new Map();
|
|
9566
|
-
templatesSnapshot.forEach((
|
|
9567
|
-
templatesMap.set(
|
|
10739
|
+
templatesSnapshot.forEach((doc3) => {
|
|
10740
|
+
templatesMap.set(doc3.id, doc3.data());
|
|
9568
10741
|
});
|
|
9569
10742
|
for (const templateRef of technologyTemplates) {
|
|
9570
10743
|
const template = templatesMap.get(templateRef.templateId);
|
|
@@ -9805,9 +10978,9 @@ var BookingAdmin = class {
|
|
|
9805
10978
|
);
|
|
9806
10979
|
const eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
9807
10980
|
const snapshot = await eventsRef.get();
|
|
9808
|
-
const events = snapshot.docs.map((
|
|
9809
|
-
...
|
|
9810
|
-
id:
|
|
10981
|
+
const events = snapshot.docs.map((doc3) => ({
|
|
10982
|
+
...doc3.data(),
|
|
10983
|
+
id: doc3.id
|
|
9811
10984
|
})).filter((event) => {
|
|
9812
10985
|
return event.eventTime.end.toMillis() > start.toMillis();
|
|
9813
10986
|
});
|
|
@@ -9851,9 +11024,9 @@ var BookingAdmin = class {
|
|
|
9851
11024
|
);
|
|
9852
11025
|
const eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
9853
11026
|
const snapshot = await eventsRef.get();
|
|
9854
|
-
const events = snapshot.docs.map((
|
|
9855
|
-
...
|
|
9856
|
-
id:
|
|
11027
|
+
const events = snapshot.docs.map((doc3) => ({
|
|
11028
|
+
...doc3.data(),
|
|
11029
|
+
id: doc3.id
|
|
9857
11030
|
})).filter((event) => {
|
|
9858
11031
|
return event.eventTime.end.toMillis() > start.toMillis();
|
|
9859
11032
|
});
|
|
@@ -11708,8 +12881,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
|
|
|
11708
12881
|
*/
|
|
11709
12882
|
async fetchPractitionerById(practitionerId) {
|
|
11710
12883
|
try {
|
|
11711
|
-
const
|
|
11712
|
-
return
|
|
12884
|
+
const doc3 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
|
|
12885
|
+
return doc3.exists ? doc3.data() : null;
|
|
11713
12886
|
} catch (error) {
|
|
11714
12887
|
Logger.error(
|
|
11715
12888
|
"[ExistingPractitionerInviteMailingService] Error fetching practitioner:",
|
|
@@ -11725,8 +12898,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
|
|
|
11725
12898
|
*/
|
|
11726
12899
|
async fetchClinicById(clinicId) {
|
|
11727
12900
|
try {
|
|
11728
|
-
const
|
|
11729
|
-
return
|
|
12901
|
+
const doc3 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
|
|
12902
|
+
return doc3.exists ? doc3.data() : null;
|
|
11730
12903
|
} catch (error) {
|
|
11731
12904
|
Logger.error(
|
|
11732
12905
|
"[ExistingPractitionerInviteMailingService] Error fetching clinic:",
|