@blackcode_sa/metaestetics-api 1.12.68 → 1.13.0
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 +801 -2
- package/dist/admin/index.d.ts +801 -2
- package/dist/admin/index.js +2332 -153
- package/dist/admin/index.mjs +2321 -153
- package/dist/index.d.mts +1057 -2
- package/dist/index.d.ts +1057 -2
- package/dist/index.js +4150 -2117
- package/dist/index.mjs +3832 -1810
- package/package.json +1 -1
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +140 -0
- package/src/admin/analytics/analytics.admin.service.ts +278 -0
- package/src/admin/analytics/index.ts +2 -0
- package/src/admin/index.ts +6 -0
- package/src/backoffice/services/README.md +17 -0
- package/src/backoffice/services/analytics.service.proposal.md +863 -0
- package/src/backoffice/services/analytics.service.summary.md +143 -0
- package/src/services/analytics/ARCHITECTURE.md +199 -0
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
- package/src/services/analytics/QUICK_START.md +393 -0
- package/src/services/analytics/README.md +287 -0
- package/src/services/analytics/SUMMARY.md +141 -0
- package/src/services/analytics/USAGE_GUIDE.md +518 -0
- package/src/services/analytics/analytics-cloud.service.ts +222 -0
- package/src/services/analytics/analytics.service.ts +1632 -0
- package/src/services/analytics/index.ts +3 -0
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
- package/src/services/analytics/utils/cost-calculation.utils.ts +154 -0
- package/src/services/analytics/utils/grouping.utils.ts +394 -0
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
- package/src/services/appointment/appointment.service.ts +50 -6
- package/src/services/index.ts +1 -0
- package/src/types/analytics/analytics.types.ts +500 -0
- package/src/types/analytics/grouped-analytics.types.ts +148 -0
- package/src/types/analytics/index.ts +4 -0
- package/src/types/analytics/stored-analytics.types.ts +137 -0
- package/src/types/index.ts +3 -0
- package/src/types/notifications/index.ts +21 -0
package/dist/admin/index.js
CHANGED
|
@@ -218,6 +218,8 @@ var require_compat = __commonJS({
|
|
|
218
218
|
// src/admin/index.ts
|
|
219
219
|
var index_exports = {};
|
|
220
220
|
__export(index_exports, {
|
|
221
|
+
ANALYTICS_COLLECTION: () => ANALYTICS_COLLECTION,
|
|
222
|
+
AnalyticsAdminService: () => AnalyticsAdminService,
|
|
221
223
|
AppointmentAggregationService: () => AppointmentAggregationService,
|
|
222
224
|
AppointmentMailingService: () => AppointmentMailingService,
|
|
223
225
|
AppointmentStatus: () => AppointmentStatus,
|
|
@@ -225,19 +227,26 @@ __export(index_exports, {
|
|
|
225
227
|
BillingTransactionType: () => BillingTransactionType,
|
|
226
228
|
BookingAdmin: () => BookingAdmin,
|
|
227
229
|
BookingAvailabilityCalculator: () => BookingAvailabilityCalculator,
|
|
230
|
+
CANCELLATION_ANALYTICS_SUBCOLLECTION: () => CANCELLATION_ANALYTICS_SUBCOLLECTION,
|
|
231
|
+
CLINICS_COLLECTION: () => CLINICS_COLLECTION,
|
|
232
|
+
CLINIC_ANALYTICS_SUBCOLLECTION: () => CLINIC_ANALYTICS_SUBCOLLECTION,
|
|
228
233
|
CalendarAdminService: () => CalendarAdminService,
|
|
229
234
|
ClinicAggregationService: () => ClinicAggregationService,
|
|
235
|
+
DASHBOARD_ANALYTICS_SUBCOLLECTION: () => DASHBOARD_ANALYTICS_SUBCOLLECTION,
|
|
230
236
|
DocumentManagerAdminService: () => DocumentManagerAdminService,
|
|
231
237
|
ExistingPractitionerInviteMailingService: () => ExistingPractitionerInviteMailingService,
|
|
232
238
|
FilledFormsAggregationService: () => FilledFormsAggregationService,
|
|
233
239
|
Logger: () => Logger,
|
|
234
240
|
NOTIFICATIONS_COLLECTION: () => NOTIFICATIONS_COLLECTION,
|
|
241
|
+
NO_SHOW_ANALYTICS_SUBCOLLECTION: () => NO_SHOW_ANALYTICS_SUBCOLLECTION,
|
|
235
242
|
NotificationStatus: () => NotificationStatus,
|
|
236
243
|
NotificationType: () => NotificationType,
|
|
237
244
|
NotificationsAdmin: () => NotificationsAdmin,
|
|
238
245
|
PATIENTS_COLLECTION: () => PATIENTS_COLLECTION,
|
|
239
246
|
PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME: () => PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME,
|
|
240
247
|
PATIENT_SENSITIVE_INFO_COLLECTION: () => PATIENT_SENSITIVE_INFO_COLLECTION,
|
|
248
|
+
PRACTITIONER_ANALYTICS_SUBCOLLECTION: () => PRACTITIONER_ANALYTICS_SUBCOLLECTION,
|
|
249
|
+
PROCEDURE_ANALYTICS_SUBCOLLECTION: () => PROCEDURE_ANALYTICS_SUBCOLLECTION,
|
|
241
250
|
PatientAggregationService: () => PatientAggregationService,
|
|
242
251
|
PatientInstructionStatus: () => PatientInstructionStatus,
|
|
243
252
|
PatientRequirementOverallStatus: () => PatientRequirementOverallStatus,
|
|
@@ -248,8 +257,10 @@ __export(index_exports, {
|
|
|
248
257
|
PractitionerInviteStatus: () => PractitionerInviteStatus,
|
|
249
258
|
PractitionerTokenStatus: () => PractitionerTokenStatus,
|
|
250
259
|
ProcedureAggregationService: () => ProcedureAggregationService,
|
|
260
|
+
REVENUE_ANALYTICS_SUBCOLLECTION: () => REVENUE_ANALYTICS_SUBCOLLECTION,
|
|
251
261
|
ReviewsAggregationService: () => ReviewsAggregationService,
|
|
252
262
|
SubscriptionStatus: () => SubscriptionStatus,
|
|
263
|
+
TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION: () => TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
|
|
253
264
|
UserProfileAdminService: () => UserProfileAdminService,
|
|
254
265
|
UserRole: () => UserRole,
|
|
255
266
|
freeConsultationInfrastructure: () => freeConsultationInfrastructure
|
|
@@ -489,6 +500,7 @@ var NotificationType = /* @__PURE__ */ ((NotificationType2) => {
|
|
|
489
500
|
NotificationType2["FORM_REMINDER"] = "formReminder";
|
|
490
501
|
NotificationType2["FORM_SUBMISSION_CONFIRMATION"] = "formSubmissionConfirmation";
|
|
491
502
|
NotificationType2["REVIEW_REQUEST"] = "reviewRequest";
|
|
503
|
+
NotificationType2["PROCEDURE_RECOMMENDATION"] = "procedureRecommendation";
|
|
492
504
|
NotificationType2["PAYMENT_DUE"] = "paymentDue";
|
|
493
505
|
NotificationType2["PAYMENT_CONFIRMATION"] = "paymentConfirmation";
|
|
494
506
|
NotificationType2["PAYMENT_FAILED"] = "paymentFailed";
|
|
@@ -542,6 +554,14 @@ var AppointmentStatus = /* @__PURE__ */ ((AppointmentStatus2) => {
|
|
|
542
554
|
AppointmentStatus2["RESCHEDULED_BY_CLINIC"] = "rescheduled_by_clinic";
|
|
543
555
|
return AppointmentStatus2;
|
|
544
556
|
})(AppointmentStatus || {});
|
|
557
|
+
var PaymentStatus = /* @__PURE__ */ ((PaymentStatus2) => {
|
|
558
|
+
PaymentStatus2["UNPAID"] = "unpaid";
|
|
559
|
+
PaymentStatus2["PAID"] = "paid";
|
|
560
|
+
PaymentStatus2["PARTIALLY_PAID"] = "partially_paid";
|
|
561
|
+
PaymentStatus2["REFUNDED"] = "refunded";
|
|
562
|
+
PaymentStatus2["NOT_APPLICABLE"] = "not_applicable";
|
|
563
|
+
return PaymentStatus2;
|
|
564
|
+
})(PaymentStatus || {});
|
|
545
565
|
var APPOINTMENTS_COLLECTION = "appointments";
|
|
546
566
|
|
|
547
567
|
// src/types/patient/patient-requirements.ts
|
|
@@ -586,6 +606,17 @@ var DOCTOR_FORMS_SUBCOLLECTION = "doctor-forms";
|
|
|
586
606
|
// src/types/reviews/index.ts
|
|
587
607
|
var REVIEWS_COLLECTION = "reviews";
|
|
588
608
|
|
|
609
|
+
// src/types/analytics/stored-analytics.types.ts
|
|
610
|
+
var ANALYTICS_COLLECTION = "analytics";
|
|
611
|
+
var PRACTITIONER_ANALYTICS_SUBCOLLECTION = "practitioners";
|
|
612
|
+
var PROCEDURE_ANALYTICS_SUBCOLLECTION = "procedures";
|
|
613
|
+
var CLINIC_ANALYTICS_SUBCOLLECTION = "clinic";
|
|
614
|
+
var DASHBOARD_ANALYTICS_SUBCOLLECTION = "dashboard";
|
|
615
|
+
var TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION = "time_efficiency";
|
|
616
|
+
var CANCELLATION_ANALYTICS_SUBCOLLECTION = "cancellations";
|
|
617
|
+
var NO_SHOW_ANALYTICS_SUBCOLLECTION = "no_shows";
|
|
618
|
+
var REVENUE_ANALYTICS_SUBCOLLECTION = "revenue";
|
|
619
|
+
|
|
589
620
|
// src/admin/notifications/notifications.admin.ts
|
|
590
621
|
var admin2 = __toESM(require("firebase-admin"));
|
|
591
622
|
var import_expo_server_sdk = require("expo-server-sdk");
|
|
@@ -650,16 +681,16 @@ var Logger = class {
|
|
|
650
681
|
|
|
651
682
|
// src/admin/notifications/notifications.admin.ts
|
|
652
683
|
var NotificationsAdmin = class {
|
|
653
|
-
constructor(
|
|
684
|
+
constructor(firestore19) {
|
|
654
685
|
this.expo = new import_expo_server_sdk.Expo();
|
|
655
|
-
this.db =
|
|
686
|
+
this.db = firestore19 || admin2.firestore();
|
|
656
687
|
}
|
|
657
688
|
/**
|
|
658
689
|
* Dohvata notifikaciju po ID-u
|
|
659
690
|
*/
|
|
660
691
|
async getNotification(id) {
|
|
661
|
-
const
|
|
662
|
-
return
|
|
692
|
+
const doc2 = await this.db.collection("notifications").doc(id).get();
|
|
693
|
+
return doc2.exists ? { id: doc2.id, ...doc2.data() } : null;
|
|
663
694
|
}
|
|
664
695
|
/**
|
|
665
696
|
* Kreira novu notifikaciju
|
|
@@ -846,10 +877,10 @@ var NotificationsAdmin = class {
|
|
|
846
877
|
return;
|
|
847
878
|
}
|
|
848
879
|
const results = await Promise.allSettled(
|
|
849
|
-
pendingNotifications.docs.map(async (
|
|
880
|
+
pendingNotifications.docs.map(async (doc2) => {
|
|
850
881
|
const notification = {
|
|
851
|
-
id:
|
|
852
|
-
...
|
|
882
|
+
id: doc2.id,
|
|
883
|
+
...doc2.data()
|
|
853
884
|
};
|
|
854
885
|
Logger.info(
|
|
855
886
|
`[NotificationsAdmin] Processing notification ${notification.id} of type ${notification.notificationType}`
|
|
@@ -890,8 +921,8 @@ var NotificationsAdmin = class {
|
|
|
890
921
|
break;
|
|
891
922
|
}
|
|
892
923
|
const batch = this.db.batch();
|
|
893
|
-
oldNotifications.docs.forEach((
|
|
894
|
-
batch.delete(
|
|
924
|
+
oldNotifications.docs.forEach((doc2) => {
|
|
925
|
+
batch.delete(doc2.ref);
|
|
895
926
|
});
|
|
896
927
|
await batch.commit();
|
|
897
928
|
totalDeleted += oldNotifications.size;
|
|
@@ -1161,8 +1192,8 @@ var NotificationsAdmin = class {
|
|
|
1161
1192
|
|
|
1162
1193
|
// src/admin/requirements/patient-requirements.admin.service.ts
|
|
1163
1194
|
var PatientRequirementsAdminService = class {
|
|
1164
|
-
constructor(
|
|
1165
|
-
this.db =
|
|
1195
|
+
constructor(firestore19) {
|
|
1196
|
+
this.db = firestore19 || admin3.firestore();
|
|
1166
1197
|
this.notificationsAdmin = new NotificationsAdmin(this.db);
|
|
1167
1198
|
}
|
|
1168
1199
|
/**
|
|
@@ -1490,8 +1521,8 @@ var PatientRequirementsAdminService = class {
|
|
|
1490
1521
|
// src/admin/calendar/calendar.admin.service.ts
|
|
1491
1522
|
var admin4 = __toESM(require("firebase-admin"));
|
|
1492
1523
|
var CalendarAdminService = class {
|
|
1493
|
-
constructor(
|
|
1494
|
-
this.db =
|
|
1524
|
+
constructor(firestore19) {
|
|
1525
|
+
this.db = firestore19 || admin4.firestore();
|
|
1495
1526
|
Logger.info("[CalendarAdminService] Initialized.");
|
|
1496
1527
|
}
|
|
1497
1528
|
/**
|
|
@@ -1777,9 +1808,9 @@ var BaseMailingService = class {
|
|
|
1777
1808
|
* @param firestore Firestore instance provided by the caller
|
|
1778
1809
|
* @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
|
|
1779
1810
|
*/
|
|
1780
|
-
constructor(
|
|
1811
|
+
constructor(firestore19, mailgunClient) {
|
|
1781
1812
|
var _a;
|
|
1782
|
-
this.db =
|
|
1813
|
+
this.db = firestore19;
|
|
1783
1814
|
this.mailgunClient = mailgunClient;
|
|
1784
1815
|
if (!this.db) {
|
|
1785
1816
|
Logger.error("[BaseMailingService] No Firestore instance provided");
|
|
@@ -2321,8 +2352,8 @@ var clinicAppointmentRequestedTemplate = `
|
|
|
2321
2352
|
</html>
|
|
2322
2353
|
`;
|
|
2323
2354
|
var AppointmentMailingService = class extends BaseMailingService {
|
|
2324
|
-
constructor(
|
|
2325
|
-
super(
|
|
2355
|
+
constructor(firestore19, mailgunClient) {
|
|
2356
|
+
super(firestore19, mailgunClient);
|
|
2326
2357
|
this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
|
|
2327
2358
|
Logger.info("[AppointmentMailingService] Initialized.");
|
|
2328
2359
|
}
|
|
@@ -2551,8 +2582,8 @@ var AppointmentAggregationService = class {
|
|
|
2551
2582
|
* @param mailgunClient - An initialized Mailgun client instance.
|
|
2552
2583
|
* @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
|
|
2553
2584
|
*/
|
|
2554
|
-
constructor(mailgunClient,
|
|
2555
|
-
this.db =
|
|
2585
|
+
constructor(mailgunClient, firestore19) {
|
|
2586
|
+
this.db = firestore19 || admin6.firestore();
|
|
2556
2587
|
this.appointmentMailingService = new AppointmentMailingService(
|
|
2557
2588
|
this.db,
|
|
2558
2589
|
mailgunClient
|
|
@@ -2928,6 +2959,11 @@ var AppointmentAggregationService = class {
|
|
|
2928
2959
|
Logger.info(`[AggService] Zone photos changed for appointment ${after.id}`);
|
|
2929
2960
|
await this.handleZonePhotosUpdate(before, after);
|
|
2930
2961
|
}
|
|
2962
|
+
const recommendationsChanged = this.hasRecommendationsChanged(before, after);
|
|
2963
|
+
if (recommendationsChanged) {
|
|
2964
|
+
Logger.info(`[AggService] Recommended procedures changed for appointment ${after.id}`);
|
|
2965
|
+
await this.handleRecommendedProceduresUpdate(before, after, patientProfile);
|
|
2966
|
+
}
|
|
2931
2967
|
Logger.info(`[AggService] Successfully processed UPDATE for appointment: ${after.id}`);
|
|
2932
2968
|
} catch (error) {
|
|
2933
2969
|
Logger.error(
|
|
@@ -3574,10 +3610,10 @@ var AppointmentAggregationService = class {
|
|
|
3574
3610
|
}
|
|
3575
3611
|
const batch = this.db.batch();
|
|
3576
3612
|
let instancesUpdatedCount = 0;
|
|
3577
|
-
instancesSnapshot.docs.forEach((
|
|
3578
|
-
const instance =
|
|
3613
|
+
instancesSnapshot.docs.forEach((doc2) => {
|
|
3614
|
+
const instance = doc2.data();
|
|
3579
3615
|
if (instance.overallStatus !== newOverallStatus && instance.overallStatus !== "failedToProcess" /* FAILED_TO_PROCESS */) {
|
|
3580
|
-
batch.update(
|
|
3616
|
+
batch.update(doc2.ref, {
|
|
3581
3617
|
overallStatus: newOverallStatus,
|
|
3582
3618
|
updatedAt: admin6.firestore.FieldValue.serverTimestamp()
|
|
3583
3619
|
// Cast for now
|
|
@@ -3586,7 +3622,7 @@ var AppointmentAggregationService = class {
|
|
|
3586
3622
|
});
|
|
3587
3623
|
instancesUpdatedCount++;
|
|
3588
3624
|
Logger.debug(
|
|
3589
|
-
`[AggService] Added update for PatientRequirementInstance ${
|
|
3625
|
+
`[AggService] Added update for PatientRequirementInstance ${doc2.id} to batch. New status: ${newOverallStatus}`
|
|
3590
3626
|
);
|
|
3591
3627
|
}
|
|
3592
3628
|
});
|
|
@@ -3761,8 +3797,8 @@ var AppointmentAggregationService = class {
|
|
|
3761
3797
|
// --- Data Fetching Helpers (Consider moving to a data access layer or using existing services if available) ---
|
|
3762
3798
|
async fetchPatientProfile(patientId) {
|
|
3763
3799
|
try {
|
|
3764
|
-
const
|
|
3765
|
-
return
|
|
3800
|
+
const doc2 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
|
|
3801
|
+
return doc2.exists ? doc2.data() : null;
|
|
3766
3802
|
} catch (error) {
|
|
3767
3803
|
Logger.error(`[AggService] Error fetching patient profile ${patientId}:`, error);
|
|
3768
3804
|
return null;
|
|
@@ -3775,12 +3811,12 @@ var AppointmentAggregationService = class {
|
|
|
3775
3811
|
*/
|
|
3776
3812
|
async fetchPatientSensitiveInfo(patientId) {
|
|
3777
3813
|
try {
|
|
3778
|
-
const
|
|
3779
|
-
if (!
|
|
3814
|
+
const doc2 = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
|
|
3815
|
+
if (!doc2.exists) {
|
|
3780
3816
|
Logger.warn(`[AggService] No sensitive info found for patient ${patientId}`);
|
|
3781
3817
|
return null;
|
|
3782
3818
|
}
|
|
3783
|
-
return
|
|
3819
|
+
return doc2.data();
|
|
3784
3820
|
} catch (error) {
|
|
3785
3821
|
Logger.error(`[AggService] Error fetching patient sensitive info ${patientId}:`, error);
|
|
3786
3822
|
return null;
|
|
@@ -3797,12 +3833,12 @@ var AppointmentAggregationService = class {
|
|
|
3797
3833
|
return null;
|
|
3798
3834
|
}
|
|
3799
3835
|
try {
|
|
3800
|
-
const
|
|
3801
|
-
if (!
|
|
3836
|
+
const doc2 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
|
|
3837
|
+
if (!doc2.exists) {
|
|
3802
3838
|
Logger.warn(`[AggService] No practitioner profile found for ID ${practitionerId}`);
|
|
3803
3839
|
return null;
|
|
3804
3840
|
}
|
|
3805
|
-
return
|
|
3841
|
+
return doc2.data();
|
|
3806
3842
|
} catch (error) {
|
|
3807
3843
|
Logger.error(`[AggService] Error fetching practitioner profile ${practitionerId}:`, error);
|
|
3808
3844
|
return null;
|
|
@@ -3819,12 +3855,12 @@ var AppointmentAggregationService = class {
|
|
|
3819
3855
|
return null;
|
|
3820
3856
|
}
|
|
3821
3857
|
try {
|
|
3822
|
-
const
|
|
3823
|
-
if (!
|
|
3858
|
+
const doc2 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
|
|
3859
|
+
if (!doc2.exists) {
|
|
3824
3860
|
Logger.warn(`[AggService] No clinic info found for ID ${clinicId}`);
|
|
3825
3861
|
return null;
|
|
3826
3862
|
}
|
|
3827
|
-
return
|
|
3863
|
+
return doc2.data();
|
|
3828
3864
|
} catch (error) {
|
|
3829
3865
|
Logger.error(`[AggService] Error fetching clinic info ${clinicId}:`, error);
|
|
3830
3866
|
return null;
|
|
@@ -3960,6 +3996,104 @@ var AppointmentAggregationService = class {
|
|
|
3960
3996
|
);
|
|
3961
3997
|
}
|
|
3962
3998
|
}
|
|
3999
|
+
/**
|
|
4000
|
+
* Checks if recommended procedures have changed between two appointment states
|
|
4001
|
+
* @param before - The appointment state before update
|
|
4002
|
+
* @param after - The appointment state after update
|
|
4003
|
+
* @returns True if recommendations have changed, false otherwise
|
|
4004
|
+
*/
|
|
4005
|
+
hasRecommendationsChanged(before, after) {
|
|
4006
|
+
var _a, _b;
|
|
4007
|
+
const beforeRecommendations = ((_a = before.metadata) == null ? void 0 : _a.recommendedProcedures) || [];
|
|
4008
|
+
const afterRecommendations = ((_b = after.metadata) == null ? void 0 : _b.recommendedProcedures) || [];
|
|
4009
|
+
if (beforeRecommendations.length !== afterRecommendations.length) {
|
|
4010
|
+
return true;
|
|
4011
|
+
}
|
|
4012
|
+
for (let i = 0; i < afterRecommendations.length; i++) {
|
|
4013
|
+
const beforeRec = beforeRecommendations[i];
|
|
4014
|
+
const afterRec = afterRecommendations[i];
|
|
4015
|
+
if (!beforeRec || !afterRec) {
|
|
4016
|
+
return true;
|
|
4017
|
+
}
|
|
4018
|
+
if (beforeRec.procedure.procedureId !== afterRec.procedure.procedureId || beforeRec.note !== afterRec.note || beforeRec.timeframe.value !== afterRec.timeframe.value || beforeRec.timeframe.unit !== afterRec.timeframe.unit) {
|
|
4019
|
+
return true;
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
return false;
|
|
4023
|
+
}
|
|
4024
|
+
/**
|
|
4025
|
+
* Handles recommended procedures update - creates notifications for newly added recommendations
|
|
4026
|
+
* @param before - The appointment state before update
|
|
4027
|
+
* @param after - The appointment state after update
|
|
4028
|
+
* @param patientProfile - The patient profile (for expo tokens)
|
|
4029
|
+
*/
|
|
4030
|
+
async handleRecommendedProceduresUpdate(before, after, patientProfile) {
|
|
4031
|
+
var _a, _b, _c, _d, _e;
|
|
4032
|
+
try {
|
|
4033
|
+
const beforeRecommendations = ((_a = before.metadata) == null ? void 0 : _a.recommendedProcedures) || [];
|
|
4034
|
+
const afterRecommendations = ((_b = after.metadata) == null ? void 0 : _b.recommendedProcedures) || [];
|
|
4035
|
+
const newRecommendations = afterRecommendations.slice(beforeRecommendations.length);
|
|
4036
|
+
if (newRecommendations.length === 0) {
|
|
4037
|
+
Logger.info(
|
|
4038
|
+
`[AggService] No new recommendations detected for appointment ${after.id}`
|
|
4039
|
+
);
|
|
4040
|
+
return;
|
|
4041
|
+
}
|
|
4042
|
+
Logger.info(
|
|
4043
|
+
`[AggService] Found ${newRecommendations.length} new recommendation(s) for appointment ${after.id}`
|
|
4044
|
+
);
|
|
4045
|
+
for (let i = 0; i < newRecommendations.length; i++) {
|
|
4046
|
+
const recommendation = newRecommendations[i];
|
|
4047
|
+
const recommendationIndex = beforeRecommendations.length + i;
|
|
4048
|
+
const recommendationId = `${after.id}:${recommendationIndex}`;
|
|
4049
|
+
const timeframeText = `${recommendation.timeframe.value} ${recommendation.timeframe.unit}${recommendation.timeframe.value > 1 ? "s" : ""}`;
|
|
4050
|
+
const notificationPayload = {
|
|
4051
|
+
userId: after.patientId,
|
|
4052
|
+
userRole: "patient" /* PATIENT */,
|
|
4053
|
+
notificationType: "procedureRecommendation" /* PROCEDURE_RECOMMENDATION */,
|
|
4054
|
+
notificationTime: admin6.firestore.Timestamp.now(),
|
|
4055
|
+
notificationTokens: (patientProfile == null ? void 0 : patientProfile.expoTokens) || [],
|
|
4056
|
+
title: "New Procedure Recommendation",
|
|
4057
|
+
body: `${((_c = after.practitionerInfo) == null ? void 0 : _c.name) || "Your doctor"} recommended "${recommendation.procedure.procedureName}" for you. Suggested timeframe: in ${timeframeText}`,
|
|
4058
|
+
appointmentId: after.id,
|
|
4059
|
+
recommendationId,
|
|
4060
|
+
procedureId: recommendation.procedure.procedureId,
|
|
4061
|
+
procedureName: recommendation.procedure.procedureName,
|
|
4062
|
+
practitionerName: ((_d = after.practitionerInfo) == null ? void 0 : _d.name) || "Unknown Practitioner",
|
|
4063
|
+
clinicName: ((_e = after.clinicInfo) == null ? void 0 : _e.name) || "Unknown Clinic",
|
|
4064
|
+
note: recommendation.note,
|
|
4065
|
+
timeframe: recommendation.timeframe
|
|
4066
|
+
};
|
|
4067
|
+
try {
|
|
4068
|
+
const notificationId = await this.notificationsAdmin.createNotification(
|
|
4069
|
+
notificationPayload
|
|
4070
|
+
);
|
|
4071
|
+
Logger.info(
|
|
4072
|
+
`[AggService] Created notification ${notificationId} for recommendation ${recommendationId}`
|
|
4073
|
+
);
|
|
4074
|
+
if ((patientProfile == null ? void 0 : patientProfile.expoTokens) && patientProfile.expoTokens.length > 0) {
|
|
4075
|
+
const notification = await this.notificationsAdmin.getNotification(notificationId);
|
|
4076
|
+
if (notification) {
|
|
4077
|
+
await this.notificationsAdmin.sendPushNotification(notification);
|
|
4078
|
+
Logger.info(
|
|
4079
|
+
`[AggService] Sent push notification for recommendation ${recommendationId}`
|
|
4080
|
+
);
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
} catch (error) {
|
|
4084
|
+
Logger.error(
|
|
4085
|
+
`[AggService] Error creating notification for recommendation ${recommendationId}:`,
|
|
4086
|
+
error
|
|
4087
|
+
);
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
} catch (error) {
|
|
4091
|
+
Logger.error(
|
|
4092
|
+
`[AggService] Error handling recommended procedures update for appointment ${after.id}:`,
|
|
4093
|
+
error
|
|
4094
|
+
);
|
|
4095
|
+
}
|
|
4096
|
+
}
|
|
3963
4097
|
};
|
|
3964
4098
|
|
|
3965
4099
|
// src/admin/aggregation/clinic/clinic.aggregation.service.ts
|
|
@@ -3970,8 +4104,8 @@ var ClinicAggregationService = class {
|
|
|
3970
4104
|
* Constructor for ClinicAggregationService.
|
|
3971
4105
|
* @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
|
|
3972
4106
|
*/
|
|
3973
|
-
constructor(
|
|
3974
|
-
this.db =
|
|
4107
|
+
constructor(firestore19) {
|
|
4108
|
+
this.db = firestore19 || admin7.firestore();
|
|
3975
4109
|
}
|
|
3976
4110
|
/**
|
|
3977
4111
|
* Adds clinic information to a clinic group when a new clinic is created
|
|
@@ -4193,11 +4327,11 @@ var ClinicAggregationService = class {
|
|
|
4193
4327
|
return;
|
|
4194
4328
|
}
|
|
4195
4329
|
const batch = this.db.batch();
|
|
4196
|
-
snapshot.docs.forEach((
|
|
4330
|
+
snapshot.docs.forEach((doc2) => {
|
|
4197
4331
|
console.log(
|
|
4198
|
-
`[ClinicAggregationService] Updating location for calendar event ${
|
|
4332
|
+
`[ClinicAggregationService] Updating location for calendar event ${doc2.ref.path}`
|
|
4199
4333
|
);
|
|
4200
|
-
batch.update(
|
|
4334
|
+
batch.update(doc2.ref, {
|
|
4201
4335
|
eventLocation: newLocation,
|
|
4202
4336
|
updatedAt: admin7.firestore.FieldValue.serverTimestamp()
|
|
4203
4337
|
});
|
|
@@ -4240,11 +4374,11 @@ var ClinicAggregationService = class {
|
|
|
4240
4374
|
return;
|
|
4241
4375
|
}
|
|
4242
4376
|
const batch = this.db.batch();
|
|
4243
|
-
snapshot.docs.forEach((
|
|
4377
|
+
snapshot.docs.forEach((doc2) => {
|
|
4244
4378
|
console.log(
|
|
4245
|
-
`[ClinicAggregationService] Updating clinic info for calendar event ${
|
|
4379
|
+
`[ClinicAggregationService] Updating clinic info for calendar event ${doc2.ref.path}`
|
|
4246
4380
|
);
|
|
4247
|
-
batch.update(
|
|
4381
|
+
batch.update(doc2.ref, {
|
|
4248
4382
|
clinicInfo,
|
|
4249
4383
|
updatedAt: admin7.firestore.FieldValue.serverTimestamp()
|
|
4250
4384
|
});
|
|
@@ -4455,11 +4589,11 @@ var ClinicAggregationService = class {
|
|
|
4455
4589
|
return;
|
|
4456
4590
|
}
|
|
4457
4591
|
const batch = this.db.batch();
|
|
4458
|
-
snapshot.docs.forEach((
|
|
4592
|
+
snapshot.docs.forEach((doc2) => {
|
|
4459
4593
|
console.log(
|
|
4460
|
-
`[ClinicAggregationService] Canceling calendar event ${
|
|
4594
|
+
`[ClinicAggregationService] Canceling calendar event ${doc2.ref.path}`
|
|
4461
4595
|
);
|
|
4462
|
-
batch.update(
|
|
4596
|
+
batch.update(doc2.ref, {
|
|
4463
4597
|
status: "CANCELED",
|
|
4464
4598
|
cancelReason: "Clinic deleted",
|
|
4465
4599
|
updatedAt: admin7.firestore.FieldValue.serverTimestamp()
|
|
@@ -4486,8 +4620,8 @@ var FilledFormsAggregationService = class {
|
|
|
4486
4620
|
* Constructor for FilledFormsAggregationService.
|
|
4487
4621
|
* @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
|
|
4488
4622
|
*/
|
|
4489
|
-
constructor(
|
|
4490
|
-
this.db =
|
|
4623
|
+
constructor(firestore19) {
|
|
4624
|
+
this.db = firestore19 || admin8.firestore();
|
|
4491
4625
|
Logger.info("[FilledFormsAggregationService] Initialized");
|
|
4492
4626
|
}
|
|
4493
4627
|
/**
|
|
@@ -4694,8 +4828,8 @@ var FilledFormsAggregationService = class {
|
|
|
4694
4828
|
var admin9 = __toESM(require("firebase-admin"));
|
|
4695
4829
|
var CALENDAR_SUBCOLLECTION_ID2 = "calendar";
|
|
4696
4830
|
var PatientAggregationService = class {
|
|
4697
|
-
constructor(
|
|
4698
|
-
this.db =
|
|
4831
|
+
constructor(firestore19) {
|
|
4832
|
+
this.db = firestore19 || admin9.firestore();
|
|
4699
4833
|
}
|
|
4700
4834
|
// --- Methods for Patient Creation --- >
|
|
4701
4835
|
// No specific aggregations defined for patient creation in the plan.
|
|
@@ -4727,11 +4861,11 @@ var PatientAggregationService = class {
|
|
|
4727
4861
|
return;
|
|
4728
4862
|
}
|
|
4729
4863
|
const batch = this.db.batch();
|
|
4730
|
-
snapshot.docs.forEach((
|
|
4864
|
+
snapshot.docs.forEach((doc2) => {
|
|
4731
4865
|
console.log(
|
|
4732
|
-
`[PatientAggregationService] Updating patient info for calendar event ${
|
|
4866
|
+
`[PatientAggregationService] Updating patient info for calendar event ${doc2.ref.path}`
|
|
4733
4867
|
);
|
|
4734
|
-
batch.update(
|
|
4868
|
+
batch.update(doc2.ref, {
|
|
4735
4869
|
patientInfo,
|
|
4736
4870
|
updatedAt: admin9.firestore.FieldValue.serverTimestamp()
|
|
4737
4871
|
});
|
|
@@ -4775,11 +4909,11 @@ var PatientAggregationService = class {
|
|
|
4775
4909
|
return;
|
|
4776
4910
|
}
|
|
4777
4911
|
const batch = this.db.batch();
|
|
4778
|
-
snapshot.docs.forEach((
|
|
4912
|
+
snapshot.docs.forEach((doc2) => {
|
|
4779
4913
|
console.log(
|
|
4780
|
-
`[PatientAggregationService] Canceling calendar event ${
|
|
4914
|
+
`[PatientAggregationService] Canceling calendar event ${doc2.ref.path}`
|
|
4781
4915
|
);
|
|
4782
|
-
batch.update(
|
|
4916
|
+
batch.update(doc2.ref, {
|
|
4783
4917
|
status: "CANCELED",
|
|
4784
4918
|
cancelReason: "Patient deleted",
|
|
4785
4919
|
updatedAt: admin9.firestore.FieldValue.serverTimestamp()
|
|
@@ -4803,8 +4937,8 @@ var PatientAggregationService = class {
|
|
|
4803
4937
|
var admin10 = __toESM(require("firebase-admin"));
|
|
4804
4938
|
var CALENDAR_SUBCOLLECTION_ID3 = "calendar";
|
|
4805
4939
|
var PractitionerAggregationService = class {
|
|
4806
|
-
constructor(
|
|
4807
|
-
this.db =
|
|
4940
|
+
constructor(firestore19) {
|
|
4941
|
+
this.db = firestore19 || admin10.firestore();
|
|
4808
4942
|
}
|
|
4809
4943
|
/**
|
|
4810
4944
|
* Adds practitioner information to a clinic when a new practitioner is created
|
|
@@ -4950,11 +5084,11 @@ var PractitionerAggregationService = class {
|
|
|
4950
5084
|
return;
|
|
4951
5085
|
}
|
|
4952
5086
|
const batch = this.db.batch();
|
|
4953
|
-
snapshot.docs.forEach((
|
|
5087
|
+
snapshot.docs.forEach((doc2) => {
|
|
4954
5088
|
console.log(
|
|
4955
|
-
`[PractitionerAggregationService] Updating practitioner info for calendar event ${
|
|
5089
|
+
`[PractitionerAggregationService] Updating practitioner info for calendar event ${doc2.ref.path}`
|
|
4956
5090
|
);
|
|
4957
|
-
batch.update(
|
|
5091
|
+
batch.update(doc2.ref, {
|
|
4958
5092
|
practitionerInfo,
|
|
4959
5093
|
updatedAt: admin10.firestore.FieldValue.serverTimestamp()
|
|
4960
5094
|
});
|
|
@@ -5038,11 +5172,11 @@ var PractitionerAggregationService = class {
|
|
|
5038
5172
|
return;
|
|
5039
5173
|
}
|
|
5040
5174
|
const batch = this.db.batch();
|
|
5041
|
-
snapshot.docs.forEach((
|
|
5175
|
+
snapshot.docs.forEach((doc2) => {
|
|
5042
5176
|
console.log(
|
|
5043
|
-
`[PractitionerAggregationService] Canceling calendar event ${
|
|
5177
|
+
`[PractitionerAggregationService] Canceling calendar event ${doc2.ref.path}`
|
|
5044
5178
|
);
|
|
5045
|
-
batch.update(
|
|
5179
|
+
batch.update(doc2.ref, {
|
|
5046
5180
|
status: "CANCELED",
|
|
5047
5181
|
cancelReason: "Practitioner deleted",
|
|
5048
5182
|
updatedAt: admin10.firestore.FieldValue.serverTimestamp()
|
|
@@ -5143,8 +5277,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5143
5277
|
* @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
|
|
5144
5278
|
* @param mailingService Optional mailing service for sending emails
|
|
5145
5279
|
*/
|
|
5146
|
-
constructor(
|
|
5147
|
-
this.db =
|
|
5280
|
+
constructor(firestore19, mailingService) {
|
|
5281
|
+
this.db = firestore19 || admin11.firestore();
|
|
5148
5282
|
this.mailingService = mailingService;
|
|
5149
5283
|
Logger.info("[PractitionerInviteAggregationService] Initialized.");
|
|
5150
5284
|
}
|
|
@@ -5594,8 +5728,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5594
5728
|
*/
|
|
5595
5729
|
async fetchClinicAdminById(adminId) {
|
|
5596
5730
|
try {
|
|
5597
|
-
const
|
|
5598
|
-
return
|
|
5731
|
+
const doc2 = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
|
|
5732
|
+
return doc2.exists ? doc2.data() : null;
|
|
5599
5733
|
} catch (error) {
|
|
5600
5734
|
Logger.error(
|
|
5601
5735
|
`[PractitionerInviteAggService] Error fetching clinic admin ${adminId}:`,
|
|
@@ -5611,8 +5745,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5611
5745
|
*/
|
|
5612
5746
|
async fetchPractitionerById(practitionerId) {
|
|
5613
5747
|
try {
|
|
5614
|
-
const
|
|
5615
|
-
return
|
|
5748
|
+
const doc2 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
|
|
5749
|
+
return doc2.exists ? doc2.data() : null;
|
|
5616
5750
|
} catch (error) {
|
|
5617
5751
|
Logger.error(
|
|
5618
5752
|
`[PractitionerInviteAggService] Error fetching practitioner ${practitionerId}:`,
|
|
@@ -5628,8 +5762,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5628
5762
|
*/
|
|
5629
5763
|
async fetchClinicById(clinicId) {
|
|
5630
5764
|
try {
|
|
5631
|
-
const
|
|
5632
|
-
return
|
|
5765
|
+
const doc2 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
|
|
5766
|
+
return doc2.exists ? doc2.data() : null;
|
|
5633
5767
|
} catch (error) {
|
|
5634
5768
|
Logger.error(
|
|
5635
5769
|
`[PractitionerInviteAggService] Error fetching clinic ${clinicId}:`,
|
|
@@ -5650,8 +5784,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5650
5784
|
var _a, _b, _c, _d, _e, _f;
|
|
5651
5785
|
if (!this.mailingService) return;
|
|
5652
5786
|
try {
|
|
5653
|
-
const
|
|
5654
|
-
if (!
|
|
5787
|
+
const admin19 = await this.fetchClinicAdminById(invite.invitedBy);
|
|
5788
|
+
if (!admin19) {
|
|
5655
5789
|
Logger.warn(
|
|
5656
5790
|
`[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
|
|
5657
5791
|
);
|
|
@@ -5689,7 +5823,7 @@ var PractitionerInviteAggregationService = class {
|
|
|
5689
5823
|
);
|
|
5690
5824
|
return;
|
|
5691
5825
|
}
|
|
5692
|
-
const adminName = `${
|
|
5826
|
+
const adminName = `${admin19.contactInfo.firstName} ${admin19.contactInfo.lastName}`;
|
|
5693
5827
|
const notificationData = {
|
|
5694
5828
|
invite,
|
|
5695
5829
|
practitioner: {
|
|
@@ -5705,7 +5839,7 @@ var PractitionerInviteAggregationService = class {
|
|
|
5705
5839
|
clinic: {
|
|
5706
5840
|
name: clinic.name,
|
|
5707
5841
|
adminName,
|
|
5708
|
-
adminEmail:
|
|
5842
|
+
adminEmail: admin19.contactInfo.email
|
|
5709
5843
|
// Use the specific admin's email
|
|
5710
5844
|
},
|
|
5711
5845
|
context: {
|
|
@@ -5741,8 +5875,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5741
5875
|
var _a, _b, _c, _d, _e, _f;
|
|
5742
5876
|
if (!this.mailingService) return;
|
|
5743
5877
|
try {
|
|
5744
|
-
const
|
|
5745
|
-
if (!
|
|
5878
|
+
const admin19 = await this.fetchClinicAdminById(invite.invitedBy);
|
|
5879
|
+
if (!admin19) {
|
|
5746
5880
|
Logger.warn(
|
|
5747
5881
|
`[PractitionerInviteAggService] Admin ${invite.invitedBy} not found, using clinic contact email as fallback`
|
|
5748
5882
|
);
|
|
@@ -5780,7 +5914,7 @@ var PractitionerInviteAggregationService = class {
|
|
|
5780
5914
|
);
|
|
5781
5915
|
return;
|
|
5782
5916
|
}
|
|
5783
|
-
const adminName = `${
|
|
5917
|
+
const adminName = `${admin19.contactInfo.firstName} ${admin19.contactInfo.lastName}`;
|
|
5784
5918
|
const notificationData = {
|
|
5785
5919
|
invite,
|
|
5786
5920
|
practitioner: {
|
|
@@ -5794,7 +5928,7 @@ var PractitionerInviteAggregationService = class {
|
|
|
5794
5928
|
clinic: {
|
|
5795
5929
|
name: clinic.name,
|
|
5796
5930
|
adminName,
|
|
5797
|
-
adminEmail:
|
|
5931
|
+
adminEmail: admin19.contactInfo.email
|
|
5798
5932
|
// Use the specific admin's email
|
|
5799
5933
|
},
|
|
5800
5934
|
context: {
|
|
@@ -5826,8 +5960,8 @@ var PractitionerInviteAggregationService = class {
|
|
|
5826
5960
|
var admin12 = __toESM(require("firebase-admin"));
|
|
5827
5961
|
var CALENDAR_SUBCOLLECTION_ID4 = "calendar";
|
|
5828
5962
|
var ProcedureAggregationService = class {
|
|
5829
|
-
constructor(
|
|
5830
|
-
this.db =
|
|
5963
|
+
constructor(firestore19) {
|
|
5964
|
+
this.db = firestore19 || admin12.firestore();
|
|
5831
5965
|
}
|
|
5832
5966
|
/**
|
|
5833
5967
|
* Adds procedure information to a practitioner when a new procedure is created
|
|
@@ -6064,11 +6198,11 @@ var ProcedureAggregationService = class {
|
|
|
6064
6198
|
return;
|
|
6065
6199
|
}
|
|
6066
6200
|
const batch = this.db.batch();
|
|
6067
|
-
snapshot.docs.forEach((
|
|
6201
|
+
snapshot.docs.forEach((doc2) => {
|
|
6068
6202
|
console.log(
|
|
6069
|
-
`[ProcedureAggregationService] Updating procedure info for calendar event ${
|
|
6203
|
+
`[ProcedureAggregationService] Updating procedure info for calendar event ${doc2.ref.path}`
|
|
6070
6204
|
);
|
|
6071
|
-
batch.update(
|
|
6205
|
+
batch.update(doc2.ref, {
|
|
6072
6206
|
procedureInfo,
|
|
6073
6207
|
updatedAt: admin12.firestore.FieldValue.serverTimestamp()
|
|
6074
6208
|
});
|
|
@@ -6111,11 +6245,11 @@ var ProcedureAggregationService = class {
|
|
|
6111
6245
|
return;
|
|
6112
6246
|
}
|
|
6113
6247
|
const batch = this.db.batch();
|
|
6114
|
-
snapshot.docs.forEach((
|
|
6248
|
+
snapshot.docs.forEach((doc2) => {
|
|
6115
6249
|
console.log(
|
|
6116
|
-
`[ProcedureAggregationService] Canceling calendar event ${
|
|
6250
|
+
`[ProcedureAggregationService] Canceling calendar event ${doc2.ref.path}`
|
|
6117
6251
|
);
|
|
6118
|
-
batch.update(
|
|
6252
|
+
batch.update(doc2.ref, {
|
|
6119
6253
|
status: "CANCELED",
|
|
6120
6254
|
cancelReason: "Procedure deleted or inactivated",
|
|
6121
6255
|
updatedAt: admin12.firestore.FieldValue.serverTimestamp()
|
|
@@ -6345,8 +6479,8 @@ var ReviewsAggregationService = class {
|
|
|
6345
6479
|
* Constructor for ReviewsAggregationService.
|
|
6346
6480
|
* @param firestore Optional Firestore instance. If not provided, it uses the default admin SDK instance.
|
|
6347
6481
|
*/
|
|
6348
|
-
constructor(
|
|
6349
|
-
this.db =
|
|
6482
|
+
constructor(firestore19) {
|
|
6483
|
+
this.db = firestore19 || admin13.firestore();
|
|
6350
6484
|
}
|
|
6351
6485
|
/**
|
|
6352
6486
|
* Process a newly created review and update all related entities
|
|
@@ -6496,7 +6630,7 @@ var ReviewsAggregationService = class {
|
|
|
6496
6630
|
);
|
|
6497
6631
|
return updatedReviewInfo2;
|
|
6498
6632
|
}
|
|
6499
|
-
const reviews = reviewsQuery.docs.map((
|
|
6633
|
+
const reviews = reviewsQuery.docs.map((doc2) => doc2.data());
|
|
6500
6634
|
const clinicReviews = reviews.map((review) => review.clinicReview).filter((review) => review !== void 0);
|
|
6501
6635
|
let totalRating = 0;
|
|
6502
6636
|
let totalCleanliness = 0;
|
|
@@ -6586,7 +6720,7 @@ var ReviewsAggregationService = class {
|
|
|
6586
6720
|
);
|
|
6587
6721
|
return updatedReviewInfo2;
|
|
6588
6722
|
}
|
|
6589
|
-
const reviews = reviewsQuery.docs.map((
|
|
6723
|
+
const reviews = reviewsQuery.docs.map((doc2) => doc2.data());
|
|
6590
6724
|
const practitionerReviews = reviews.map((review) => review.practitionerReview).filter((review) => review !== void 0);
|
|
6591
6725
|
let totalRating = 0;
|
|
6592
6726
|
let totalKnowledgeAndExpertise = 0;
|
|
@@ -6659,7 +6793,7 @@ var ReviewsAggregationService = class {
|
|
|
6659
6793
|
recommendationPercentage: 0
|
|
6660
6794
|
};
|
|
6661
6795
|
const allReviewsQuery = await this.db.collection(REVIEWS_COLLECTION).get();
|
|
6662
|
-
const reviews = allReviewsQuery.docs.map((
|
|
6796
|
+
const reviews = allReviewsQuery.docs.map((doc2) => doc2.data());
|
|
6663
6797
|
const procedureReviews = [];
|
|
6664
6798
|
reviews.forEach((review) => {
|
|
6665
6799
|
if (review.procedureReview && review.procedureReview.procedureId === procedureId) {
|
|
@@ -6825,8 +6959,2042 @@ var ReviewsAggregationService = class {
|
|
|
6825
6959
|
}
|
|
6826
6960
|
};
|
|
6827
6961
|
|
|
6828
|
-
// src/admin/
|
|
6962
|
+
// src/admin/analytics/analytics.admin.service.ts
|
|
6963
|
+
var admin14 = __toESM(require("firebase-admin"));
|
|
6964
|
+
|
|
6965
|
+
// src/services/analytics/analytics.service.ts
|
|
6966
|
+
var import_firestore3 = require("firebase/firestore");
|
|
6967
|
+
|
|
6968
|
+
// src/services/base.service.ts
|
|
6969
|
+
var import_storage = require("firebase/storage");
|
|
6970
|
+
var BaseService = class {
|
|
6971
|
+
constructor(db, auth, app, storage) {
|
|
6972
|
+
this.db = db;
|
|
6973
|
+
this.auth = auth;
|
|
6974
|
+
this.app = app;
|
|
6975
|
+
if (app) {
|
|
6976
|
+
this.storage = storage || (0, import_storage.getStorage)(app);
|
|
6977
|
+
}
|
|
6978
|
+
}
|
|
6979
|
+
/**
|
|
6980
|
+
* Generiše jedinstveni ID za dokumente
|
|
6981
|
+
* Format: xxxxxxxxxxxx-timestamp
|
|
6982
|
+
* Gde je x random karakter (broj ili slovo)
|
|
6983
|
+
*/
|
|
6984
|
+
generateId() {
|
|
6985
|
+
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
|
|
6986
|
+
const timestamp = Date.now().toString(36);
|
|
6987
|
+
const randomPart = Array.from(
|
|
6988
|
+
{ length: 12 },
|
|
6989
|
+
() => chars.charAt(Math.floor(Math.random() * chars.length))
|
|
6990
|
+
).join("");
|
|
6991
|
+
return `${randomPart}-${timestamp}`;
|
|
6992
|
+
}
|
|
6993
|
+
};
|
|
6994
|
+
|
|
6995
|
+
// src/services/analytics/utils/cost-calculation.utils.ts
|
|
6996
|
+
function calculateAppointmentCost(appointment) {
|
|
6997
|
+
const metadata = appointment.metadata;
|
|
6998
|
+
const currency = appointment.currency || "CHF";
|
|
6999
|
+
if (metadata == null ? void 0 : metadata.finalbilling) {
|
|
7000
|
+
const finalbilling = metadata.finalbilling;
|
|
7001
|
+
return {
|
|
7002
|
+
cost: finalbilling.finalPrice,
|
|
7003
|
+
currency: finalbilling.currency || currency,
|
|
7004
|
+
source: "finalbilling",
|
|
7005
|
+
subtotal: finalbilling.subtotalAll,
|
|
7006
|
+
tax: finalbilling.taxPrice
|
|
7007
|
+
};
|
|
7008
|
+
}
|
|
7009
|
+
if (metadata == null ? void 0 : metadata.zonesData) {
|
|
7010
|
+
const zonesData = metadata.zonesData;
|
|
7011
|
+
let subtotal = 0;
|
|
7012
|
+
let foundCurrency = currency;
|
|
7013
|
+
Object.values(zonesData).forEach((items) => {
|
|
7014
|
+
items.forEach((item) => {
|
|
7015
|
+
if (item.type === "item" && item.subtotal) {
|
|
7016
|
+
subtotal += item.subtotal;
|
|
7017
|
+
if (item.currency && !foundCurrency) {
|
|
7018
|
+
foundCurrency = item.currency;
|
|
7019
|
+
}
|
|
7020
|
+
}
|
|
7021
|
+
});
|
|
7022
|
+
});
|
|
7023
|
+
if (subtotal > 0) {
|
|
7024
|
+
return {
|
|
7025
|
+
cost: subtotal,
|
|
7026
|
+
// Note: This doesn't include tax, but zonesData might not have tax info
|
|
7027
|
+
currency: foundCurrency,
|
|
7028
|
+
source: "zonesData",
|
|
7029
|
+
subtotal
|
|
7030
|
+
};
|
|
7031
|
+
}
|
|
7032
|
+
}
|
|
7033
|
+
return {
|
|
7034
|
+
cost: appointment.cost || 0,
|
|
7035
|
+
currency,
|
|
7036
|
+
source: "baseCost"
|
|
7037
|
+
};
|
|
7038
|
+
}
|
|
7039
|
+
function calculateTotalRevenue(appointments) {
|
|
7040
|
+
if (appointments.length === 0) {
|
|
7041
|
+
return { totalRevenue: 0, currency: "CHF" };
|
|
7042
|
+
}
|
|
7043
|
+
let totalRevenue = 0;
|
|
7044
|
+
const currencies = /* @__PURE__ */ new Set();
|
|
7045
|
+
appointments.forEach((appointment) => {
|
|
7046
|
+
const costData = calculateAppointmentCost(appointment);
|
|
7047
|
+
totalRevenue += costData.cost;
|
|
7048
|
+
currencies.add(costData.currency);
|
|
7049
|
+
});
|
|
7050
|
+
const currency = currencies.size > 0 ? Array.from(currencies)[0] : "CHF";
|
|
7051
|
+
return { totalRevenue, currency };
|
|
7052
|
+
}
|
|
7053
|
+
function extractProductUsage(appointment) {
|
|
7054
|
+
const products = [];
|
|
7055
|
+
const metadata = appointment.metadata;
|
|
7056
|
+
if (!(metadata == null ? void 0 : metadata.zonesData)) {
|
|
7057
|
+
return products;
|
|
7058
|
+
}
|
|
7059
|
+
const zonesData = metadata.zonesData;
|
|
7060
|
+
const currency = appointment.currency || "CHF";
|
|
7061
|
+
Object.values(zonesData).forEach((items) => {
|
|
7062
|
+
items.forEach((item) => {
|
|
7063
|
+
if (item.type === "item" && item.productId) {
|
|
7064
|
+
const price = item.priceOverrideAmount || item.price || 0;
|
|
7065
|
+
const quantity = item.quantity || 1;
|
|
7066
|
+
const subtotal = item.subtotal || price * quantity;
|
|
7067
|
+
products.push({
|
|
7068
|
+
productId: item.productId,
|
|
7069
|
+
productName: item.productName || "Unknown Product",
|
|
7070
|
+
brandId: item.productBrandId || "",
|
|
7071
|
+
brandName: item.productBrandName || "",
|
|
7072
|
+
quantity,
|
|
7073
|
+
price,
|
|
7074
|
+
subtotal,
|
|
7075
|
+
currency: item.currency || currency
|
|
7076
|
+
});
|
|
7077
|
+
}
|
|
7078
|
+
});
|
|
7079
|
+
});
|
|
7080
|
+
return products;
|
|
7081
|
+
}
|
|
7082
|
+
|
|
7083
|
+
// src/services/analytics/utils/time-calculation.utils.ts
|
|
7084
|
+
function calculateTimeEfficiency(appointment) {
|
|
7085
|
+
const startTime = appointment.appointmentStartTime;
|
|
7086
|
+
const endTime = appointment.appointmentEndTime;
|
|
7087
|
+
if (!startTime || !endTime) {
|
|
7088
|
+
return null;
|
|
7089
|
+
}
|
|
7090
|
+
const bookedDurationMs = endTime.toMillis() - startTime.toMillis();
|
|
7091
|
+
const bookedDuration = Math.round(bookedDurationMs / (1e3 * 60));
|
|
7092
|
+
const actualDuration = appointment.actualDurationMinutes || bookedDuration;
|
|
7093
|
+
const efficiency = bookedDuration > 0 ? actualDuration / bookedDuration * 100 : 100;
|
|
7094
|
+
const overrun = actualDuration > bookedDuration ? actualDuration - bookedDuration : 0;
|
|
7095
|
+
const underutilization = bookedDuration > actualDuration ? bookedDuration - actualDuration : 0;
|
|
7096
|
+
return {
|
|
7097
|
+
bookedDuration,
|
|
7098
|
+
actualDuration,
|
|
7099
|
+
efficiency,
|
|
7100
|
+
overrun,
|
|
7101
|
+
underutilization
|
|
7102
|
+
};
|
|
7103
|
+
}
|
|
7104
|
+
function calculateAverageTimeMetrics(appointments) {
|
|
7105
|
+
if (appointments.length === 0) {
|
|
7106
|
+
return {
|
|
7107
|
+
averageBookedDuration: 0,
|
|
7108
|
+
averageActualDuration: 0,
|
|
7109
|
+
averageEfficiency: 0,
|
|
7110
|
+
totalOverrun: 0,
|
|
7111
|
+
totalUnderutilization: 0,
|
|
7112
|
+
averageOverrun: 0,
|
|
7113
|
+
averageUnderutilization: 0,
|
|
7114
|
+
appointmentsWithActualTime: 0
|
|
7115
|
+
};
|
|
7116
|
+
}
|
|
7117
|
+
let totalBookedDuration = 0;
|
|
7118
|
+
let totalActualDuration = 0;
|
|
7119
|
+
let totalOverrun = 0;
|
|
7120
|
+
let totalUnderutilization = 0;
|
|
7121
|
+
let appointmentsWithActualTime = 0;
|
|
7122
|
+
appointments.forEach((appointment) => {
|
|
7123
|
+
const timeData = calculateTimeEfficiency(appointment);
|
|
7124
|
+
if (timeData) {
|
|
7125
|
+
totalBookedDuration += timeData.bookedDuration;
|
|
7126
|
+
totalActualDuration += timeData.actualDuration;
|
|
7127
|
+
totalOverrun += timeData.overrun;
|
|
7128
|
+
totalUnderutilization += timeData.underutilization;
|
|
7129
|
+
if (appointment.actualDurationMinutes !== void 0) {
|
|
7130
|
+
appointmentsWithActualTime++;
|
|
7131
|
+
}
|
|
7132
|
+
}
|
|
7133
|
+
});
|
|
7134
|
+
const count = appointments.length;
|
|
7135
|
+
const averageBookedDuration = count > 0 ? totalBookedDuration / count : 0;
|
|
7136
|
+
const averageActualDuration = count > 0 ? totalActualDuration / count : 0;
|
|
7137
|
+
const averageEfficiency = averageBookedDuration > 0 ? averageActualDuration / averageBookedDuration * 100 : 0;
|
|
7138
|
+
const averageOverrun = count > 0 ? totalOverrun / count : 0;
|
|
7139
|
+
const averageUnderutilization = count > 0 ? totalUnderutilization / count : 0;
|
|
7140
|
+
return {
|
|
7141
|
+
averageBookedDuration: Math.round(averageBookedDuration),
|
|
7142
|
+
averageActualDuration: Math.round(averageActualDuration),
|
|
7143
|
+
averageEfficiency: Math.round(averageEfficiency * 100) / 100,
|
|
7144
|
+
totalOverrun,
|
|
7145
|
+
totalUnderutilization,
|
|
7146
|
+
averageOverrun: Math.round(averageOverrun),
|
|
7147
|
+
averageUnderutilization: Math.round(averageUnderutilization),
|
|
7148
|
+
appointmentsWithActualTime
|
|
7149
|
+
};
|
|
7150
|
+
}
|
|
7151
|
+
function calculateEfficiencyDistribution(appointments) {
|
|
7152
|
+
const ranges = [
|
|
7153
|
+
{ label: "0-50%", min: 0, max: 50 },
|
|
7154
|
+
{ label: "50-75%", min: 50, max: 75 },
|
|
7155
|
+
{ label: "75-100%", min: 75, max: 100 },
|
|
7156
|
+
{ label: "100%+", min: 100, max: Infinity }
|
|
7157
|
+
];
|
|
7158
|
+
const distribution = ranges.map((range) => ({
|
|
7159
|
+
range: range.label,
|
|
7160
|
+
count: 0,
|
|
7161
|
+
percentage: 0
|
|
7162
|
+
}));
|
|
7163
|
+
let validCount = 0;
|
|
7164
|
+
appointments.forEach((appointment) => {
|
|
7165
|
+
const timeData = calculateTimeEfficiency(appointment);
|
|
7166
|
+
if (timeData) {
|
|
7167
|
+
validCount++;
|
|
7168
|
+
const efficiency = timeData.efficiency;
|
|
7169
|
+
for (let i = 0; i < ranges.length; i++) {
|
|
7170
|
+
if (efficiency >= ranges[i].min && efficiency < ranges[i].max) {
|
|
7171
|
+
distribution[i].count++;
|
|
7172
|
+
break;
|
|
7173
|
+
}
|
|
7174
|
+
}
|
|
7175
|
+
}
|
|
7176
|
+
});
|
|
7177
|
+
if (validCount > 0) {
|
|
7178
|
+
distribution.forEach((item) => {
|
|
7179
|
+
item.percentage = Math.round(item.count / validCount * 100 * 100) / 100;
|
|
7180
|
+
});
|
|
7181
|
+
}
|
|
7182
|
+
return distribution;
|
|
7183
|
+
}
|
|
7184
|
+
function calculateCancellationLeadTime(appointment) {
|
|
7185
|
+
if (!appointment.cancellationTime || !appointment.appointmentStartTime) {
|
|
7186
|
+
return null;
|
|
7187
|
+
}
|
|
7188
|
+
const cancellationTime = appointment.cancellationTime.toMillis();
|
|
7189
|
+
const appointmentTime = appointment.appointmentStartTime.toMillis();
|
|
7190
|
+
const diffMs = appointmentTime - cancellationTime;
|
|
7191
|
+
return Math.max(0, diffMs / (1e3 * 60 * 60));
|
|
7192
|
+
}
|
|
7193
|
+
|
|
7194
|
+
// src/services/analytics/utils/appointment-filtering.utils.ts
|
|
7195
|
+
function filterAppointments(appointments, filters) {
|
|
7196
|
+
if (!filters) {
|
|
7197
|
+
return appointments;
|
|
7198
|
+
}
|
|
7199
|
+
return appointments.filter((appointment) => {
|
|
7200
|
+
if (filters.clinicBranchId && appointment.clinicBranchId !== filters.clinicBranchId) {
|
|
7201
|
+
return false;
|
|
7202
|
+
}
|
|
7203
|
+
if (filters.practitionerId && appointment.practitionerId !== filters.practitionerId) {
|
|
7204
|
+
return false;
|
|
7205
|
+
}
|
|
7206
|
+
if (filters.procedureId && appointment.procedureId !== filters.procedureId) {
|
|
7207
|
+
return false;
|
|
7208
|
+
}
|
|
7209
|
+
if (filters.patientId && appointment.patientId !== filters.patientId) {
|
|
7210
|
+
return false;
|
|
7211
|
+
}
|
|
7212
|
+
return true;
|
|
7213
|
+
});
|
|
7214
|
+
}
|
|
7215
|
+
function filterByStatus(appointments, statuses) {
|
|
7216
|
+
return appointments.filter((appointment) => statuses.includes(appointment.status));
|
|
7217
|
+
}
|
|
7218
|
+
function getCompletedAppointments(appointments) {
|
|
7219
|
+
return filterByStatus(appointments, ["completed" /* COMPLETED */]);
|
|
7220
|
+
}
|
|
7221
|
+
function getCanceledAppointments(appointments) {
|
|
7222
|
+
return filterByStatus(appointments, [
|
|
7223
|
+
"canceled_patient" /* CANCELED_PATIENT */,
|
|
7224
|
+
"canceled_clinic" /* CANCELED_CLINIC */,
|
|
7225
|
+
"canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */
|
|
7226
|
+
]);
|
|
7227
|
+
}
|
|
7228
|
+
function getNoShowAppointments(appointments) {
|
|
7229
|
+
return filterByStatus(appointments, ["no_show" /* NO_SHOW */]);
|
|
7230
|
+
}
|
|
7231
|
+
function calculatePercentage(part, total) {
|
|
7232
|
+
if (total === 0) {
|
|
7233
|
+
return 0;
|
|
7234
|
+
}
|
|
7235
|
+
return Math.round(part / total * 100 * 100) / 100;
|
|
7236
|
+
}
|
|
7237
|
+
|
|
7238
|
+
// src/services/analytics/utils/stored-analytics.utils.ts
|
|
6829
7239
|
var import_firestore2 = require("firebase/firestore");
|
|
7240
|
+
function isAnalyticsDataFresh(computedAt, maxAgeHours) {
|
|
7241
|
+
const now = import_firestore2.Timestamp.now();
|
|
7242
|
+
const ageMs = now.toMillis() - computedAt.toMillis();
|
|
7243
|
+
const ageHours = ageMs / (1e3 * 60 * 60);
|
|
7244
|
+
return ageHours <= maxAgeHours;
|
|
7245
|
+
}
|
|
7246
|
+
async function readStoredAnalytics(db, clinicBranchId, subcollection, documentId, period) {
|
|
7247
|
+
try {
|
|
7248
|
+
const docRef = (0, import_firestore2.doc)(
|
|
7249
|
+
db,
|
|
7250
|
+
CLINICS_COLLECTION,
|
|
7251
|
+
clinicBranchId,
|
|
7252
|
+
ANALYTICS_COLLECTION,
|
|
7253
|
+
subcollection,
|
|
7254
|
+
period,
|
|
7255
|
+
documentId
|
|
7256
|
+
);
|
|
7257
|
+
const docSnap = await (0, import_firestore2.getDoc)(docRef);
|
|
7258
|
+
if (!docSnap.exists()) {
|
|
7259
|
+
return null;
|
|
7260
|
+
}
|
|
7261
|
+
return docSnap.data();
|
|
7262
|
+
} catch (error) {
|
|
7263
|
+
console.error(`[StoredAnalytics] Error reading ${subcollection}/${period}/${documentId}:`, error);
|
|
7264
|
+
return null;
|
|
7265
|
+
}
|
|
7266
|
+
}
|
|
7267
|
+
async function readStoredPractitionerAnalytics(db, clinicBranchId, practitionerId, options = {}) {
|
|
7268
|
+
const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
|
|
7269
|
+
if (!useCache) {
|
|
7270
|
+
return null;
|
|
7271
|
+
}
|
|
7272
|
+
const stored = await readStoredAnalytics(
|
|
7273
|
+
db,
|
|
7274
|
+
clinicBranchId,
|
|
7275
|
+
PRACTITIONER_ANALYTICS_SUBCOLLECTION,
|
|
7276
|
+
practitionerId,
|
|
7277
|
+
period
|
|
7278
|
+
);
|
|
7279
|
+
if (!stored) {
|
|
7280
|
+
return null;
|
|
7281
|
+
}
|
|
7282
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
7283
|
+
return null;
|
|
7284
|
+
}
|
|
7285
|
+
return stored;
|
|
7286
|
+
}
|
|
7287
|
+
async function readStoredProcedureAnalytics(db, clinicBranchId, procedureId, options = {}) {
|
|
7288
|
+
const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
|
|
7289
|
+
if (!useCache) {
|
|
7290
|
+
return null;
|
|
7291
|
+
}
|
|
7292
|
+
const stored = await readStoredAnalytics(
|
|
7293
|
+
db,
|
|
7294
|
+
clinicBranchId,
|
|
7295
|
+
PROCEDURE_ANALYTICS_SUBCOLLECTION,
|
|
7296
|
+
procedureId,
|
|
7297
|
+
period
|
|
7298
|
+
);
|
|
7299
|
+
if (!stored) {
|
|
7300
|
+
return null;
|
|
7301
|
+
}
|
|
7302
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
7303
|
+
return null;
|
|
7304
|
+
}
|
|
7305
|
+
return stored;
|
|
7306
|
+
}
|
|
7307
|
+
async function readStoredDashboardAnalytics(db, clinicBranchId, options = {}) {
|
|
7308
|
+
const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
|
|
7309
|
+
if (!useCache) {
|
|
7310
|
+
return null;
|
|
7311
|
+
}
|
|
7312
|
+
const stored = await readStoredAnalytics(
|
|
7313
|
+
db,
|
|
7314
|
+
clinicBranchId,
|
|
7315
|
+
DASHBOARD_ANALYTICS_SUBCOLLECTION,
|
|
7316
|
+
"current",
|
|
7317
|
+
period
|
|
7318
|
+
);
|
|
7319
|
+
if (!stored) {
|
|
7320
|
+
return null;
|
|
7321
|
+
}
|
|
7322
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
7323
|
+
return null;
|
|
7324
|
+
}
|
|
7325
|
+
return stored;
|
|
7326
|
+
}
|
|
7327
|
+
async function readStoredTimeEfficiencyMetrics(db, clinicBranchId, options = {}) {
|
|
7328
|
+
const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
|
|
7329
|
+
if (!useCache) {
|
|
7330
|
+
return null;
|
|
7331
|
+
}
|
|
7332
|
+
const stored = await readStoredAnalytics(
|
|
7333
|
+
db,
|
|
7334
|
+
clinicBranchId,
|
|
7335
|
+
TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
|
|
7336
|
+
"current",
|
|
7337
|
+
period
|
|
7338
|
+
);
|
|
7339
|
+
if (!stored) {
|
|
7340
|
+
return null;
|
|
7341
|
+
}
|
|
7342
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
7343
|
+
return null;
|
|
7344
|
+
}
|
|
7345
|
+
return stored;
|
|
7346
|
+
}
|
|
7347
|
+
async function readStoredRevenueMetrics(db, clinicBranchId, options = {}) {
|
|
7348
|
+
const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
|
|
7349
|
+
if (!useCache) {
|
|
7350
|
+
return null;
|
|
7351
|
+
}
|
|
7352
|
+
const stored = await readStoredAnalytics(
|
|
7353
|
+
db,
|
|
7354
|
+
clinicBranchId,
|
|
7355
|
+
REVENUE_ANALYTICS_SUBCOLLECTION,
|
|
7356
|
+
"current",
|
|
7357
|
+
period
|
|
7358
|
+
);
|
|
7359
|
+
if (!stored) {
|
|
7360
|
+
return null;
|
|
7361
|
+
}
|
|
7362
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
7363
|
+
return null;
|
|
7364
|
+
}
|
|
7365
|
+
return stored;
|
|
7366
|
+
}
|
|
7367
|
+
async function readStoredCancellationMetrics(db, clinicBranchId, entityType, options = {}) {
|
|
7368
|
+
const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
|
|
7369
|
+
if (!useCache) {
|
|
7370
|
+
return null;
|
|
7371
|
+
}
|
|
7372
|
+
const stored = await readStoredAnalytics(
|
|
7373
|
+
db,
|
|
7374
|
+
clinicBranchId,
|
|
7375
|
+
CANCELLATION_ANALYTICS_SUBCOLLECTION,
|
|
7376
|
+
entityType,
|
|
7377
|
+
period
|
|
7378
|
+
);
|
|
7379
|
+
if (!stored) {
|
|
7380
|
+
return null;
|
|
7381
|
+
}
|
|
7382
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
7383
|
+
return null;
|
|
7384
|
+
}
|
|
7385
|
+
return stored;
|
|
7386
|
+
}
|
|
7387
|
+
async function readStoredNoShowMetrics(db, clinicBranchId, entityType, options = {}) {
|
|
7388
|
+
const { useCache = true, maxCacheAgeHours = 12, period = "all_time" } = options;
|
|
7389
|
+
if (!useCache) {
|
|
7390
|
+
return null;
|
|
7391
|
+
}
|
|
7392
|
+
const stored = await readStoredAnalytics(
|
|
7393
|
+
db,
|
|
7394
|
+
clinicBranchId,
|
|
7395
|
+
NO_SHOW_ANALYTICS_SUBCOLLECTION,
|
|
7396
|
+
entityType,
|
|
7397
|
+
period
|
|
7398
|
+
);
|
|
7399
|
+
if (!stored) {
|
|
7400
|
+
return null;
|
|
7401
|
+
}
|
|
7402
|
+
if (!isAnalyticsDataFresh(stored.metadata.computedAt, maxCacheAgeHours)) {
|
|
7403
|
+
return null;
|
|
7404
|
+
}
|
|
7405
|
+
return stored;
|
|
7406
|
+
}
|
|
7407
|
+
|
|
7408
|
+
// src/services/analytics/utils/grouping.utils.ts
|
|
7409
|
+
function getTechnologyId(appointment) {
|
|
7410
|
+
var _a;
|
|
7411
|
+
return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
|
|
7412
|
+
}
|
|
7413
|
+
function getTechnologyName(appointment) {
|
|
7414
|
+
var _a, _b;
|
|
7415
|
+
return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyName) || ((_b = appointment.procedureInfo) == null ? void 0 : _b.technologyName) || "Unknown";
|
|
7416
|
+
}
|
|
7417
|
+
function getEntityName(appointment, entityType) {
|
|
7418
|
+
var _a, _b, _c, _d, _e, _f;
|
|
7419
|
+
switch (entityType) {
|
|
7420
|
+
case "clinic":
|
|
7421
|
+
return ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
7422
|
+
case "practitioner":
|
|
7423
|
+
return ((_b = appointment.practitionerInfo) == null ? void 0 : _b.name) || "Unknown";
|
|
7424
|
+
case "patient":
|
|
7425
|
+
return ((_c = appointment.patientInfo) == null ? void 0 : _c.fullName) || "Unknown";
|
|
7426
|
+
case "procedure":
|
|
7427
|
+
return ((_d = appointment.procedureInfo) == null ? void 0 : _d.name) || "Unknown";
|
|
7428
|
+
case "technology":
|
|
7429
|
+
return ((_e = appointment.procedureExtendedInfo) == null ? void 0 : _e.procedureTechnologyName) || ((_f = appointment.procedureInfo) == null ? void 0 : _f.technologyName) || "Unknown";
|
|
7430
|
+
}
|
|
7431
|
+
}
|
|
7432
|
+
function getEntityId(appointment, entityType) {
|
|
7433
|
+
var _a;
|
|
7434
|
+
switch (entityType) {
|
|
7435
|
+
case "clinic":
|
|
7436
|
+
return appointment.clinicBranchId;
|
|
7437
|
+
case "practitioner":
|
|
7438
|
+
return appointment.practitionerId;
|
|
7439
|
+
case "patient":
|
|
7440
|
+
return appointment.patientId;
|
|
7441
|
+
case "procedure":
|
|
7442
|
+
return appointment.procedureId;
|
|
7443
|
+
case "technology":
|
|
7444
|
+
return ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
|
|
7445
|
+
}
|
|
7446
|
+
}
|
|
7447
|
+
function groupAppointmentsByEntity(appointments, entityType) {
|
|
7448
|
+
const entityMap = /* @__PURE__ */ new Map();
|
|
7449
|
+
appointments.forEach((appointment) => {
|
|
7450
|
+
let entityId;
|
|
7451
|
+
let entityName;
|
|
7452
|
+
if (entityType === "technology") {
|
|
7453
|
+
entityId = getTechnologyId(appointment);
|
|
7454
|
+
entityName = getTechnologyName(appointment);
|
|
7455
|
+
} else {
|
|
7456
|
+
entityId = getEntityId(appointment, entityType);
|
|
7457
|
+
entityName = getEntityName(appointment, entityType);
|
|
7458
|
+
}
|
|
7459
|
+
if (!entityMap.has(entityId)) {
|
|
7460
|
+
entityMap.set(entityId, { name: entityName, appointments: [] });
|
|
7461
|
+
}
|
|
7462
|
+
entityMap.get(entityId).appointments.push(appointment);
|
|
7463
|
+
});
|
|
7464
|
+
return entityMap;
|
|
7465
|
+
}
|
|
7466
|
+
function calculateGroupedRevenueMetrics(appointments, entityType) {
|
|
7467
|
+
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7468
|
+
const completed = getCompletedAppointments(appointments);
|
|
7469
|
+
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7470
|
+
const entityAppointments = data.appointments;
|
|
7471
|
+
const entityCompleted = entityAppointments.filter(
|
|
7472
|
+
(a) => completed.some((c) => c.id === a.id)
|
|
7473
|
+
);
|
|
7474
|
+
const { totalRevenue, currency } = calculateTotalRevenue(entityCompleted);
|
|
7475
|
+
let totalTax = 0;
|
|
7476
|
+
let totalSubtotal = 0;
|
|
7477
|
+
let unpaidRevenue = 0;
|
|
7478
|
+
let refundedRevenue = 0;
|
|
7479
|
+
entityCompleted.forEach((appointment) => {
|
|
7480
|
+
const costData = calculateAppointmentCost(appointment);
|
|
7481
|
+
if (costData.source === "finalbilling") {
|
|
7482
|
+
totalTax += costData.tax || 0;
|
|
7483
|
+
totalSubtotal += costData.subtotal || 0;
|
|
7484
|
+
} else {
|
|
7485
|
+
totalSubtotal += costData.cost;
|
|
7486
|
+
}
|
|
7487
|
+
if (appointment.paymentStatus === "unpaid") {
|
|
7488
|
+
unpaidRevenue += costData.cost;
|
|
7489
|
+
} else if (appointment.paymentStatus === "refunded") {
|
|
7490
|
+
refundedRevenue += costData.cost;
|
|
7491
|
+
}
|
|
7492
|
+
});
|
|
7493
|
+
return {
|
|
7494
|
+
entityId,
|
|
7495
|
+
entityName: data.name,
|
|
7496
|
+
entityType,
|
|
7497
|
+
totalRevenue,
|
|
7498
|
+
averageRevenuePerAppointment: entityCompleted.length > 0 ? totalRevenue / entityCompleted.length : 0,
|
|
7499
|
+
totalAppointments: entityAppointments.length,
|
|
7500
|
+
completedAppointments: entityCompleted.length,
|
|
7501
|
+
currency,
|
|
7502
|
+
unpaidRevenue,
|
|
7503
|
+
refundedRevenue,
|
|
7504
|
+
totalTax,
|
|
7505
|
+
totalSubtotal
|
|
7506
|
+
};
|
|
7507
|
+
});
|
|
7508
|
+
}
|
|
7509
|
+
function calculateGroupedProductUsageMetrics(appointments, entityType) {
|
|
7510
|
+
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7511
|
+
const completed = getCompletedAppointments(appointments);
|
|
7512
|
+
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7513
|
+
const entityAppointments = data.appointments;
|
|
7514
|
+
const entityCompleted = entityAppointments.filter(
|
|
7515
|
+
(a) => completed.some((c) => c.id === a.id)
|
|
7516
|
+
);
|
|
7517
|
+
const productMap = /* @__PURE__ */ new Map();
|
|
7518
|
+
entityCompleted.forEach((appointment) => {
|
|
7519
|
+
const products = extractProductUsage(appointment);
|
|
7520
|
+
products.forEach((product) => {
|
|
7521
|
+
if (productMap.has(product.productId)) {
|
|
7522
|
+
const existing = productMap.get(product.productId);
|
|
7523
|
+
existing.quantity += product.quantity;
|
|
7524
|
+
existing.revenue += product.subtotal;
|
|
7525
|
+
existing.usageCount++;
|
|
7526
|
+
} else {
|
|
7527
|
+
productMap.set(product.productId, {
|
|
7528
|
+
name: product.productName,
|
|
7529
|
+
brandName: product.brandName,
|
|
7530
|
+
quantity: product.quantity,
|
|
7531
|
+
revenue: product.subtotal,
|
|
7532
|
+
usageCount: 1
|
|
7533
|
+
});
|
|
7534
|
+
}
|
|
7535
|
+
});
|
|
7536
|
+
});
|
|
7537
|
+
const topProducts = Array.from(productMap.entries()).map(([productId, productData]) => ({
|
|
7538
|
+
productId,
|
|
7539
|
+
productName: productData.name,
|
|
7540
|
+
brandName: productData.brandName,
|
|
7541
|
+
totalQuantity: productData.quantity,
|
|
7542
|
+
totalRevenue: productData.revenue,
|
|
7543
|
+
usageCount: productData.usageCount
|
|
7544
|
+
})).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 10);
|
|
7545
|
+
const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
|
|
7546
|
+
const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
|
|
7547
|
+
return {
|
|
7548
|
+
entityId,
|
|
7549
|
+
entityName: data.name,
|
|
7550
|
+
entityType,
|
|
7551
|
+
totalProductsUsed: productMap.size,
|
|
7552
|
+
uniqueProducts: productMap.size,
|
|
7553
|
+
totalProductRevenue,
|
|
7554
|
+
totalProductQuantity,
|
|
7555
|
+
averageProductsPerAppointment: entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
|
|
7556
|
+
topProducts
|
|
7557
|
+
};
|
|
7558
|
+
});
|
|
7559
|
+
}
|
|
7560
|
+
function calculateGroupedTimeEfficiencyMetrics(appointments, entityType) {
|
|
7561
|
+
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7562
|
+
const completed = getCompletedAppointments(appointments);
|
|
7563
|
+
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7564
|
+
const entityAppointments = data.appointments;
|
|
7565
|
+
const entityCompleted = entityAppointments.filter(
|
|
7566
|
+
(a) => completed.some((c) => c.id === a.id)
|
|
7567
|
+
);
|
|
7568
|
+
const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
|
|
7569
|
+
return {
|
|
7570
|
+
entityId,
|
|
7571
|
+
entityName: data.name,
|
|
7572
|
+
entityType,
|
|
7573
|
+
totalAppointments: entityCompleted.length,
|
|
7574
|
+
appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
|
|
7575
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
7576
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
7577
|
+
averageEfficiency: timeMetrics.averageEfficiency,
|
|
7578
|
+
totalOverrun: timeMetrics.totalOverrun,
|
|
7579
|
+
totalUnderutilization: timeMetrics.totalUnderutilization,
|
|
7580
|
+
averageOverrun: timeMetrics.averageOverrun,
|
|
7581
|
+
averageUnderutilization: timeMetrics.averageUnderutilization
|
|
7582
|
+
};
|
|
7583
|
+
});
|
|
7584
|
+
}
|
|
7585
|
+
function calculateGroupedPatientBehaviorMetrics(appointments, entityType) {
|
|
7586
|
+
const entityMap = groupAppointmentsByEntity(appointments, entityType);
|
|
7587
|
+
const canceled = getCanceledAppointments(appointments);
|
|
7588
|
+
const noShow = getNoShowAppointments(appointments);
|
|
7589
|
+
return Array.from(entityMap.entries()).map(([entityId, data]) => {
|
|
7590
|
+
const entityAppointments = data.appointments;
|
|
7591
|
+
const patientMap = /* @__PURE__ */ new Map();
|
|
7592
|
+
entityAppointments.forEach((appointment) => {
|
|
7593
|
+
var _a;
|
|
7594
|
+
const patientId = appointment.patientId;
|
|
7595
|
+
const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
|
|
7596
|
+
if (!patientMap.has(patientId)) {
|
|
7597
|
+
patientMap.set(patientId, {
|
|
7598
|
+
name: patientName,
|
|
7599
|
+
appointments: [],
|
|
7600
|
+
noShows: [],
|
|
7601
|
+
cancellations: []
|
|
7602
|
+
});
|
|
7603
|
+
}
|
|
7604
|
+
const patientData = patientMap.get(patientId);
|
|
7605
|
+
patientData.appointments.push(appointment);
|
|
7606
|
+
if (noShow.some((ns) => ns.id === appointment.id)) {
|
|
7607
|
+
patientData.noShows.push(appointment);
|
|
7608
|
+
}
|
|
7609
|
+
if (canceled.some((c) => c.id === appointment.id)) {
|
|
7610
|
+
patientData.cancellations.push(appointment);
|
|
7611
|
+
}
|
|
7612
|
+
});
|
|
7613
|
+
const patientMetrics = Array.from(patientMap.entries()).map(([patientId, patientData]) => ({
|
|
7614
|
+
patientId,
|
|
7615
|
+
patientName: patientData.name,
|
|
7616
|
+
noShowCount: patientData.noShows.length,
|
|
7617
|
+
cancellationCount: patientData.cancellations.length,
|
|
7618
|
+
totalAppointments: patientData.appointments.length,
|
|
7619
|
+
noShowRate: calculatePercentage(
|
|
7620
|
+
patientData.noShows.length,
|
|
7621
|
+
patientData.appointments.length
|
|
7622
|
+
),
|
|
7623
|
+
cancellationRate: calculatePercentage(
|
|
7624
|
+
patientData.cancellations.length,
|
|
7625
|
+
patientData.appointments.length
|
|
7626
|
+
)
|
|
7627
|
+
}));
|
|
7628
|
+
const patientsWithNoShows = patientMetrics.filter((p) => p.noShowCount > 0).length;
|
|
7629
|
+
const patientsWithCancellations = patientMetrics.filter((p) => p.cancellationCount > 0).length;
|
|
7630
|
+
const averageNoShowRate = patientMetrics.length > 0 ? patientMetrics.reduce((sum, p) => sum + p.noShowRate, 0) / patientMetrics.length : 0;
|
|
7631
|
+
const averageCancellationRate = patientMetrics.length > 0 ? patientMetrics.reduce((sum, p) => sum + p.cancellationRate, 0) / patientMetrics.length : 0;
|
|
7632
|
+
const topNoShowPatients = patientMetrics.filter((p) => p.noShowCount > 0).sort((a, b) => b.noShowRate - a.noShowRate).slice(0, 10).map((p) => ({
|
|
7633
|
+
patientId: p.patientId,
|
|
7634
|
+
patientName: p.patientName,
|
|
7635
|
+
noShowCount: p.noShowCount,
|
|
7636
|
+
totalAppointments: p.totalAppointments,
|
|
7637
|
+
noShowRate: p.noShowRate
|
|
7638
|
+
}));
|
|
7639
|
+
const topCancellationPatients = patientMetrics.filter((p) => p.cancellationCount > 0).sort((a, b) => b.cancellationRate - a.cancellationRate).slice(0, 10).map((p) => ({
|
|
7640
|
+
patientId: p.patientId,
|
|
7641
|
+
patientName: p.patientName,
|
|
7642
|
+
cancellationCount: p.cancellationCount,
|
|
7643
|
+
totalAppointments: p.totalAppointments,
|
|
7644
|
+
cancellationRate: p.cancellationRate
|
|
7645
|
+
}));
|
|
7646
|
+
const newPatients = patientMetrics.filter((p) => p.totalAppointments === 1).length;
|
|
7647
|
+
const returningPatients = patientMetrics.filter((p) => p.totalAppointments > 1).length;
|
|
7648
|
+
return {
|
|
7649
|
+
entityId,
|
|
7650
|
+
entityName: data.name,
|
|
7651
|
+
entityType,
|
|
7652
|
+
totalPatients: patientMap.size,
|
|
7653
|
+
patientsWithNoShows,
|
|
7654
|
+
patientsWithCancellations,
|
|
7655
|
+
averageNoShowRate: Math.round(averageNoShowRate * 100) / 100,
|
|
7656
|
+
averageCancellationRate: Math.round(averageCancellationRate * 100) / 100,
|
|
7657
|
+
topNoShowPatients,
|
|
7658
|
+
topCancellationPatients
|
|
7659
|
+
};
|
|
7660
|
+
});
|
|
7661
|
+
}
|
|
7662
|
+
|
|
7663
|
+
// src/services/analytics/analytics.service.ts
|
|
7664
|
+
var AnalyticsService = class extends BaseService {
|
|
7665
|
+
/**
|
|
7666
|
+
* Creates a new AnalyticsService instance.
|
|
7667
|
+
*
|
|
7668
|
+
* @param db Firestore instance
|
|
7669
|
+
* @param auth Firebase Auth instance
|
|
7670
|
+
* @param app Firebase App instance
|
|
7671
|
+
* @param appointmentService Appointment service instance for querying appointments
|
|
7672
|
+
*/
|
|
7673
|
+
constructor(db, auth, app, appointmentService) {
|
|
7674
|
+
super(db, auth, app);
|
|
7675
|
+
this.appointmentService = appointmentService;
|
|
7676
|
+
}
|
|
7677
|
+
/**
|
|
7678
|
+
* Fetches appointments with optional filters
|
|
7679
|
+
*
|
|
7680
|
+
* @param filters - Optional filters
|
|
7681
|
+
* @param dateRange - Optional date range
|
|
7682
|
+
* @returns Array of appointments
|
|
7683
|
+
*/
|
|
7684
|
+
async fetchAppointments(filters, dateRange) {
|
|
7685
|
+
try {
|
|
7686
|
+
const constraints = [];
|
|
7687
|
+
if (filters == null ? void 0 : filters.clinicBranchId) {
|
|
7688
|
+
constraints.push((0, import_firestore3.where)("clinicBranchId", "==", filters.clinicBranchId));
|
|
7689
|
+
}
|
|
7690
|
+
if (filters == null ? void 0 : filters.practitionerId) {
|
|
7691
|
+
constraints.push((0, import_firestore3.where)("practitionerId", "==", filters.practitionerId));
|
|
7692
|
+
}
|
|
7693
|
+
if (filters == null ? void 0 : filters.procedureId) {
|
|
7694
|
+
constraints.push((0, import_firestore3.where)("procedureId", "==", filters.procedureId));
|
|
7695
|
+
}
|
|
7696
|
+
if (filters == null ? void 0 : filters.patientId) {
|
|
7697
|
+
constraints.push((0, import_firestore3.where)("patientId", "==", filters.patientId));
|
|
7698
|
+
}
|
|
7699
|
+
if (dateRange) {
|
|
7700
|
+
constraints.push((0, import_firestore3.where)("appointmentStartTime", ">=", import_firestore3.Timestamp.fromDate(dateRange.start)));
|
|
7701
|
+
constraints.push((0, import_firestore3.where)("appointmentStartTime", "<=", import_firestore3.Timestamp.fromDate(dateRange.end)));
|
|
7702
|
+
}
|
|
7703
|
+
const searchParams = {};
|
|
7704
|
+
if (filters == null ? void 0 : filters.clinicBranchId) searchParams.clinicBranchId = filters.clinicBranchId;
|
|
7705
|
+
if (filters == null ? void 0 : filters.practitionerId) searchParams.practitionerId = filters.practitionerId;
|
|
7706
|
+
if (filters == null ? void 0 : filters.procedureId) searchParams.procedureId = filters.procedureId;
|
|
7707
|
+
if (filters == null ? void 0 : filters.patientId) searchParams.patientId = filters.patientId;
|
|
7708
|
+
if (dateRange) {
|
|
7709
|
+
searchParams.startDate = dateRange.start;
|
|
7710
|
+
searchParams.endDate = dateRange.end;
|
|
7711
|
+
}
|
|
7712
|
+
const result = await this.appointmentService.searchAppointments(searchParams);
|
|
7713
|
+
return result.appointments;
|
|
7714
|
+
} catch (error) {
|
|
7715
|
+
console.error("[AnalyticsService] Error fetching appointments:", error);
|
|
7716
|
+
throw error;
|
|
7717
|
+
}
|
|
7718
|
+
}
|
|
7719
|
+
// ==========================================
|
|
7720
|
+
// Practitioner Analytics
|
|
7721
|
+
// ==========================================
|
|
7722
|
+
/**
|
|
7723
|
+
* Get practitioner performance metrics
|
|
7724
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
7725
|
+
*
|
|
7726
|
+
* @param practitionerId - ID of the practitioner
|
|
7727
|
+
* @param dateRange - Optional date range filter
|
|
7728
|
+
* @param options - Options for reading stored analytics
|
|
7729
|
+
* @returns Practitioner analytics object
|
|
7730
|
+
*/
|
|
7731
|
+
async getPractitionerAnalytics(practitionerId, dateRange, options) {
|
|
7732
|
+
var _a;
|
|
7733
|
+
if (dateRange && (options == null ? void 0 : options.useCache) !== false) {
|
|
7734
|
+
const period = this.determinePeriodFromDateRange(dateRange);
|
|
7735
|
+
const clinicBranchId = options == null ? void 0 : options.clinicBranchId;
|
|
7736
|
+
if (clinicBranchId) {
|
|
7737
|
+
const stored = await readStoredPractitionerAnalytics(
|
|
7738
|
+
this.db,
|
|
7739
|
+
clinicBranchId,
|
|
7740
|
+
practitionerId,
|
|
7741
|
+
{ ...options, period }
|
|
7742
|
+
);
|
|
7743
|
+
if (stored) {
|
|
7744
|
+
const { metadata, ...analytics } = stored;
|
|
7745
|
+
return analytics;
|
|
7746
|
+
}
|
|
7747
|
+
}
|
|
7748
|
+
}
|
|
7749
|
+
const appointments = await this.fetchAppointments({ practitionerId }, dateRange);
|
|
7750
|
+
const completed = getCompletedAppointments(appointments);
|
|
7751
|
+
const canceled = getCanceledAppointments(appointments);
|
|
7752
|
+
const noShow = getNoShowAppointments(appointments);
|
|
7753
|
+
const pending = filterAppointments(appointments, { practitionerId }).filter(
|
|
7754
|
+
(a) => a.status === "pending" /* PENDING */
|
|
7755
|
+
);
|
|
7756
|
+
const confirmed = filterAppointments(appointments, { practitionerId }).filter(
|
|
7757
|
+
(a) => a.status === "confirmed" /* CONFIRMED */
|
|
7758
|
+
);
|
|
7759
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
7760
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
7761
|
+
const uniquePatients = new Set(appointments.map((a) => a.patientId));
|
|
7762
|
+
const returningPatients = new Set(
|
|
7763
|
+
appointments.filter((a) => {
|
|
7764
|
+
const patientAppointments = appointments.filter((ap) => ap.patientId === a.patientId);
|
|
7765
|
+
return patientAppointments.length > 1;
|
|
7766
|
+
}).map((a) => a.patientId)
|
|
7767
|
+
);
|
|
7768
|
+
const procedureMap = /* @__PURE__ */ new Map();
|
|
7769
|
+
completed.forEach((appointment) => {
|
|
7770
|
+
var _a2;
|
|
7771
|
+
const procId = appointment.procedureId;
|
|
7772
|
+
const procName = ((_a2 = appointment.procedureInfo) == null ? void 0 : _a2.name) || "Unknown";
|
|
7773
|
+
const cost = calculateAppointmentCost(appointment).cost;
|
|
7774
|
+
if (procedureMap.has(procId)) {
|
|
7775
|
+
const existing = procedureMap.get(procId);
|
|
7776
|
+
existing.count++;
|
|
7777
|
+
existing.revenue += cost;
|
|
7778
|
+
} else {
|
|
7779
|
+
procedureMap.set(procId, { name: procName, count: 1, revenue: cost });
|
|
7780
|
+
}
|
|
7781
|
+
});
|
|
7782
|
+
const topProcedures = Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
|
|
7783
|
+
procedureId,
|
|
7784
|
+
procedureName: data.name,
|
|
7785
|
+
count: data.count,
|
|
7786
|
+
revenue: data.revenue
|
|
7787
|
+
})).sort((a, b) => b.count - a.count).slice(0, 10);
|
|
7788
|
+
const practitionerName = appointments.length > 0 ? ((_a = appointments[0].practitionerInfo) == null ? void 0 : _a.name) || "Unknown" : "Unknown";
|
|
7789
|
+
return {
|
|
7790
|
+
total: appointments.length,
|
|
7791
|
+
dateRange,
|
|
7792
|
+
practitionerId,
|
|
7793
|
+
practitionerName,
|
|
7794
|
+
totalAppointments: appointments.length,
|
|
7795
|
+
completedAppointments: completed.length,
|
|
7796
|
+
canceledAppointments: canceled.length,
|
|
7797
|
+
noShowAppointments: noShow.length,
|
|
7798
|
+
pendingAppointments: pending.length,
|
|
7799
|
+
confirmedAppointments: confirmed.length,
|
|
7800
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
7801
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
7802
|
+
averageBookedTime: timeMetrics.averageBookedDuration,
|
|
7803
|
+
averageActualTime: timeMetrics.averageActualDuration,
|
|
7804
|
+
timeEfficiency: timeMetrics.averageEfficiency,
|
|
7805
|
+
totalRevenue,
|
|
7806
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
7807
|
+
currency,
|
|
7808
|
+
topProcedures,
|
|
7809
|
+
patientRetentionRate: calculatePercentage(returningPatients.size, uniquePatients.size),
|
|
7810
|
+
uniquePatients: uniquePatients.size
|
|
7811
|
+
};
|
|
7812
|
+
}
|
|
7813
|
+
// ==========================================
|
|
7814
|
+
// Procedure Analytics
|
|
7815
|
+
// ==========================================
|
|
7816
|
+
/**
|
|
7817
|
+
* Get procedure performance metrics
|
|
7818
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
7819
|
+
*
|
|
7820
|
+
* @param procedureId - ID of the procedure (optional, if not provided returns all)
|
|
7821
|
+
* @param dateRange - Optional date range filter
|
|
7822
|
+
* @param options - Options for reading stored analytics
|
|
7823
|
+
* @returns Procedure analytics object or array
|
|
7824
|
+
*/
|
|
7825
|
+
async getProcedureAnalytics(procedureId, dateRange, options) {
|
|
7826
|
+
if (procedureId && dateRange && (options == null ? void 0 : options.useCache) !== false) {
|
|
7827
|
+
const period = this.determinePeriodFromDateRange(dateRange);
|
|
7828
|
+
const clinicBranchId = options == null ? void 0 : options.clinicBranchId;
|
|
7829
|
+
if (clinicBranchId) {
|
|
7830
|
+
const stored = await readStoredProcedureAnalytics(
|
|
7831
|
+
this.db,
|
|
7832
|
+
clinicBranchId,
|
|
7833
|
+
procedureId,
|
|
7834
|
+
{ ...options, period }
|
|
7835
|
+
);
|
|
7836
|
+
if (stored) {
|
|
7837
|
+
const { metadata, ...analytics } = stored;
|
|
7838
|
+
return analytics;
|
|
7839
|
+
}
|
|
7840
|
+
}
|
|
7841
|
+
}
|
|
7842
|
+
const appointments = await this.fetchAppointments(procedureId ? { procedureId } : void 0, dateRange);
|
|
7843
|
+
if (procedureId) {
|
|
7844
|
+
return this.calculateProcedureAnalytics(appointments, procedureId);
|
|
7845
|
+
}
|
|
7846
|
+
const procedureMap = /* @__PURE__ */ new Map();
|
|
7847
|
+
appointments.forEach((appointment) => {
|
|
7848
|
+
const procId = appointment.procedureId;
|
|
7849
|
+
if (!procedureMap.has(procId)) {
|
|
7850
|
+
procedureMap.set(procId, []);
|
|
7851
|
+
}
|
|
7852
|
+
procedureMap.get(procId).push(appointment);
|
|
7853
|
+
});
|
|
7854
|
+
return Array.from(procedureMap.entries()).map(
|
|
7855
|
+
([procId, procAppointments]) => this.calculateProcedureAnalytics(procAppointments, procId)
|
|
7856
|
+
);
|
|
7857
|
+
}
|
|
7858
|
+
/**
|
|
7859
|
+
* Calculate analytics for a specific procedure
|
|
7860
|
+
*
|
|
7861
|
+
* @param appointments - Appointments for the procedure
|
|
7862
|
+
* @param procedureId - Procedure ID
|
|
7863
|
+
* @returns Procedure analytics
|
|
7864
|
+
*/
|
|
7865
|
+
calculateProcedureAnalytics(appointments, procedureId) {
|
|
7866
|
+
const completed = getCompletedAppointments(appointments);
|
|
7867
|
+
const canceled = getCanceledAppointments(appointments);
|
|
7868
|
+
const noShow = getNoShowAppointments(appointments);
|
|
7869
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
7870
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
7871
|
+
const firstAppointment = appointments[0];
|
|
7872
|
+
const procedureInfo = (firstAppointment == null ? void 0 : firstAppointment.procedureExtendedInfo) || (firstAppointment == null ? void 0 : firstAppointment.procedureInfo);
|
|
7873
|
+
const productMap = /* @__PURE__ */ new Map();
|
|
7874
|
+
completed.forEach((appointment) => {
|
|
7875
|
+
const products = extractProductUsage(appointment);
|
|
7876
|
+
products.forEach((product) => {
|
|
7877
|
+
if (productMap.has(product.productId)) {
|
|
7878
|
+
const existing = productMap.get(product.productId);
|
|
7879
|
+
existing.quantity += product.quantity;
|
|
7880
|
+
existing.revenue += product.subtotal;
|
|
7881
|
+
existing.usageCount++;
|
|
7882
|
+
} else {
|
|
7883
|
+
productMap.set(product.productId, {
|
|
7884
|
+
name: product.productName,
|
|
7885
|
+
brandName: product.brandName,
|
|
7886
|
+
quantity: product.quantity,
|
|
7887
|
+
revenue: product.subtotal,
|
|
7888
|
+
usageCount: 1
|
|
7889
|
+
});
|
|
7890
|
+
}
|
|
7891
|
+
});
|
|
7892
|
+
});
|
|
7893
|
+
const productUsage = Array.from(productMap.entries()).map(([productId, data]) => ({
|
|
7894
|
+
productId,
|
|
7895
|
+
productName: data.name,
|
|
7896
|
+
brandName: data.brandName,
|
|
7897
|
+
totalQuantity: data.quantity,
|
|
7898
|
+
totalRevenue: data.revenue,
|
|
7899
|
+
usageCount: data.usageCount
|
|
7900
|
+
}));
|
|
7901
|
+
return {
|
|
7902
|
+
total: appointments.length,
|
|
7903
|
+
procedureId,
|
|
7904
|
+
procedureName: (procedureInfo == null ? void 0 : procedureInfo.name) || "Unknown",
|
|
7905
|
+
procedureFamily: (procedureInfo == null ? void 0 : procedureInfo.procedureFamily) || "",
|
|
7906
|
+
categoryName: (procedureInfo == null ? void 0 : procedureInfo.procedureCategoryName) || "",
|
|
7907
|
+
subcategoryName: (procedureInfo == null ? void 0 : procedureInfo.procedureSubCategoryName) || "",
|
|
7908
|
+
technologyName: (procedureInfo == null ? void 0 : procedureInfo.procedureTechnologyName) || "",
|
|
7909
|
+
totalAppointments: appointments.length,
|
|
7910
|
+
completedAppointments: completed.length,
|
|
7911
|
+
canceledAppointments: canceled.length,
|
|
7912
|
+
noShowAppointments: noShow.length,
|
|
7913
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
7914
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
7915
|
+
averageCost: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
7916
|
+
totalRevenue,
|
|
7917
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
7918
|
+
currency,
|
|
7919
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
7920
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
7921
|
+
productUsage
|
|
7922
|
+
};
|
|
7923
|
+
}
|
|
7924
|
+
/**
|
|
7925
|
+
* Get procedure popularity metrics
|
|
7926
|
+
*
|
|
7927
|
+
* @param dateRange - Optional date range filter
|
|
7928
|
+
* @param limit - Number of top procedures to return
|
|
7929
|
+
* @returns Array of procedure popularity metrics
|
|
7930
|
+
*/
|
|
7931
|
+
async getProcedurePopularity(dateRange, limit = 10) {
|
|
7932
|
+
const appointments = await this.fetchAppointments(void 0, dateRange);
|
|
7933
|
+
const completed = getCompletedAppointments(appointments);
|
|
7934
|
+
const procedureMap = /* @__PURE__ */ new Map();
|
|
7935
|
+
completed.forEach((appointment) => {
|
|
7936
|
+
const procId = appointment.procedureId;
|
|
7937
|
+
const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
|
|
7938
|
+
if (procedureMap.has(procId)) {
|
|
7939
|
+
procedureMap.get(procId).count++;
|
|
7940
|
+
} else {
|
|
7941
|
+
procedureMap.set(procId, {
|
|
7942
|
+
name: (procInfo == null ? void 0 : procInfo.name) || "Unknown",
|
|
7943
|
+
category: (procInfo == null ? void 0 : procInfo.procedureCategoryName) || "",
|
|
7944
|
+
subcategory: (procInfo == null ? void 0 : procInfo.procedureSubCategoryName) || "",
|
|
7945
|
+
technology: (procInfo == null ? void 0 : procInfo.procedureTechnologyName) || "",
|
|
7946
|
+
count: 1
|
|
7947
|
+
});
|
|
7948
|
+
}
|
|
7949
|
+
});
|
|
7950
|
+
return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
|
|
7951
|
+
procedureId,
|
|
7952
|
+
procedureName: data.name,
|
|
7953
|
+
categoryName: data.category,
|
|
7954
|
+
subcategoryName: data.subcategory,
|
|
7955
|
+
technologyName: data.technology,
|
|
7956
|
+
appointmentCount: data.count,
|
|
7957
|
+
completedCount: data.count,
|
|
7958
|
+
rank: 0
|
|
7959
|
+
// Will be set after sorting
|
|
7960
|
+
})).sort((a, b) => b.appointmentCount - a.appointmentCount).slice(0, limit).map((item, index) => ({ ...item, rank: index + 1 }));
|
|
7961
|
+
}
|
|
7962
|
+
/**
|
|
7963
|
+
* Get procedure profitability metrics
|
|
7964
|
+
*
|
|
7965
|
+
* @param dateRange - Optional date range filter
|
|
7966
|
+
* @param limit - Number of top procedures to return
|
|
7967
|
+
* @returns Array of procedure profitability metrics
|
|
7968
|
+
*/
|
|
7969
|
+
async getProcedureProfitability(dateRange, limit = 10) {
|
|
7970
|
+
const appointments = await this.fetchAppointments(void 0, dateRange);
|
|
7971
|
+
const completed = getCompletedAppointments(appointments);
|
|
7972
|
+
const procedureMap = /* @__PURE__ */ new Map();
|
|
7973
|
+
completed.forEach((appointment) => {
|
|
7974
|
+
const procId = appointment.procedureId;
|
|
7975
|
+
const procInfo = appointment.procedureExtendedInfo || appointment.procedureInfo;
|
|
7976
|
+
const cost = calculateAppointmentCost(appointment).cost;
|
|
7977
|
+
if (procedureMap.has(procId)) {
|
|
7978
|
+
const existing = procedureMap.get(procId);
|
|
7979
|
+
existing.revenue += cost;
|
|
7980
|
+
existing.count++;
|
|
7981
|
+
} else {
|
|
7982
|
+
procedureMap.set(procId, {
|
|
7983
|
+
name: (procInfo == null ? void 0 : procInfo.name) || "Unknown",
|
|
7984
|
+
category: (procInfo == null ? void 0 : procInfo.procedureCategoryName) || "",
|
|
7985
|
+
subcategory: (procInfo == null ? void 0 : procInfo.procedureSubCategoryName) || "",
|
|
7986
|
+
technology: (procInfo == null ? void 0 : procInfo.procedureTechnologyName) || "",
|
|
7987
|
+
revenue: cost,
|
|
7988
|
+
count: 1
|
|
7989
|
+
});
|
|
7990
|
+
}
|
|
7991
|
+
});
|
|
7992
|
+
return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
|
|
7993
|
+
procedureId,
|
|
7994
|
+
procedureName: data.name,
|
|
7995
|
+
categoryName: data.category,
|
|
7996
|
+
subcategoryName: data.subcategory,
|
|
7997
|
+
technologyName: data.technology,
|
|
7998
|
+
totalRevenue: data.revenue,
|
|
7999
|
+
averageRevenue: data.count > 0 ? data.revenue / data.count : 0,
|
|
8000
|
+
appointmentCount: data.count,
|
|
8001
|
+
rank: 0
|
|
8002
|
+
// Will be set after sorting
|
|
8003
|
+
})).sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, limit).map((item, index) => ({ ...item, rank: index + 1 }));
|
|
8004
|
+
}
|
|
8005
|
+
// ==========================================
|
|
8006
|
+
// Time Efficiency Analytics
|
|
8007
|
+
// ==========================================
|
|
8008
|
+
/**
|
|
8009
|
+
* Get time efficiency metrics grouped by clinic, practitioner, procedure, patient, or technology
|
|
8010
|
+
*
|
|
8011
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
|
|
8012
|
+
* @param dateRange - Optional date range filter
|
|
8013
|
+
* @param filters - Optional additional filters
|
|
8014
|
+
* @returns Grouped time efficiency metrics
|
|
8015
|
+
*/
|
|
8016
|
+
async getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters) {
|
|
8017
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
8018
|
+
return calculateGroupedTimeEfficiencyMetrics(appointments, groupBy);
|
|
8019
|
+
}
|
|
8020
|
+
/**
|
|
8021
|
+
* Get time efficiency metrics for appointments
|
|
8022
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
8023
|
+
*
|
|
8024
|
+
* @param filters - Optional filters
|
|
8025
|
+
* @param dateRange - Optional date range filter
|
|
8026
|
+
* @param options - Options for reading stored analytics
|
|
8027
|
+
* @returns Time efficiency metrics
|
|
8028
|
+
*/
|
|
8029
|
+
async getTimeEfficiencyMetrics(filters, dateRange, options) {
|
|
8030
|
+
if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
|
|
8031
|
+
const period = this.determinePeriodFromDateRange(dateRange);
|
|
8032
|
+
const stored = await readStoredTimeEfficiencyMetrics(
|
|
8033
|
+
this.db,
|
|
8034
|
+
filters.clinicBranchId,
|
|
8035
|
+
{ ...options, period }
|
|
8036
|
+
);
|
|
8037
|
+
if (stored) {
|
|
8038
|
+
const { metadata, ...metrics } = stored;
|
|
8039
|
+
return metrics;
|
|
8040
|
+
}
|
|
8041
|
+
}
|
|
8042
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
8043
|
+
const completed = getCompletedAppointments(appointments);
|
|
8044
|
+
const timeMetrics = calculateAverageTimeMetrics(completed);
|
|
8045
|
+
const efficiencyDistribution = calculateEfficiencyDistribution(completed);
|
|
8046
|
+
return {
|
|
8047
|
+
totalAppointments: completed.length,
|
|
8048
|
+
appointmentsWithActualTime: timeMetrics.appointmentsWithActualTime,
|
|
8049
|
+
averageBookedDuration: timeMetrics.averageBookedDuration,
|
|
8050
|
+
averageActualDuration: timeMetrics.averageActualDuration,
|
|
8051
|
+
averageEfficiency: timeMetrics.averageEfficiency,
|
|
8052
|
+
totalOverrun: timeMetrics.totalOverrun,
|
|
8053
|
+
totalUnderutilization: timeMetrics.totalUnderutilization,
|
|
8054
|
+
averageOverrun: timeMetrics.averageOverrun,
|
|
8055
|
+
averageUnderutilization: timeMetrics.averageUnderutilization,
|
|
8056
|
+
efficiencyDistribution
|
|
8057
|
+
};
|
|
8058
|
+
}
|
|
8059
|
+
// ==========================================
|
|
8060
|
+
// Cancellation & No-Show Analytics
|
|
8061
|
+
// ==========================================
|
|
8062
|
+
/**
|
|
8063
|
+
* Get cancellation metrics
|
|
8064
|
+
* First checks for stored analytics when grouping by clinic, then calculates if not available or stale
|
|
8065
|
+
*
|
|
8066
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
|
|
8067
|
+
* @param dateRange - Optional date range filter
|
|
8068
|
+
* @param options - Options for reading stored analytics (requires clinicBranchId for cache)
|
|
8069
|
+
* @returns Cancellation metrics grouped by specified entity
|
|
8070
|
+
*/
|
|
8071
|
+
async getCancellationMetrics(groupBy, dateRange, options) {
|
|
8072
|
+
if (groupBy === "clinic" && dateRange && (options == null ? void 0 : options.useCache) !== false && (options == null ? void 0 : options.clinicBranchId)) {
|
|
8073
|
+
const period = this.determinePeriodFromDateRange(dateRange);
|
|
8074
|
+
const stored = await readStoredCancellationMetrics(
|
|
8075
|
+
this.db,
|
|
8076
|
+
options.clinicBranchId,
|
|
8077
|
+
"clinic",
|
|
8078
|
+
{ ...options, period }
|
|
8079
|
+
);
|
|
8080
|
+
if (stored) {
|
|
8081
|
+
const { metadata, ...metrics } = stored;
|
|
8082
|
+
return metrics;
|
|
8083
|
+
}
|
|
8084
|
+
}
|
|
8085
|
+
const appointments = await this.fetchAppointments(void 0, dateRange);
|
|
8086
|
+
const canceled = getCanceledAppointments(appointments);
|
|
8087
|
+
if (groupBy === "clinic") {
|
|
8088
|
+
return this.groupCancellationsByClinic(canceled, appointments);
|
|
8089
|
+
} else if (groupBy === "practitioner") {
|
|
8090
|
+
return this.groupCancellationsByPractitioner(canceled, appointments);
|
|
8091
|
+
} else if (groupBy === "patient") {
|
|
8092
|
+
return this.groupCancellationsByPatient(canceled, appointments);
|
|
8093
|
+
} else if (groupBy === "technology") {
|
|
8094
|
+
return this.groupCancellationsByTechnology(canceled, appointments);
|
|
8095
|
+
} else {
|
|
8096
|
+
return this.groupCancellationsByProcedure(canceled, appointments);
|
|
8097
|
+
}
|
|
8098
|
+
}
|
|
8099
|
+
/**
|
|
8100
|
+
* Group cancellations by clinic
|
|
8101
|
+
*/
|
|
8102
|
+
groupCancellationsByClinic(canceled, allAppointments) {
|
|
8103
|
+
const clinicMap = /* @__PURE__ */ new Map();
|
|
8104
|
+
allAppointments.forEach((appointment) => {
|
|
8105
|
+
var _a;
|
|
8106
|
+
const clinicId = appointment.clinicBranchId;
|
|
8107
|
+
const clinicName = ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8108
|
+
if (!clinicMap.has(clinicId)) {
|
|
8109
|
+
clinicMap.set(clinicId, { name: clinicName, canceled: [], all: [] });
|
|
8110
|
+
}
|
|
8111
|
+
clinicMap.get(clinicId).all.push(appointment);
|
|
8112
|
+
});
|
|
8113
|
+
canceled.forEach((appointment) => {
|
|
8114
|
+
const clinicId = appointment.clinicBranchId;
|
|
8115
|
+
if (clinicMap.has(clinicId)) {
|
|
8116
|
+
clinicMap.get(clinicId).canceled.push(appointment);
|
|
8117
|
+
}
|
|
8118
|
+
});
|
|
8119
|
+
return Array.from(clinicMap.entries()).map(
|
|
8120
|
+
([clinicId, data]) => this.calculateCancellationMetrics(clinicId, data.name, "clinic", data.canceled, data.all)
|
|
8121
|
+
);
|
|
8122
|
+
}
|
|
8123
|
+
/**
|
|
8124
|
+
* Group cancellations by practitioner
|
|
8125
|
+
*/
|
|
8126
|
+
groupCancellationsByPractitioner(canceled, allAppointments) {
|
|
8127
|
+
const practitionerMap = /* @__PURE__ */ new Map();
|
|
8128
|
+
allAppointments.forEach((appointment) => {
|
|
8129
|
+
var _a;
|
|
8130
|
+
const practitionerId = appointment.practitionerId;
|
|
8131
|
+
const practitionerName = ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8132
|
+
if (!practitionerMap.has(practitionerId)) {
|
|
8133
|
+
practitionerMap.set(practitionerId, { name: practitionerName, canceled: [], all: [] });
|
|
8134
|
+
}
|
|
8135
|
+
practitionerMap.get(practitionerId).all.push(appointment);
|
|
8136
|
+
});
|
|
8137
|
+
canceled.forEach((appointment) => {
|
|
8138
|
+
const practitionerId = appointment.practitionerId;
|
|
8139
|
+
if (practitionerMap.has(practitionerId)) {
|
|
8140
|
+
practitionerMap.get(practitionerId).canceled.push(appointment);
|
|
8141
|
+
}
|
|
8142
|
+
});
|
|
8143
|
+
return Array.from(practitionerMap.entries()).map(
|
|
8144
|
+
([practitionerId, data]) => this.calculateCancellationMetrics(
|
|
8145
|
+
practitionerId,
|
|
8146
|
+
data.name,
|
|
8147
|
+
"practitioner",
|
|
8148
|
+
data.canceled,
|
|
8149
|
+
data.all
|
|
8150
|
+
)
|
|
8151
|
+
);
|
|
8152
|
+
}
|
|
8153
|
+
/**
|
|
8154
|
+
* Group cancellations by patient
|
|
8155
|
+
*/
|
|
8156
|
+
groupCancellationsByPatient(canceled, allAppointments) {
|
|
8157
|
+
const patientMap = /* @__PURE__ */ new Map();
|
|
8158
|
+
allAppointments.forEach((appointment) => {
|
|
8159
|
+
var _a;
|
|
8160
|
+
const patientId = appointment.patientId;
|
|
8161
|
+
const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
|
|
8162
|
+
if (!patientMap.has(patientId)) {
|
|
8163
|
+
patientMap.set(patientId, { name: patientName, canceled: [], all: [] });
|
|
8164
|
+
}
|
|
8165
|
+
patientMap.get(patientId).all.push(appointment);
|
|
8166
|
+
});
|
|
8167
|
+
canceled.forEach((appointment) => {
|
|
8168
|
+
const patientId = appointment.patientId;
|
|
8169
|
+
if (patientMap.has(patientId)) {
|
|
8170
|
+
patientMap.get(patientId).canceled.push(appointment);
|
|
8171
|
+
}
|
|
8172
|
+
});
|
|
8173
|
+
return Array.from(patientMap.entries()).map(
|
|
8174
|
+
([patientId, data]) => this.calculateCancellationMetrics(patientId, data.name, "patient", data.canceled, data.all)
|
|
8175
|
+
);
|
|
8176
|
+
}
|
|
8177
|
+
/**
|
|
8178
|
+
* Group cancellations by procedure
|
|
8179
|
+
*/
|
|
8180
|
+
groupCancellationsByProcedure(canceled, allAppointments) {
|
|
8181
|
+
const procedureMap = /* @__PURE__ */ new Map();
|
|
8182
|
+
allAppointments.forEach((appointment) => {
|
|
8183
|
+
var _a;
|
|
8184
|
+
const procedureId = appointment.procedureId;
|
|
8185
|
+
const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8186
|
+
if (!procedureMap.has(procedureId)) {
|
|
8187
|
+
procedureMap.set(procedureId, { name: procedureName, canceled: [], all: [] });
|
|
8188
|
+
}
|
|
8189
|
+
procedureMap.get(procedureId).all.push(appointment);
|
|
8190
|
+
});
|
|
8191
|
+
canceled.forEach((appointment) => {
|
|
8192
|
+
const procedureId = appointment.procedureId;
|
|
8193
|
+
if (procedureMap.has(procedureId)) {
|
|
8194
|
+
procedureMap.get(procedureId).canceled.push(appointment);
|
|
8195
|
+
}
|
|
8196
|
+
});
|
|
8197
|
+
return Array.from(procedureMap.entries()).map(
|
|
8198
|
+
([procedureId, data]) => this.calculateCancellationMetrics(
|
|
8199
|
+
procedureId,
|
|
8200
|
+
data.name,
|
|
8201
|
+
"procedure",
|
|
8202
|
+
data.canceled,
|
|
8203
|
+
data.all
|
|
8204
|
+
)
|
|
8205
|
+
);
|
|
8206
|
+
}
|
|
8207
|
+
/**
|
|
8208
|
+
* Group cancellations by technology
|
|
8209
|
+
* Aggregates all procedures using the same technology across all doctors
|
|
8210
|
+
*/
|
|
8211
|
+
groupCancellationsByTechnology(canceled, allAppointments) {
|
|
8212
|
+
const technologyMap = /* @__PURE__ */ new Map();
|
|
8213
|
+
allAppointments.forEach((appointment) => {
|
|
8214
|
+
var _a, _b, _c;
|
|
8215
|
+
const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
|
|
8216
|
+
const technologyName = ((_b = appointment.procedureExtendedInfo) == null ? void 0 : _b.procedureTechnologyName) || ((_c = appointment.procedureInfo) == null ? void 0 : _c.technologyName) || "Unknown";
|
|
8217
|
+
if (!technologyMap.has(technologyId)) {
|
|
8218
|
+
technologyMap.set(technologyId, { name: technologyName, canceled: [], all: [] });
|
|
8219
|
+
}
|
|
8220
|
+
technologyMap.get(technologyId).all.push(appointment);
|
|
8221
|
+
});
|
|
8222
|
+
canceled.forEach((appointment) => {
|
|
8223
|
+
var _a;
|
|
8224
|
+
const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
|
|
8225
|
+
if (technologyMap.has(technologyId)) {
|
|
8226
|
+
technologyMap.get(technologyId).canceled.push(appointment);
|
|
8227
|
+
}
|
|
8228
|
+
});
|
|
8229
|
+
return Array.from(technologyMap.entries()).map(
|
|
8230
|
+
([technologyId, data]) => this.calculateCancellationMetrics(
|
|
8231
|
+
technologyId,
|
|
8232
|
+
data.name,
|
|
8233
|
+
"technology",
|
|
8234
|
+
data.canceled,
|
|
8235
|
+
data.all
|
|
8236
|
+
)
|
|
8237
|
+
);
|
|
8238
|
+
}
|
|
8239
|
+
/**
|
|
8240
|
+
* Calculate cancellation metrics for a specific entity
|
|
8241
|
+
*/
|
|
8242
|
+
calculateCancellationMetrics(entityId, entityName, entityType, canceled, all) {
|
|
8243
|
+
const canceledByPatient = canceled.filter(
|
|
8244
|
+
(a) => a.status === "canceled_patient" /* CANCELED_PATIENT */
|
|
8245
|
+
).length;
|
|
8246
|
+
const canceledByClinic = canceled.filter(
|
|
8247
|
+
(a) => a.status === "canceled_clinic" /* CANCELED_CLINIC */
|
|
8248
|
+
).length;
|
|
8249
|
+
const canceledRescheduled = canceled.filter(
|
|
8250
|
+
(a) => a.status === "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */
|
|
8251
|
+
).length;
|
|
8252
|
+
const leadTimes = canceled.map((a) => calculateCancellationLeadTime(a)).filter((lt) => lt !== null);
|
|
8253
|
+
const averageLeadTime = leadTimes.length > 0 ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0;
|
|
8254
|
+
const reasonMap = /* @__PURE__ */ new Map();
|
|
8255
|
+
canceled.forEach((appointment) => {
|
|
8256
|
+
const reason = appointment.cancellationReason || "No reason provided";
|
|
8257
|
+
reasonMap.set(reason, (reasonMap.get(reason) || 0) + 1);
|
|
8258
|
+
});
|
|
8259
|
+
const cancellationReasons = Array.from(reasonMap.entries()).map(([reason, count]) => ({
|
|
8260
|
+
reason,
|
|
8261
|
+
count,
|
|
8262
|
+
percentage: calculatePercentage(count, canceled.length)
|
|
8263
|
+
}));
|
|
8264
|
+
return {
|
|
8265
|
+
entityId,
|
|
8266
|
+
entityName,
|
|
8267
|
+
entityType,
|
|
8268
|
+
totalAppointments: all.length,
|
|
8269
|
+
canceledAppointments: canceled.length,
|
|
8270
|
+
cancellationRate: calculatePercentage(canceled.length, all.length),
|
|
8271
|
+
canceledByPatient,
|
|
8272
|
+
canceledByClinic,
|
|
8273
|
+
canceledByPractitioner: 0,
|
|
8274
|
+
// Not tracked in current status enum
|
|
8275
|
+
canceledRescheduled,
|
|
8276
|
+
averageCancellationLeadTime: Math.round(averageLeadTime * 100) / 100,
|
|
8277
|
+
cancellationReasons
|
|
8278
|
+
};
|
|
8279
|
+
}
|
|
8280
|
+
/**
|
|
8281
|
+
* Get no-show metrics
|
|
8282
|
+
* First checks for stored analytics when grouping by clinic, then calculates if not available or stale
|
|
8283
|
+
*
|
|
8284
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'patient' | 'procedure' | 'technology'
|
|
8285
|
+
* @param dateRange - Optional date range filter
|
|
8286
|
+
* @param options - Options for reading stored analytics (requires clinicBranchId for cache)
|
|
8287
|
+
* @returns No-show metrics grouped by specified entity
|
|
8288
|
+
*/
|
|
8289
|
+
async getNoShowMetrics(groupBy, dateRange, options) {
|
|
8290
|
+
if (groupBy === "clinic" && dateRange && (options == null ? void 0 : options.useCache) !== false && (options == null ? void 0 : options.clinicBranchId)) {
|
|
8291
|
+
const period = this.determinePeriodFromDateRange(dateRange);
|
|
8292
|
+
const stored = await readStoredNoShowMetrics(
|
|
8293
|
+
this.db,
|
|
8294
|
+
options.clinicBranchId,
|
|
8295
|
+
"clinic",
|
|
8296
|
+
{ ...options, period }
|
|
8297
|
+
);
|
|
8298
|
+
if (stored) {
|
|
8299
|
+
const { metadata, ...metrics } = stored;
|
|
8300
|
+
return metrics;
|
|
8301
|
+
}
|
|
8302
|
+
}
|
|
8303
|
+
const appointments = await this.fetchAppointments(void 0, dateRange);
|
|
8304
|
+
const noShow = getNoShowAppointments(appointments);
|
|
8305
|
+
if (groupBy === "clinic") {
|
|
8306
|
+
return this.groupNoShowsByClinic(noShow, appointments);
|
|
8307
|
+
} else if (groupBy === "practitioner") {
|
|
8308
|
+
return this.groupNoShowsByPractitioner(noShow, appointments);
|
|
8309
|
+
} else if (groupBy === "patient") {
|
|
8310
|
+
return this.groupNoShowsByPatient(noShow, appointments);
|
|
8311
|
+
} else if (groupBy === "technology") {
|
|
8312
|
+
return this.groupNoShowsByTechnology(noShow, appointments);
|
|
8313
|
+
} else {
|
|
8314
|
+
return this.groupNoShowsByProcedure(noShow, appointments);
|
|
8315
|
+
}
|
|
8316
|
+
}
|
|
8317
|
+
/**
|
|
8318
|
+
* Group no-shows by clinic
|
|
8319
|
+
*/
|
|
8320
|
+
groupNoShowsByClinic(noShow, allAppointments) {
|
|
8321
|
+
const clinicMap = /* @__PURE__ */ new Map();
|
|
8322
|
+
allAppointments.forEach((appointment) => {
|
|
8323
|
+
var _a;
|
|
8324
|
+
const clinicId = appointment.clinicBranchId;
|
|
8325
|
+
const clinicName = ((_a = appointment.clinicInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8326
|
+
if (!clinicMap.has(clinicId)) {
|
|
8327
|
+
clinicMap.set(clinicId, { name: clinicName, noShow: [], all: [] });
|
|
8328
|
+
}
|
|
8329
|
+
clinicMap.get(clinicId).all.push(appointment);
|
|
8330
|
+
});
|
|
8331
|
+
noShow.forEach((appointment) => {
|
|
8332
|
+
const clinicId = appointment.clinicBranchId;
|
|
8333
|
+
if (clinicMap.has(clinicId)) {
|
|
8334
|
+
clinicMap.get(clinicId).noShow.push(appointment);
|
|
8335
|
+
}
|
|
8336
|
+
});
|
|
8337
|
+
return Array.from(clinicMap.entries()).map(([clinicId, data]) => ({
|
|
8338
|
+
entityId: clinicId,
|
|
8339
|
+
entityName: data.name,
|
|
8340
|
+
entityType: "clinic",
|
|
8341
|
+
totalAppointments: data.all.length,
|
|
8342
|
+
noShowAppointments: data.noShow.length,
|
|
8343
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length)
|
|
8344
|
+
}));
|
|
8345
|
+
}
|
|
8346
|
+
/**
|
|
8347
|
+
* Group no-shows by practitioner
|
|
8348
|
+
*/
|
|
8349
|
+
groupNoShowsByPractitioner(noShow, allAppointments) {
|
|
8350
|
+
const practitionerMap = /* @__PURE__ */ new Map();
|
|
8351
|
+
allAppointments.forEach((appointment) => {
|
|
8352
|
+
var _a;
|
|
8353
|
+
const practitionerId = appointment.practitionerId;
|
|
8354
|
+
const practitionerName = ((_a = appointment.practitionerInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8355
|
+
if (!practitionerMap.has(practitionerId)) {
|
|
8356
|
+
practitionerMap.set(practitionerId, { name: practitionerName, noShow: [], all: [] });
|
|
8357
|
+
}
|
|
8358
|
+
practitionerMap.get(practitionerId).all.push(appointment);
|
|
8359
|
+
});
|
|
8360
|
+
noShow.forEach((appointment) => {
|
|
8361
|
+
const practitionerId = appointment.practitionerId;
|
|
8362
|
+
if (practitionerMap.has(practitionerId)) {
|
|
8363
|
+
practitionerMap.get(practitionerId).noShow.push(appointment);
|
|
8364
|
+
}
|
|
8365
|
+
});
|
|
8366
|
+
return Array.from(practitionerMap.entries()).map(([practitionerId, data]) => ({
|
|
8367
|
+
entityId: practitionerId,
|
|
8368
|
+
entityName: data.name,
|
|
8369
|
+
entityType: "practitioner",
|
|
8370
|
+
totalAppointments: data.all.length,
|
|
8371
|
+
noShowAppointments: data.noShow.length,
|
|
8372
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length)
|
|
8373
|
+
}));
|
|
8374
|
+
}
|
|
8375
|
+
/**
|
|
8376
|
+
* Group no-shows by patient
|
|
8377
|
+
*/
|
|
8378
|
+
groupNoShowsByPatient(noShow, allAppointments) {
|
|
8379
|
+
const patientMap = /* @__PURE__ */ new Map();
|
|
8380
|
+
allAppointments.forEach((appointment) => {
|
|
8381
|
+
var _a;
|
|
8382
|
+
const patientId = appointment.patientId;
|
|
8383
|
+
const patientName = ((_a = appointment.patientInfo) == null ? void 0 : _a.fullName) || "Unknown";
|
|
8384
|
+
if (!patientMap.has(patientId)) {
|
|
8385
|
+
patientMap.set(patientId, { name: patientName, noShow: [], all: [] });
|
|
8386
|
+
}
|
|
8387
|
+
patientMap.get(patientId).all.push(appointment);
|
|
8388
|
+
});
|
|
8389
|
+
noShow.forEach((appointment) => {
|
|
8390
|
+
const patientId = appointment.patientId;
|
|
8391
|
+
if (patientMap.has(patientId)) {
|
|
8392
|
+
patientMap.get(patientId).noShow.push(appointment);
|
|
8393
|
+
}
|
|
8394
|
+
});
|
|
8395
|
+
return Array.from(patientMap.entries()).map(([patientId, data]) => ({
|
|
8396
|
+
entityId: patientId,
|
|
8397
|
+
entityName: data.name,
|
|
8398
|
+
entityType: "patient",
|
|
8399
|
+
totalAppointments: data.all.length,
|
|
8400
|
+
noShowAppointments: data.noShow.length,
|
|
8401
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length)
|
|
8402
|
+
}));
|
|
8403
|
+
}
|
|
8404
|
+
/**
|
|
8405
|
+
* Group no-shows by procedure
|
|
8406
|
+
*/
|
|
8407
|
+
groupNoShowsByProcedure(noShow, allAppointments) {
|
|
8408
|
+
const procedureMap = /* @__PURE__ */ new Map();
|
|
8409
|
+
allAppointments.forEach((appointment) => {
|
|
8410
|
+
var _a;
|
|
8411
|
+
const procedureId = appointment.procedureId;
|
|
8412
|
+
const procedureName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8413
|
+
if (!procedureMap.has(procedureId)) {
|
|
8414
|
+
procedureMap.set(procedureId, { name: procedureName, noShow: [], all: [] });
|
|
8415
|
+
}
|
|
8416
|
+
procedureMap.get(procedureId).all.push(appointment);
|
|
8417
|
+
});
|
|
8418
|
+
noShow.forEach((appointment) => {
|
|
8419
|
+
const procedureId = appointment.procedureId;
|
|
8420
|
+
if (procedureMap.has(procedureId)) {
|
|
8421
|
+
procedureMap.get(procedureId).noShow.push(appointment);
|
|
8422
|
+
}
|
|
8423
|
+
});
|
|
8424
|
+
return Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
|
|
8425
|
+
entityId: procedureId,
|
|
8426
|
+
entityName: data.name,
|
|
8427
|
+
entityType: "procedure",
|
|
8428
|
+
totalAppointments: data.all.length,
|
|
8429
|
+
noShowAppointments: data.noShow.length,
|
|
8430
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length)
|
|
8431
|
+
}));
|
|
8432
|
+
}
|
|
8433
|
+
/**
|
|
8434
|
+
* Group no-shows by technology
|
|
8435
|
+
* Aggregates all procedures using the same technology across all doctors
|
|
8436
|
+
*/
|
|
8437
|
+
groupNoShowsByTechnology(noShow, allAppointments) {
|
|
8438
|
+
const technologyMap = /* @__PURE__ */ new Map();
|
|
8439
|
+
allAppointments.forEach((appointment) => {
|
|
8440
|
+
var _a, _b, _c;
|
|
8441
|
+
const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
|
|
8442
|
+
const technologyName = ((_b = appointment.procedureExtendedInfo) == null ? void 0 : _b.procedureTechnologyName) || ((_c = appointment.procedureInfo) == null ? void 0 : _c.technologyName) || "Unknown";
|
|
8443
|
+
if (!technologyMap.has(technologyId)) {
|
|
8444
|
+
technologyMap.set(technologyId, { name: technologyName, noShow: [], all: [] });
|
|
8445
|
+
}
|
|
8446
|
+
technologyMap.get(technologyId).all.push(appointment);
|
|
8447
|
+
});
|
|
8448
|
+
noShow.forEach((appointment) => {
|
|
8449
|
+
var _a;
|
|
8450
|
+
const technologyId = ((_a = appointment.procedureExtendedInfo) == null ? void 0 : _a.procedureTechnologyId) || "unknown-technology";
|
|
8451
|
+
if (technologyMap.has(technologyId)) {
|
|
8452
|
+
technologyMap.get(technologyId).noShow.push(appointment);
|
|
8453
|
+
}
|
|
8454
|
+
});
|
|
8455
|
+
return Array.from(technologyMap.entries()).map(([technologyId, data]) => ({
|
|
8456
|
+
entityId: technologyId,
|
|
8457
|
+
entityName: data.name,
|
|
8458
|
+
entityType: "technology",
|
|
8459
|
+
totalAppointments: data.all.length,
|
|
8460
|
+
noShowAppointments: data.noShow.length,
|
|
8461
|
+
noShowRate: calculatePercentage(data.noShow.length, data.all.length)
|
|
8462
|
+
}));
|
|
8463
|
+
}
|
|
8464
|
+
// ==========================================
|
|
8465
|
+
// Financial Analytics
|
|
8466
|
+
// ==========================================
|
|
8467
|
+
/**
|
|
8468
|
+
* Get revenue metrics grouped by clinic, practitioner, procedure, patient, or technology
|
|
8469
|
+
*
|
|
8470
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient' | 'technology'
|
|
8471
|
+
* @param dateRange - Optional date range filter
|
|
8472
|
+
* @param filters - Optional additional filters
|
|
8473
|
+
* @returns Grouped revenue metrics
|
|
8474
|
+
*/
|
|
8475
|
+
async getRevenueMetricsByEntity(groupBy, dateRange, filters) {
|
|
8476
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
8477
|
+
return calculateGroupedRevenueMetrics(appointments, groupBy);
|
|
8478
|
+
}
|
|
8479
|
+
/**
|
|
8480
|
+
* Get revenue metrics
|
|
8481
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
8482
|
+
*
|
|
8483
|
+
* @param filters - Optional filters
|
|
8484
|
+
* @param dateRange - Optional date range filter
|
|
8485
|
+
* @param options - Options for reading stored analytics
|
|
8486
|
+
* @returns Revenue metrics
|
|
8487
|
+
*/
|
|
8488
|
+
async getRevenueMetrics(filters, dateRange, options) {
|
|
8489
|
+
if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
|
|
8490
|
+
const period = this.determinePeriodFromDateRange(dateRange);
|
|
8491
|
+
const stored = await readStoredRevenueMetrics(
|
|
8492
|
+
this.db,
|
|
8493
|
+
filters.clinicBranchId,
|
|
8494
|
+
{ ...options, period }
|
|
8495
|
+
);
|
|
8496
|
+
if (stored) {
|
|
8497
|
+
const { metadata, ...metrics } = stored;
|
|
8498
|
+
return metrics;
|
|
8499
|
+
}
|
|
8500
|
+
}
|
|
8501
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
8502
|
+
const completed = getCompletedAppointments(appointments);
|
|
8503
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
8504
|
+
const revenueByStatus = {};
|
|
8505
|
+
Object.values(AppointmentStatus).forEach((status) => {
|
|
8506
|
+
const statusAppointments = appointments.filter((a) => a.status === status);
|
|
8507
|
+
const { totalRevenue: statusRevenue } = calculateTotalRevenue(statusAppointments);
|
|
8508
|
+
revenueByStatus[status] = statusRevenue;
|
|
8509
|
+
});
|
|
8510
|
+
const revenueByPaymentStatus = {};
|
|
8511
|
+
Object.values(PaymentStatus).forEach((paymentStatus) => {
|
|
8512
|
+
const paymentAppointments = completed.filter((a) => a.paymentStatus === paymentStatus);
|
|
8513
|
+
const { totalRevenue: paymentRevenue } = calculateTotalRevenue(paymentAppointments);
|
|
8514
|
+
revenueByPaymentStatus[paymentStatus] = paymentRevenue;
|
|
8515
|
+
});
|
|
8516
|
+
const unpaid = completed.filter((a) => a.paymentStatus === "unpaid" /* UNPAID */);
|
|
8517
|
+
const refunded = completed.filter((a) => a.paymentStatus === "refunded" /* REFUNDED */);
|
|
8518
|
+
const { totalRevenue: unpaidRevenue } = calculateTotalRevenue(unpaid);
|
|
8519
|
+
const { totalRevenue: refundedRevenue } = calculateTotalRevenue(refunded);
|
|
8520
|
+
let totalTax = 0;
|
|
8521
|
+
let totalSubtotal = 0;
|
|
8522
|
+
completed.forEach((appointment) => {
|
|
8523
|
+
const costData = calculateAppointmentCost(appointment);
|
|
8524
|
+
if (costData.source === "finalbilling") {
|
|
8525
|
+
totalTax += costData.tax || 0;
|
|
8526
|
+
totalSubtotal += costData.subtotal || 0;
|
|
8527
|
+
} else {
|
|
8528
|
+
totalSubtotal += costData.cost;
|
|
8529
|
+
}
|
|
8530
|
+
});
|
|
8531
|
+
return {
|
|
8532
|
+
totalRevenue,
|
|
8533
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
8534
|
+
totalAppointments: appointments.length,
|
|
8535
|
+
completedAppointments: completed.length,
|
|
8536
|
+
currency,
|
|
8537
|
+
revenueByStatus,
|
|
8538
|
+
revenueByPaymentStatus,
|
|
8539
|
+
unpaidRevenue,
|
|
8540
|
+
refundedRevenue,
|
|
8541
|
+
totalTax,
|
|
8542
|
+
totalSubtotal
|
|
8543
|
+
};
|
|
8544
|
+
}
|
|
8545
|
+
// ==========================================
|
|
8546
|
+
// Product Usage Analytics
|
|
8547
|
+
// ==========================================
|
|
8548
|
+
/**
|
|
8549
|
+
* Get product usage metrics grouped by clinic, practitioner, procedure, or patient
|
|
8550
|
+
*
|
|
8551
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'patient'
|
|
8552
|
+
* @param dateRange - Optional date range filter
|
|
8553
|
+
* @param filters - Optional additional filters
|
|
8554
|
+
* @returns Grouped product usage metrics
|
|
8555
|
+
*/
|
|
8556
|
+
async getProductUsageMetricsByEntity(groupBy, dateRange, filters) {
|
|
8557
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
8558
|
+
return calculateGroupedProductUsageMetrics(appointments, groupBy);
|
|
8559
|
+
}
|
|
8560
|
+
/**
|
|
8561
|
+
* Get product usage metrics
|
|
8562
|
+
*
|
|
8563
|
+
* @param productId - Optional product ID (if not provided, returns all products)
|
|
8564
|
+
* @param dateRange - Optional date range filter
|
|
8565
|
+
* @returns Product usage metrics
|
|
8566
|
+
*/
|
|
8567
|
+
async getProductUsageMetrics(productId, dateRange) {
|
|
8568
|
+
const appointments = await this.fetchAppointments(void 0, dateRange);
|
|
8569
|
+
const completed = getCompletedAppointments(appointments);
|
|
8570
|
+
const productMap = /* @__PURE__ */ new Map();
|
|
8571
|
+
completed.forEach((appointment) => {
|
|
8572
|
+
const products = extractProductUsage(appointment);
|
|
8573
|
+
products.forEach((product) => {
|
|
8574
|
+
var _a;
|
|
8575
|
+
if (productId && product.productId !== productId) {
|
|
8576
|
+
return;
|
|
8577
|
+
}
|
|
8578
|
+
if (!productMap.has(product.productId)) {
|
|
8579
|
+
productMap.set(product.productId, {
|
|
8580
|
+
name: product.productName,
|
|
8581
|
+
brandId: product.brandId,
|
|
8582
|
+
brandName: product.brandName,
|
|
8583
|
+
quantity: 0,
|
|
8584
|
+
revenue: 0,
|
|
8585
|
+
usageCount: 0,
|
|
8586
|
+
procedureMap: /* @__PURE__ */ new Map()
|
|
8587
|
+
});
|
|
8588
|
+
}
|
|
8589
|
+
const productData = productMap.get(product.productId);
|
|
8590
|
+
productData.quantity += product.quantity;
|
|
8591
|
+
productData.revenue += product.subtotal;
|
|
8592
|
+
productData.usageCount++;
|
|
8593
|
+
const procId = appointment.procedureId;
|
|
8594
|
+
const procName = ((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown";
|
|
8595
|
+
if (productData.procedureMap.has(procId)) {
|
|
8596
|
+
const procData = productData.procedureMap.get(procId);
|
|
8597
|
+
procData.count++;
|
|
8598
|
+
procData.quantity += product.quantity;
|
|
8599
|
+
} else {
|
|
8600
|
+
productData.procedureMap.set(procId, {
|
|
8601
|
+
name: procName,
|
|
8602
|
+
count: 1,
|
|
8603
|
+
quantity: product.quantity
|
|
8604
|
+
});
|
|
8605
|
+
}
|
|
8606
|
+
});
|
|
8607
|
+
});
|
|
8608
|
+
const results = Array.from(productMap.entries()).map(([productId2, data]) => ({
|
|
8609
|
+
productId: productId2,
|
|
8610
|
+
productName: data.name,
|
|
8611
|
+
brandId: data.brandId,
|
|
8612
|
+
brandName: data.brandName,
|
|
8613
|
+
totalQuantity: data.quantity,
|
|
8614
|
+
totalRevenue: data.revenue,
|
|
8615
|
+
averagePrice: data.usageCount > 0 ? data.revenue / data.quantity : 0,
|
|
8616
|
+
currency: "CHF",
|
|
8617
|
+
// Could be extracted from products
|
|
8618
|
+
usageCount: data.usageCount,
|
|
8619
|
+
averageQuantityPerAppointment: data.usageCount > 0 ? data.quantity / data.usageCount : 0,
|
|
8620
|
+
usageByProcedure: Array.from(data.procedureMap.entries()).map(([procId, procData]) => ({
|
|
8621
|
+
procedureId: procId,
|
|
8622
|
+
procedureName: procData.name,
|
|
8623
|
+
count: procData.count,
|
|
8624
|
+
totalQuantity: procData.quantity
|
|
8625
|
+
}))
|
|
8626
|
+
}));
|
|
8627
|
+
return productId ? results[0] : results;
|
|
8628
|
+
}
|
|
8629
|
+
// ==========================================
|
|
8630
|
+
// Patient Analytics
|
|
8631
|
+
// ==========================================
|
|
8632
|
+
/**
|
|
8633
|
+
* Get patient behavior metrics grouped by clinic, practitioner, procedure, or technology
|
|
8634
|
+
* Shows patient no-show and cancellation patterns per entity
|
|
8635
|
+
*
|
|
8636
|
+
* @param groupBy - Group by 'clinic' | 'practitioner' | 'procedure' | 'technology'
|
|
8637
|
+
* @param dateRange - Optional date range filter
|
|
8638
|
+
* @param filters - Optional additional filters
|
|
8639
|
+
* @returns Grouped patient behavior metrics
|
|
8640
|
+
*/
|
|
8641
|
+
async getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters) {
|
|
8642
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
8643
|
+
return calculateGroupedPatientBehaviorMetrics(appointments, groupBy);
|
|
8644
|
+
}
|
|
8645
|
+
/**
|
|
8646
|
+
* Get patient analytics
|
|
8647
|
+
*
|
|
8648
|
+
* @param patientId - Optional patient ID (if not provided, returns aggregate)
|
|
8649
|
+
* @param dateRange - Optional date range filter
|
|
8650
|
+
* @returns Patient analytics
|
|
8651
|
+
*/
|
|
8652
|
+
async getPatientAnalytics(patientId, dateRange) {
|
|
8653
|
+
const appointments = await this.fetchAppointments(patientId ? { patientId } : void 0, dateRange);
|
|
8654
|
+
if (patientId) {
|
|
8655
|
+
return this.calculatePatientAnalytics(appointments, patientId);
|
|
8656
|
+
}
|
|
8657
|
+
const patientMap = /* @__PURE__ */ new Map();
|
|
8658
|
+
appointments.forEach((appointment) => {
|
|
8659
|
+
const patId = appointment.patientId;
|
|
8660
|
+
if (!patientMap.has(patId)) {
|
|
8661
|
+
patientMap.set(patId, []);
|
|
8662
|
+
}
|
|
8663
|
+
patientMap.get(patId).push(appointment);
|
|
8664
|
+
});
|
|
8665
|
+
return Array.from(patientMap.entries()).map(
|
|
8666
|
+
([patId, patAppointments]) => this.calculatePatientAnalytics(patAppointments, patId)
|
|
8667
|
+
);
|
|
8668
|
+
}
|
|
8669
|
+
/**
|
|
8670
|
+
* Calculate analytics for a specific patient
|
|
8671
|
+
*/
|
|
8672
|
+
calculatePatientAnalytics(appointments, patientId) {
|
|
8673
|
+
var _a;
|
|
8674
|
+
const completed = getCompletedAppointments(appointments);
|
|
8675
|
+
const canceled = getCanceledAppointments(appointments);
|
|
8676
|
+
const noShow = getNoShowAppointments(appointments);
|
|
8677
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
8678
|
+
const appointmentDates = appointments.map((a) => a.appointmentStartTime.toDate()).sort((a, b) => a.getTime() - b.getTime());
|
|
8679
|
+
const firstAppointmentDate = appointmentDates.length > 0 ? appointmentDates[0] : null;
|
|
8680
|
+
const lastAppointmentDate = appointmentDates.length > 0 ? appointmentDates[appointmentDates.length - 1] : null;
|
|
8681
|
+
let averageDaysBetween = null;
|
|
8682
|
+
if (appointmentDates.length > 1) {
|
|
8683
|
+
const intervals = [];
|
|
8684
|
+
for (let i = 1; i < appointmentDates.length; i++) {
|
|
8685
|
+
const diffMs = appointmentDates[i].getTime() - appointmentDates[i - 1].getTime();
|
|
8686
|
+
intervals.push(diffMs / (1e3 * 60 * 60 * 24));
|
|
8687
|
+
}
|
|
8688
|
+
averageDaysBetween = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
8689
|
+
}
|
|
8690
|
+
const uniquePractitioners = new Set(appointments.map((a) => a.practitionerId));
|
|
8691
|
+
const uniqueClinics = new Set(appointments.map((a) => a.clinicBranchId));
|
|
8692
|
+
const procedureMap = /* @__PURE__ */ new Map();
|
|
8693
|
+
completed.forEach((appointment) => {
|
|
8694
|
+
var _a2, _b;
|
|
8695
|
+
const procId = appointment.procedureId;
|
|
8696
|
+
const procName = ((_a2 = appointment.procedureInfo) == null ? void 0 : _a2.name) || "Unknown";
|
|
8697
|
+
procedureMap.set(procId, {
|
|
8698
|
+
name: procName,
|
|
8699
|
+
count: (((_b = procedureMap.get(procId)) == null ? void 0 : _b.count) || 0) + 1
|
|
8700
|
+
});
|
|
8701
|
+
});
|
|
8702
|
+
const favoriteProcedures = Array.from(procedureMap.entries()).map(([procedureId, data]) => ({
|
|
8703
|
+
procedureId,
|
|
8704
|
+
procedureName: data.name,
|
|
8705
|
+
count: data.count
|
|
8706
|
+
})).sort((a, b) => b.count - a.count).slice(0, 5);
|
|
8707
|
+
const patientName = appointments.length > 0 ? ((_a = appointments[0].patientInfo) == null ? void 0 : _a.fullName) || "Unknown" : "Unknown";
|
|
8708
|
+
return {
|
|
8709
|
+
patientId,
|
|
8710
|
+
patientName,
|
|
8711
|
+
totalAppointments: appointments.length,
|
|
8712
|
+
completedAppointments: completed.length,
|
|
8713
|
+
canceledAppointments: canceled.length,
|
|
8714
|
+
noShowAppointments: noShow.length,
|
|
8715
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
8716
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length),
|
|
8717
|
+
totalRevenue,
|
|
8718
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
8719
|
+
currency,
|
|
8720
|
+
lifetimeValue: totalRevenue,
|
|
8721
|
+
firstAppointmentDate,
|
|
8722
|
+
lastAppointmentDate,
|
|
8723
|
+
averageDaysBetweenAppointments: averageDaysBetween ? Math.round(averageDaysBetween) : null,
|
|
8724
|
+
uniquePractitioners: uniquePractitioners.size,
|
|
8725
|
+
uniqueClinics: uniqueClinics.size,
|
|
8726
|
+
favoriteProcedures
|
|
8727
|
+
};
|
|
8728
|
+
}
|
|
8729
|
+
// ==========================================
|
|
8730
|
+
// Dashboard Analytics
|
|
8731
|
+
// ==========================================
|
|
8732
|
+
/**
|
|
8733
|
+
* Determines analytics period from date range
|
|
8734
|
+
*/
|
|
8735
|
+
determinePeriodFromDateRange(dateRange) {
|
|
8736
|
+
const diffMs = dateRange.end.getTime() - dateRange.start.getTime();
|
|
8737
|
+
const diffDays = diffMs / (1e3 * 60 * 60 * 24);
|
|
8738
|
+
if (diffDays <= 1) return "daily";
|
|
8739
|
+
if (diffDays <= 7) return "weekly";
|
|
8740
|
+
if (diffDays <= 31) return "monthly";
|
|
8741
|
+
if (diffDays <= 365) return "yearly";
|
|
8742
|
+
return "all_time";
|
|
8743
|
+
}
|
|
8744
|
+
/**
|
|
8745
|
+
* Get comprehensive dashboard data
|
|
8746
|
+
* First checks for stored analytics, then calculates if not available or stale
|
|
8747
|
+
*
|
|
8748
|
+
* @param filters - Optional filters
|
|
8749
|
+
* @param dateRange - Optional date range filter
|
|
8750
|
+
* @param options - Options for reading stored analytics
|
|
8751
|
+
* @returns Complete dashboard analytics
|
|
8752
|
+
*/
|
|
8753
|
+
async getDashboardData(filters, dateRange, options) {
|
|
8754
|
+
if ((filters == null ? void 0 : filters.clinicBranchId) && dateRange && (options == null ? void 0 : options.useCache) !== false) {
|
|
8755
|
+
const period = this.determinePeriodFromDateRange(dateRange);
|
|
8756
|
+
const stored = await readStoredDashboardAnalytics(
|
|
8757
|
+
this.db,
|
|
8758
|
+
filters.clinicBranchId,
|
|
8759
|
+
{ ...options, period }
|
|
8760
|
+
);
|
|
8761
|
+
if (stored) {
|
|
8762
|
+
const { metadata, ...analytics } = stored;
|
|
8763
|
+
return analytics;
|
|
8764
|
+
}
|
|
8765
|
+
}
|
|
8766
|
+
const appointments = await this.fetchAppointments(filters, dateRange);
|
|
8767
|
+
const completed = getCompletedAppointments(appointments);
|
|
8768
|
+
const canceled = getCanceledAppointments(appointments);
|
|
8769
|
+
const noShow = getNoShowAppointments(appointments);
|
|
8770
|
+
const pending = appointments.filter((a) => a.status === "pending" /* PENDING */);
|
|
8771
|
+
const confirmed = appointments.filter((a) => a.status === "confirmed" /* CONFIRMED */);
|
|
8772
|
+
const { totalRevenue, currency } = calculateTotalRevenue(completed);
|
|
8773
|
+
const uniquePatients = new Set(appointments.map((a) => a.patientId));
|
|
8774
|
+
const uniquePractitioners = new Set(appointments.map((a) => a.practitionerId));
|
|
8775
|
+
const uniqueProcedures = new Set(appointments.map((a) => a.procedureId));
|
|
8776
|
+
const practitionerMetrics = await Promise.all(
|
|
8777
|
+
Array.from(uniquePractitioners).slice(0, 5).map((practitionerId) => this.getPractitionerAnalytics(practitionerId, dateRange))
|
|
8778
|
+
);
|
|
8779
|
+
const procedureMetricsResults = await Promise.all(
|
|
8780
|
+
Array.from(uniqueProcedures).slice(0, 5).map((procedureId) => this.getProcedureAnalytics(procedureId, dateRange))
|
|
8781
|
+
);
|
|
8782
|
+
const procedureMetrics = procedureMetricsResults.filter(
|
|
8783
|
+
(result) => !Array.isArray(result)
|
|
8784
|
+
);
|
|
8785
|
+
const cancellationMetrics = await this.getCancellationMetrics("clinic", dateRange);
|
|
8786
|
+
const noShowMetrics = await this.getNoShowMetrics("clinic", dateRange);
|
|
8787
|
+
const timeEfficiency = await this.getTimeEfficiencyMetrics(filters, dateRange);
|
|
8788
|
+
const productMetrics = await this.getProductUsageMetrics(void 0, dateRange);
|
|
8789
|
+
const topProducts = Array.isArray(productMetrics) ? productMetrics.sort((a, b) => b.totalRevenue - a.totalRevenue).slice(0, 5) : [];
|
|
8790
|
+
const recentActivity = appointments.sort((a, b) => b.appointmentStartTime.toMillis() - a.appointmentStartTime.toMillis()).slice(0, 10).map((appointment) => {
|
|
8791
|
+
var _a, _b, _c, _d, _e;
|
|
8792
|
+
let type = "appointment";
|
|
8793
|
+
let description = "";
|
|
8794
|
+
if (appointment.status === "completed" /* COMPLETED */) {
|
|
8795
|
+
type = "completion";
|
|
8796
|
+
description = `Appointment completed: ${((_a = appointment.procedureInfo) == null ? void 0 : _a.name) || "Unknown procedure"}`;
|
|
8797
|
+
} else if (appointment.status === "canceled_patient" /* CANCELED_PATIENT */ || appointment.status === "canceled_clinic" /* CANCELED_CLINIC */ || appointment.status === "canceled_patient_rescheduled" /* CANCELED_PATIENT_RESCHEDULED */) {
|
|
8798
|
+
type = "cancellation";
|
|
8799
|
+
description = `Appointment canceled: ${((_b = appointment.procedureInfo) == null ? void 0 : _b.name) || "Unknown procedure"}`;
|
|
8800
|
+
} else if (appointment.status === "no_show" /* NO_SHOW */) {
|
|
8801
|
+
type = "no_show";
|
|
8802
|
+
description = `No-show: ${((_c = appointment.procedureInfo) == null ? void 0 : _c.name) || "Unknown procedure"}`;
|
|
8803
|
+
} else {
|
|
8804
|
+
description = `Appointment ${appointment.status}: ${((_d = appointment.procedureInfo) == null ? void 0 : _d.name) || "Unknown procedure"}`;
|
|
8805
|
+
}
|
|
8806
|
+
return {
|
|
8807
|
+
type,
|
|
8808
|
+
date: appointment.appointmentStartTime.toDate(),
|
|
8809
|
+
description,
|
|
8810
|
+
entityId: appointment.practitionerId,
|
|
8811
|
+
entityName: ((_e = appointment.practitionerInfo) == null ? void 0 : _e.name) || "Unknown"
|
|
8812
|
+
};
|
|
8813
|
+
});
|
|
8814
|
+
return {
|
|
8815
|
+
overview: {
|
|
8816
|
+
totalAppointments: appointments.length,
|
|
8817
|
+
completedAppointments: completed.length,
|
|
8818
|
+
canceledAppointments: canceled.length,
|
|
8819
|
+
noShowAppointments: noShow.length,
|
|
8820
|
+
pendingAppointments: pending.length,
|
|
8821
|
+
confirmedAppointments: confirmed.length,
|
|
8822
|
+
totalRevenue,
|
|
8823
|
+
averageRevenuePerAppointment: completed.length > 0 ? totalRevenue / completed.length : 0,
|
|
8824
|
+
currency,
|
|
8825
|
+
uniquePatients: uniquePatients.size,
|
|
8826
|
+
uniquePractitioners: uniquePractitioners.size,
|
|
8827
|
+
uniqueProcedures: uniqueProcedures.size,
|
|
8828
|
+
cancellationRate: calculatePercentage(canceled.length, appointments.length),
|
|
8829
|
+
noShowRate: calculatePercentage(noShow.length, appointments.length)
|
|
8830
|
+
},
|
|
8831
|
+
practitionerMetrics: Array.isArray(practitionerMetrics) ? practitionerMetrics : [],
|
|
8832
|
+
procedureMetrics: Array.isArray(procedureMetrics) ? procedureMetrics : [],
|
|
8833
|
+
cancellationMetrics: Array.isArray(cancellationMetrics) ? cancellationMetrics[0] : cancellationMetrics,
|
|
8834
|
+
noShowMetrics: Array.isArray(noShowMetrics) ? noShowMetrics[0] : noShowMetrics,
|
|
8835
|
+
revenueTrends: [],
|
|
8836
|
+
// TODO: Implement revenue trends
|
|
8837
|
+
timeEfficiency,
|
|
8838
|
+
topProducts,
|
|
8839
|
+
recentActivity
|
|
8840
|
+
};
|
|
8841
|
+
}
|
|
8842
|
+
};
|
|
8843
|
+
|
|
8844
|
+
// src/admin/analytics/analytics.admin.service.ts
|
|
8845
|
+
var AnalyticsAdminService = class {
|
|
8846
|
+
/**
|
|
8847
|
+
* Creates a new AnalyticsAdminService instance
|
|
8848
|
+
*
|
|
8849
|
+
* @param firestore - Admin Firestore instance (optional, defaults to admin.firestore())
|
|
8850
|
+
*/
|
|
8851
|
+
constructor(firestore19) {
|
|
8852
|
+
this.db = firestore19 || admin14.firestore();
|
|
8853
|
+
const mockApp = {
|
|
8854
|
+
name: "[DEFAULT]",
|
|
8855
|
+
options: {},
|
|
8856
|
+
automaticDataCollectionEnabled: false
|
|
8857
|
+
};
|
|
8858
|
+
const mockAuth = {};
|
|
8859
|
+
const appointmentService = this.createAppointmentServiceAdapter();
|
|
8860
|
+
this.analyticsService = new AnalyticsService(
|
|
8861
|
+
this.db,
|
|
8862
|
+
// Cast admin Firestore to client Firestore type
|
|
8863
|
+
mockAuth,
|
|
8864
|
+
mockApp,
|
|
8865
|
+
appointmentService
|
|
8866
|
+
);
|
|
8867
|
+
}
|
|
8868
|
+
/**
|
|
8869
|
+
* Creates an adapter for AppointmentService to work with admin SDK
|
|
8870
|
+
*/
|
|
8871
|
+
createAppointmentServiceAdapter() {
|
|
8872
|
+
return {
|
|
8873
|
+
searchAppointments: async (params) => {
|
|
8874
|
+
let query2 = this.db.collection(APPOINTMENTS_COLLECTION);
|
|
8875
|
+
if (params.clinicBranchId) {
|
|
8876
|
+
query2 = query2.where("clinicBranchId", "==", params.clinicBranchId);
|
|
8877
|
+
}
|
|
8878
|
+
if (params.practitionerId) {
|
|
8879
|
+
query2 = query2.where("practitionerId", "==", params.practitionerId);
|
|
8880
|
+
}
|
|
8881
|
+
if (params.procedureId) {
|
|
8882
|
+
query2 = query2.where("procedureId", "==", params.procedureId);
|
|
8883
|
+
}
|
|
8884
|
+
if (params.patientId) {
|
|
8885
|
+
query2 = query2.where("patientId", "==", params.patientId);
|
|
8886
|
+
}
|
|
8887
|
+
if (params.startDate) {
|
|
8888
|
+
const startDate = params.startDate instanceof Date ? params.startDate : params.startDate.toDate();
|
|
8889
|
+
const startTimestamp = admin14.firestore.Timestamp.fromDate(startDate);
|
|
8890
|
+
query2 = query2.where("appointmentStartTime", ">=", startTimestamp);
|
|
8891
|
+
}
|
|
8892
|
+
if (params.endDate) {
|
|
8893
|
+
const endDate = params.endDate instanceof Date ? params.endDate : params.endDate.toDate();
|
|
8894
|
+
const endTimestamp = admin14.firestore.Timestamp.fromDate(endDate);
|
|
8895
|
+
query2 = query2.where("appointmentStartTime", "<=", endTimestamp);
|
|
8896
|
+
}
|
|
8897
|
+
const snapshot = await query2.get();
|
|
8898
|
+
const appointments = snapshot.docs.map((doc2) => ({
|
|
8899
|
+
id: doc2.id,
|
|
8900
|
+
...doc2.data()
|
|
8901
|
+
}));
|
|
8902
|
+
return {
|
|
8903
|
+
appointments,
|
|
8904
|
+
total: appointments.length
|
|
8905
|
+
};
|
|
8906
|
+
}
|
|
8907
|
+
};
|
|
8908
|
+
}
|
|
8909
|
+
// Delegate all methods to the underlying AnalyticsService
|
|
8910
|
+
// We expose them here so they can be called with admin SDK context
|
|
8911
|
+
async getPractitionerAnalytics(practitionerId, dateRange, options) {
|
|
8912
|
+
return this.analyticsService.getPractitionerAnalytics(practitionerId, dateRange, options);
|
|
8913
|
+
}
|
|
8914
|
+
async getProcedureAnalytics(procedureId, dateRange, options) {
|
|
8915
|
+
return this.analyticsService.getProcedureAnalytics(procedureId, dateRange, options);
|
|
8916
|
+
}
|
|
8917
|
+
async getTimeEfficiencyMetrics(filters, dateRange, options) {
|
|
8918
|
+
return this.analyticsService.getTimeEfficiencyMetrics(filters, dateRange, options);
|
|
8919
|
+
}
|
|
8920
|
+
async getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters) {
|
|
8921
|
+
return this.analyticsService.getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters);
|
|
8922
|
+
}
|
|
8923
|
+
async getCancellationMetrics(groupBy, dateRange, options) {
|
|
8924
|
+
return this.analyticsService.getCancellationMetrics(groupBy, dateRange, options);
|
|
8925
|
+
}
|
|
8926
|
+
async getNoShowMetrics(groupBy, dateRange, options) {
|
|
8927
|
+
return this.analyticsService.getNoShowMetrics(groupBy, dateRange, options);
|
|
8928
|
+
}
|
|
8929
|
+
async getRevenueMetrics(filters, dateRange, options) {
|
|
8930
|
+
return this.analyticsService.getRevenueMetrics(filters, dateRange, options);
|
|
8931
|
+
}
|
|
8932
|
+
async getRevenueMetricsByEntity(groupBy, dateRange, filters) {
|
|
8933
|
+
return this.analyticsService.getRevenueMetricsByEntity(groupBy, dateRange, filters);
|
|
8934
|
+
}
|
|
8935
|
+
async getProductUsageMetrics(productId, dateRange) {
|
|
8936
|
+
return this.analyticsService.getProductUsageMetrics(productId, dateRange);
|
|
8937
|
+
}
|
|
8938
|
+
async getProductUsageMetricsByEntity(groupBy, dateRange, filters) {
|
|
8939
|
+
return this.analyticsService.getProductUsageMetricsByEntity(groupBy, dateRange, filters);
|
|
8940
|
+
}
|
|
8941
|
+
async getPatientAnalytics(patientId, dateRange) {
|
|
8942
|
+
return this.analyticsService.getPatientAnalytics(patientId, dateRange);
|
|
8943
|
+
}
|
|
8944
|
+
async getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters) {
|
|
8945
|
+
return this.analyticsService.getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters);
|
|
8946
|
+
}
|
|
8947
|
+
async getClinicAnalytics(clinicBranchId, dateRange) {
|
|
8948
|
+
const dashboard = await this.analyticsService.getDashboardData(
|
|
8949
|
+
{ clinicBranchId },
|
|
8950
|
+
dateRange
|
|
8951
|
+
);
|
|
8952
|
+
const clinicDoc = await this.db.collection("clinics").doc(clinicBranchId).get();
|
|
8953
|
+
const clinicData = clinicDoc.data();
|
|
8954
|
+
const clinicName = (clinicData == null ? void 0 : clinicData.name) || "Unknown";
|
|
8955
|
+
return {
|
|
8956
|
+
clinicBranchId,
|
|
8957
|
+
clinicName,
|
|
8958
|
+
totalAppointments: dashboard.overview.totalAppointments,
|
|
8959
|
+
completedAppointments: dashboard.overview.completedAppointments,
|
|
8960
|
+
canceledAppointments: dashboard.overview.canceledAppointments,
|
|
8961
|
+
noShowAppointments: dashboard.overview.noShowAppointments,
|
|
8962
|
+
cancellationRate: dashboard.overview.cancellationRate,
|
|
8963
|
+
noShowRate: dashboard.overview.noShowRate,
|
|
8964
|
+
totalRevenue: dashboard.overview.totalRevenue,
|
|
8965
|
+
averageRevenuePerAppointment: dashboard.overview.averageRevenuePerAppointment,
|
|
8966
|
+
currency: dashboard.overview.currency,
|
|
8967
|
+
practitionerCount: dashboard.overview.uniquePractitioners,
|
|
8968
|
+
patientCount: dashboard.overview.uniquePatients,
|
|
8969
|
+
procedureCount: dashboard.overview.uniqueProcedures,
|
|
8970
|
+
topPractitioners: dashboard.practitionerMetrics.slice(0, 5).map((p) => ({
|
|
8971
|
+
practitionerId: p.practitionerId,
|
|
8972
|
+
practitionerName: p.practitionerName,
|
|
8973
|
+
appointmentCount: p.totalAppointments,
|
|
8974
|
+
revenue: p.totalRevenue
|
|
8975
|
+
})),
|
|
8976
|
+
topProcedures: dashboard.procedureMetrics.slice(0, 5).map((p) => ({
|
|
8977
|
+
procedureId: p.procedureId,
|
|
8978
|
+
procedureName: p.procedureName,
|
|
8979
|
+
appointmentCount: p.totalAppointments,
|
|
8980
|
+
revenue: p.totalRevenue
|
|
8981
|
+
}))
|
|
8982
|
+
};
|
|
8983
|
+
}
|
|
8984
|
+
async getDashboardData(filters, dateRange, options) {
|
|
8985
|
+
return this.analyticsService.getDashboardData(filters, dateRange, options);
|
|
8986
|
+
}
|
|
8987
|
+
/**
|
|
8988
|
+
* Expose fetchAppointments for direct access if needed
|
|
8989
|
+
* This method is used internally by AnalyticsService
|
|
8990
|
+
*/
|
|
8991
|
+
async fetchAppointments(filters, dateRange) {
|
|
8992
|
+
return this.analyticsService.fetchAppointments(filters, dateRange);
|
|
8993
|
+
}
|
|
8994
|
+
};
|
|
8995
|
+
|
|
8996
|
+
// src/admin/booking/booking.calculator.ts
|
|
8997
|
+
var import_firestore4 = require("firebase/firestore");
|
|
6830
8998
|
var import_luxon2 = require("luxon");
|
|
6831
8999
|
var BookingAvailabilityCalculator = class {
|
|
6832
9000
|
/**
|
|
@@ -6952,8 +9120,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
6952
9120
|
const intervalStart = workStart < import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
|
|
6953
9121
|
const intervalEnd = workEnd > import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
|
|
6954
9122
|
workingIntervals.push({
|
|
6955
|
-
start:
|
|
6956
|
-
end:
|
|
9123
|
+
start: import_firestore4.Timestamp.fromMillis(intervalStart.toMillis()),
|
|
9124
|
+
end: import_firestore4.Timestamp.fromMillis(intervalEnd.toMillis())
|
|
6957
9125
|
});
|
|
6958
9126
|
if (daySchedule.breaks && daySchedule.breaks.length > 0) {
|
|
6959
9127
|
for (const breakTime of daySchedule.breaks) {
|
|
@@ -6973,8 +9141,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
6973
9141
|
...this.subtractInterval(
|
|
6974
9142
|
workingIntervals[workingIntervals.length - 1],
|
|
6975
9143
|
{
|
|
6976
|
-
start:
|
|
6977
|
-
end:
|
|
9144
|
+
start: import_firestore4.Timestamp.fromMillis(breakStart.toMillis()),
|
|
9145
|
+
end: import_firestore4.Timestamp.fromMillis(breakEnd.toMillis())
|
|
6978
9146
|
}
|
|
6979
9147
|
)
|
|
6980
9148
|
);
|
|
@@ -7084,8 +9252,8 @@ var BookingAvailabilityCalculator = class {
|
|
|
7084
9252
|
const intervalStart = workStart < import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
|
|
7085
9253
|
const intervalEnd = workEnd > import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
|
|
7086
9254
|
workingIntervals.push({
|
|
7087
|
-
start:
|
|
7088
|
-
end:
|
|
9255
|
+
start: import_firestore4.Timestamp.fromMillis(intervalStart.toMillis()),
|
|
9256
|
+
end: import_firestore4.Timestamp.fromMillis(intervalEnd.toMillis())
|
|
7089
9257
|
});
|
|
7090
9258
|
}
|
|
7091
9259
|
}
|
|
@@ -7165,7 +9333,7 @@ var BookingAvailabilityCalculator = class {
|
|
|
7165
9333
|
const isInFuture = slotStart >= earliestBookableTime;
|
|
7166
9334
|
if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
|
|
7167
9335
|
slots.push({
|
|
7168
|
-
start:
|
|
9336
|
+
start: import_firestore4.Timestamp.fromMillis(slotStart.toMillis())
|
|
7169
9337
|
});
|
|
7170
9338
|
}
|
|
7171
9339
|
slotStart = slotStart.plus({ minutes: intervalMinutes });
|
|
@@ -7284,13 +9452,13 @@ var BookingAvailabilityCalculator = class {
|
|
|
7284
9452
|
BookingAvailabilityCalculator.DEFAULT_INTERVAL_MINUTES = 15;
|
|
7285
9453
|
|
|
7286
9454
|
// src/admin/booking/booking.admin.ts
|
|
7287
|
-
var
|
|
9455
|
+
var admin16 = __toESM(require("firebase-admin"));
|
|
7288
9456
|
|
|
7289
9457
|
// src/admin/documentation-templates/document-manager.admin.ts
|
|
7290
|
-
var
|
|
9458
|
+
var admin15 = __toESM(require("firebase-admin"));
|
|
7291
9459
|
var DocumentManagerAdminService = class {
|
|
7292
|
-
constructor(
|
|
7293
|
-
this.db =
|
|
9460
|
+
constructor(firestore19) {
|
|
9461
|
+
this.db = firestore19;
|
|
7294
9462
|
}
|
|
7295
9463
|
/**
|
|
7296
9464
|
* Adds operations to a Firestore batch to initialize all linked forms for a new appointment
|
|
@@ -7393,10 +9561,10 @@ var DocumentManagerAdminService = class {
|
|
|
7393
9561
|
};
|
|
7394
9562
|
}
|
|
7395
9563
|
const templateIds = technologyTemplates.map((t) => t.templateId);
|
|
7396
|
-
const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(
|
|
9564
|
+
const templatesSnapshot = await this.db.collection(DOCUMENTATION_TEMPLATES_COLLECTION).where(admin15.firestore.FieldPath.documentId(), "in", templateIds).get();
|
|
7397
9565
|
const templatesMap = /* @__PURE__ */ new Map();
|
|
7398
|
-
templatesSnapshot.forEach((
|
|
7399
|
-
templatesMap.set(
|
|
9566
|
+
templatesSnapshot.forEach((doc2) => {
|
|
9567
|
+
templatesMap.set(doc2.id, doc2.data());
|
|
7400
9568
|
});
|
|
7401
9569
|
for (const templateRef of technologyTemplates) {
|
|
7402
9570
|
const template = templatesMap.get(templateRef.templateId);
|
|
@@ -7459,8 +9627,8 @@ var BookingAdmin = class {
|
|
|
7459
9627
|
* Creates a new BookingAdmin instance
|
|
7460
9628
|
* @param firestore - Firestore instance provided by the caller
|
|
7461
9629
|
*/
|
|
7462
|
-
constructor(
|
|
7463
|
-
this.db =
|
|
9630
|
+
constructor(firestore19) {
|
|
9631
|
+
this.db = firestore19 || admin16.firestore();
|
|
7464
9632
|
this.documentManagerAdmin = new DocumentManagerAdminService(this.db);
|
|
7465
9633
|
}
|
|
7466
9634
|
/**
|
|
@@ -7482,8 +9650,8 @@ var BookingAdmin = class {
|
|
|
7482
9650
|
timeframeStart: timeframe.start instanceof Date ? timeframe.start.toISOString() : timeframe.start.toDate().toISOString(),
|
|
7483
9651
|
timeframeEnd: timeframe.end instanceof Date ? timeframe.end.toISOString() : timeframe.end.toDate().toISOString()
|
|
7484
9652
|
});
|
|
7485
|
-
const start = timeframe.start instanceof Date ?
|
|
7486
|
-
const end = timeframe.end instanceof Date ?
|
|
9653
|
+
const start = timeframe.start instanceof Date ? admin16.firestore.Timestamp.fromDate(timeframe.start) : timeframe.start;
|
|
9654
|
+
const end = timeframe.end instanceof Date ? admin16.firestore.Timestamp.fromDate(timeframe.end) : timeframe.end;
|
|
7487
9655
|
Logger.debug("[BookingAdmin] Fetching clinic data", { clinicId });
|
|
7488
9656
|
const clinicDoc = await this.db.collection("clinics").doc(clinicId).get();
|
|
7489
9657
|
if (!clinicDoc.exists) {
|
|
@@ -7569,7 +9737,7 @@ var BookingAdmin = class {
|
|
|
7569
9737
|
const result = BookingAvailabilityCalculator.calculateSlots(request);
|
|
7570
9738
|
const availableSlotsResult = {
|
|
7571
9739
|
availableSlots: result.availableSlots.map((slot) => ({
|
|
7572
|
-
start:
|
|
9740
|
+
start: admin16.firestore.Timestamp.fromMillis(slot.start.toMillis())
|
|
7573
9741
|
}))
|
|
7574
9742
|
};
|
|
7575
9743
|
Logger.info(
|
|
@@ -7632,14 +9800,14 @@ var BookingAdmin = class {
|
|
|
7632
9800
|
endTime: end.toDate().toISOString()
|
|
7633
9801
|
});
|
|
7634
9802
|
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
|
|
7635
|
-
const queryStart =
|
|
9803
|
+
const queryStart = admin16.firestore.Timestamp.fromMillis(
|
|
7636
9804
|
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
7637
9805
|
);
|
|
7638
9806
|
const eventsRef = this.db.collection(`clinics/${clinicId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
7639
9807
|
const snapshot = await eventsRef.get();
|
|
7640
|
-
const events = snapshot.docs.map((
|
|
7641
|
-
...
|
|
7642
|
-
id:
|
|
9808
|
+
const events = snapshot.docs.map((doc2) => ({
|
|
9809
|
+
...doc2.data(),
|
|
9810
|
+
id: doc2.id
|
|
7643
9811
|
})).filter((event) => {
|
|
7644
9812
|
return event.eventTime.end.toMillis() > start.toMillis();
|
|
7645
9813
|
});
|
|
@@ -7678,14 +9846,14 @@ var BookingAdmin = class {
|
|
|
7678
9846
|
endTime: end.toDate().toISOString()
|
|
7679
9847
|
});
|
|
7680
9848
|
const MAX_EVENT_DURATION_MS = 24 * 60 * 60 * 1e3;
|
|
7681
|
-
const queryStart =
|
|
9849
|
+
const queryStart = admin16.firestore.Timestamp.fromMillis(
|
|
7682
9850
|
start.toMillis() - MAX_EVENT_DURATION_MS
|
|
7683
9851
|
);
|
|
7684
9852
|
const eventsRef = this.db.collection(`practitioners/${practitionerId}/calendar`).where("eventTime.start", ">=", queryStart).where("eventTime.start", "<", end).orderBy("eventTime.start");
|
|
7685
9853
|
const snapshot = await eventsRef.get();
|
|
7686
|
-
const events = snapshot.docs.map((
|
|
7687
|
-
...
|
|
7688
|
-
id:
|
|
9854
|
+
const events = snapshot.docs.map((doc2) => ({
|
|
9855
|
+
...doc2.data(),
|
|
9856
|
+
id: doc2.id
|
|
7689
9857
|
})).filter((event) => {
|
|
7690
9858
|
return event.eventTime.end.toMillis() > start.toMillis();
|
|
7691
9859
|
});
|
|
@@ -7747,8 +9915,8 @@ var BookingAdmin = class {
|
|
|
7747
9915
|
`[BookingAdmin] Orchestrating appointment creation for patient ${data.patientId} by user ${authenticatedUserId}`
|
|
7748
9916
|
);
|
|
7749
9917
|
const batch = this.db.batch();
|
|
7750
|
-
const adminTsNow =
|
|
7751
|
-
const serverTimestampValue =
|
|
9918
|
+
const adminTsNow = admin16.firestore.Timestamp.now();
|
|
9919
|
+
const serverTimestampValue = admin16.firestore.FieldValue.serverTimestamp();
|
|
7752
9920
|
try {
|
|
7753
9921
|
if (!data.patientId || !data.procedureId || !data.appointmentStartTime || !data.appointmentEndTime) {
|
|
7754
9922
|
return {
|
|
@@ -7845,7 +10013,7 @@ var BookingAdmin = class {
|
|
|
7845
10013
|
fullName: `${(patientSensitiveData == null ? void 0 : patientSensitiveData.firstName) || ""} ${(patientSensitiveData == null ? void 0 : patientSensitiveData.lastName) || ""}`.trim() || patientProfileData.displayName,
|
|
7846
10014
|
email: (patientSensitiveData == null ? void 0 : patientSensitiveData.email) || "",
|
|
7847
10015
|
phone: (patientSensitiveData == null ? void 0 : patientSensitiveData.phoneNumber) || patientProfileData.phoneNumber || null,
|
|
7848
|
-
dateOfBirth: (patientSensitiveData == null ? void 0 : patientSensitiveData.dateOfBirth) || patientProfileData.dateOfBirth ||
|
|
10016
|
+
dateOfBirth: (patientSensitiveData == null ? void 0 : patientSensitiveData.dateOfBirth) || patientProfileData.dateOfBirth || admin16.firestore.Timestamp.now(),
|
|
7849
10017
|
gender: (patientSensitiveData == null ? void 0 : patientSensitiveData.gender) || "other" /* OTHER */
|
|
7850
10018
|
};
|
|
7851
10019
|
const newAppointmentId = this.db.collection(APPOINTMENTS_COLLECTION).doc().id;
|
|
@@ -8110,7 +10278,7 @@ var BookingAdmin = class {
|
|
|
8110
10278
|
};
|
|
8111
10279
|
|
|
8112
10280
|
// src/admin/free-consultation/free-consultation-utils.admin.ts
|
|
8113
|
-
var
|
|
10281
|
+
var admin17 = __toESM(require("firebase-admin"));
|
|
8114
10282
|
|
|
8115
10283
|
// src/backoffice/types/category.types.ts
|
|
8116
10284
|
var CATEGORIES_COLLECTION = "backoffice_categories";
|
|
@@ -8123,10 +10291,10 @@ var TECHNOLOGIES_COLLECTION = "technologies";
|
|
|
8123
10291
|
|
|
8124
10292
|
// src/admin/free-consultation/free-consultation-utils.admin.ts
|
|
8125
10293
|
async function freeConsultationInfrastructure(db) {
|
|
8126
|
-
const
|
|
10294
|
+
const firestore19 = db || admin17.firestore();
|
|
8127
10295
|
try {
|
|
8128
10296
|
console.log("[freeConsultationInfrastructure] Checking free consultation infrastructure...");
|
|
8129
|
-
const technologyRef =
|
|
10297
|
+
const technologyRef = firestore19.collection(TECHNOLOGIES_COLLECTION).doc("free-consultation-tech");
|
|
8130
10298
|
const technologyDoc = await technologyRef.get();
|
|
8131
10299
|
if (technologyDoc.exists) {
|
|
8132
10300
|
console.log(
|
|
@@ -8135,7 +10303,7 @@ async function freeConsultationInfrastructure(db) {
|
|
|
8135
10303
|
return true;
|
|
8136
10304
|
}
|
|
8137
10305
|
console.log("[freeConsultationInfrastructure] Creating free consultation infrastructure...");
|
|
8138
|
-
await createFreeConsultationInfrastructure(
|
|
10306
|
+
await createFreeConsultationInfrastructure(firestore19);
|
|
8139
10307
|
console.log(
|
|
8140
10308
|
"[freeConsultationInfrastructure] Successfully created free consultation infrastructure"
|
|
8141
10309
|
);
|
|
@@ -8332,8 +10500,8 @@ var PractitionerInviteMailingService = class extends BaseMailingService {
|
|
|
8332
10500
|
* @param firestore Firestore instance provided by the caller
|
|
8333
10501
|
* @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
|
|
8334
10502
|
*/
|
|
8335
|
-
constructor(
|
|
8336
|
-
super(
|
|
10503
|
+
constructor(firestore19, mailgunClient) {
|
|
10504
|
+
super(firestore19, mailgunClient);
|
|
8337
10505
|
this.DEFAULT_REGISTRATION_URL = "https://metaesthetics.net/register";
|
|
8338
10506
|
this.DEFAULT_SUBJECT = "You've Been Invited to Join as a Practitioner";
|
|
8339
10507
|
this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
|
|
@@ -9196,8 +11364,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
|
|
|
9196
11364
|
* @param firestore Firestore instance provided by the caller
|
|
9197
11365
|
* @param mailgunClient Mailgun client instance (mailgun.js v10+) provided by the caller
|
|
9198
11366
|
*/
|
|
9199
|
-
constructor(
|
|
9200
|
-
super(
|
|
11367
|
+
constructor(firestore19, mailgunClient) {
|
|
11368
|
+
super(firestore19, mailgunClient);
|
|
9201
11369
|
this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
|
|
9202
11370
|
this.DEFAULT_FROM_ADDRESS = "MetaEstetics <no-reply@mg.metaesthetics.net>";
|
|
9203
11371
|
}
|
|
@@ -9540,8 +11708,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
|
|
|
9540
11708
|
*/
|
|
9541
11709
|
async fetchPractitionerById(practitionerId) {
|
|
9542
11710
|
try {
|
|
9543
|
-
const
|
|
9544
|
-
return
|
|
11711
|
+
const doc2 = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
|
|
11712
|
+
return doc2.exists ? doc2.data() : null;
|
|
9545
11713
|
} catch (error) {
|
|
9546
11714
|
Logger.error(
|
|
9547
11715
|
"[ExistingPractitionerInviteMailingService] Error fetching practitioner:",
|
|
@@ -9557,8 +11725,8 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
|
|
|
9557
11725
|
*/
|
|
9558
11726
|
async fetchClinicById(clinicId) {
|
|
9559
11727
|
try {
|
|
9560
|
-
const
|
|
9561
|
-
return
|
|
11728
|
+
const doc2 = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
|
|
11729
|
+
return doc2.exists ? doc2.data() : null;
|
|
9562
11730
|
} catch (error) {
|
|
9563
11731
|
Logger.error(
|
|
9564
11732
|
"[ExistingPractitionerInviteMailingService] Error fetching clinic:",
|
|
@@ -9570,14 +11738,14 @@ var ExistingPractitionerInviteMailingService = class extends BaseMailingService
|
|
|
9570
11738
|
};
|
|
9571
11739
|
|
|
9572
11740
|
// src/admin/users/user-profile.admin.ts
|
|
9573
|
-
var
|
|
11741
|
+
var admin18 = __toESM(require("firebase-admin"));
|
|
9574
11742
|
var UserProfileAdminService = class {
|
|
9575
11743
|
/**
|
|
9576
11744
|
* Constructor for UserProfileAdminService
|
|
9577
11745
|
* @param firestore Optional Firestore instance. If not provided, uses the default admin SDK instance.
|
|
9578
11746
|
*/
|
|
9579
|
-
constructor(
|
|
9580
|
-
this.db =
|
|
11747
|
+
constructor(firestore19) {
|
|
11748
|
+
this.db = firestore19 || admin18.firestore();
|
|
9581
11749
|
}
|
|
9582
11750
|
/**
|
|
9583
11751
|
* Creates a blank user profile with minimal information
|
|
@@ -9594,9 +11762,9 @@ var UserProfileAdminService = class {
|
|
|
9594
11762
|
roles: [],
|
|
9595
11763
|
// Empty roles array as requested
|
|
9596
11764
|
isAnonymous: authUserData.isAnonymous,
|
|
9597
|
-
createdAt:
|
|
9598
|
-
updatedAt:
|
|
9599
|
-
lastLoginAt:
|
|
11765
|
+
createdAt: admin18.firestore.FieldValue.serverTimestamp(),
|
|
11766
|
+
updatedAt: admin18.firestore.FieldValue.serverTimestamp(),
|
|
11767
|
+
lastLoginAt: admin18.firestore.FieldValue.serverTimestamp()
|
|
9600
11768
|
};
|
|
9601
11769
|
try {
|
|
9602
11770
|
const userRef = this.db.collection(USERS_COLLECTION).doc(authUserData.uid);
|
|
@@ -9672,8 +11840,8 @@ var UserProfileAdminService = class {
|
|
|
9672
11840
|
clinics: mergedProfileData.clinics || [],
|
|
9673
11841
|
doctorIds: mergedProfileData.doctorIds || [],
|
|
9674
11842
|
clinicIds: mergedProfileData.clinicIds || [],
|
|
9675
|
-
createdAt:
|
|
9676
|
-
updatedAt:
|
|
11843
|
+
createdAt: admin18.firestore.FieldValue.serverTimestamp(),
|
|
11844
|
+
updatedAt: admin18.firestore.FieldValue.serverTimestamp()
|
|
9677
11845
|
};
|
|
9678
11846
|
await patientProfileRef.set(patientProfileData);
|
|
9679
11847
|
patientProfile = {
|
|
@@ -9712,8 +11880,8 @@ var UserProfileAdminService = class {
|
|
|
9712
11880
|
};
|
|
9713
11881
|
const sensitiveInfoData = {
|
|
9714
11882
|
...mergedSensitiveData,
|
|
9715
|
-
createdAt:
|
|
9716
|
-
updatedAt:
|
|
11883
|
+
createdAt: admin18.firestore.FieldValue.serverTimestamp(),
|
|
11884
|
+
updatedAt: admin18.firestore.FieldValue.serverTimestamp()
|
|
9717
11885
|
// Leave dateOfBirth as is
|
|
9718
11886
|
};
|
|
9719
11887
|
await sensitiveInfoRef.set(sensitiveInfoData);
|
|
@@ -9739,7 +11907,7 @@ var UserProfileAdminService = class {
|
|
|
9739
11907
|
contraindications: [],
|
|
9740
11908
|
allergies: [],
|
|
9741
11909
|
currentMedications: [],
|
|
9742
|
-
lastUpdated:
|
|
11910
|
+
lastUpdated: admin18.firestore.FieldValue.serverTimestamp(),
|
|
9743
11911
|
updatedBy: userId
|
|
9744
11912
|
};
|
|
9745
11913
|
await medicalInfoRef.set(medicalInfoData);
|
|
@@ -9756,14 +11924,14 @@ var UserProfileAdminService = class {
|
|
|
9756
11924
|
const batch = this.db.batch();
|
|
9757
11925
|
if (!userData.roles.includes("patient" /* PATIENT */)) {
|
|
9758
11926
|
batch.update(userRef, {
|
|
9759
|
-
roles:
|
|
9760
|
-
updatedAt:
|
|
11927
|
+
roles: admin18.firestore.FieldValue.arrayUnion("patient" /* PATIENT */),
|
|
11928
|
+
updatedAt: admin18.firestore.FieldValue.serverTimestamp()
|
|
9761
11929
|
});
|
|
9762
11930
|
}
|
|
9763
11931
|
if (!userData.patientProfile) {
|
|
9764
11932
|
batch.update(userRef, {
|
|
9765
11933
|
patientProfile: patientProfileId,
|
|
9766
|
-
updatedAt:
|
|
11934
|
+
updatedAt: admin18.firestore.FieldValue.serverTimestamp()
|
|
9767
11935
|
});
|
|
9768
11936
|
}
|
|
9769
11937
|
await batch.commit();
|
|
@@ -9804,8 +11972,8 @@ var UserProfileAdminService = class {
|
|
|
9804
11972
|
const userData = userDoc.data();
|
|
9805
11973
|
if (!userData.roles.includes("clinic_admin" /* CLINIC_ADMIN */)) {
|
|
9806
11974
|
await userRef.update({
|
|
9807
|
-
roles:
|
|
9808
|
-
updatedAt:
|
|
11975
|
+
roles: admin18.firestore.FieldValue.arrayUnion("clinic_admin" /* CLINIC_ADMIN */),
|
|
11976
|
+
updatedAt: admin18.firestore.FieldValue.serverTimestamp()
|
|
9809
11977
|
});
|
|
9810
11978
|
}
|
|
9811
11979
|
const updatedUserDoc = await userRef.get();
|
|
@@ -9836,8 +12004,8 @@ var UserProfileAdminService = class {
|
|
|
9836
12004
|
const userData = userDoc.data();
|
|
9837
12005
|
if (!userData.roles.includes("practitioner" /* PRACTITIONER */)) {
|
|
9838
12006
|
await userRef.update({
|
|
9839
|
-
roles:
|
|
9840
|
-
updatedAt:
|
|
12007
|
+
roles: admin18.firestore.FieldValue.arrayUnion("practitioner" /* PRACTITIONER */),
|
|
12008
|
+
updatedAt: admin18.firestore.FieldValue.serverTimestamp()
|
|
9841
12009
|
});
|
|
9842
12010
|
}
|
|
9843
12011
|
const updatedUserDoc = await userRef.get();
|
|
@@ -9857,6 +12025,8 @@ console.log("[Admin Module] Initialized and services exported.");
|
|
|
9857
12025
|
TimestampUtils.enableServerMode();
|
|
9858
12026
|
// Annotate the CommonJS export names for ESM import in node:
|
|
9859
12027
|
0 && (module.exports = {
|
|
12028
|
+
ANALYTICS_COLLECTION,
|
|
12029
|
+
AnalyticsAdminService,
|
|
9860
12030
|
AppointmentAggregationService,
|
|
9861
12031
|
AppointmentMailingService,
|
|
9862
12032
|
AppointmentStatus,
|
|
@@ -9864,19 +12034,26 @@ TimestampUtils.enableServerMode();
|
|
|
9864
12034
|
BillingTransactionType,
|
|
9865
12035
|
BookingAdmin,
|
|
9866
12036
|
BookingAvailabilityCalculator,
|
|
12037
|
+
CANCELLATION_ANALYTICS_SUBCOLLECTION,
|
|
12038
|
+
CLINICS_COLLECTION,
|
|
12039
|
+
CLINIC_ANALYTICS_SUBCOLLECTION,
|
|
9867
12040
|
CalendarAdminService,
|
|
9868
12041
|
ClinicAggregationService,
|
|
12042
|
+
DASHBOARD_ANALYTICS_SUBCOLLECTION,
|
|
9869
12043
|
DocumentManagerAdminService,
|
|
9870
12044
|
ExistingPractitionerInviteMailingService,
|
|
9871
12045
|
FilledFormsAggregationService,
|
|
9872
12046
|
Logger,
|
|
9873
12047
|
NOTIFICATIONS_COLLECTION,
|
|
12048
|
+
NO_SHOW_ANALYTICS_SUBCOLLECTION,
|
|
9874
12049
|
NotificationStatus,
|
|
9875
12050
|
NotificationType,
|
|
9876
12051
|
NotificationsAdmin,
|
|
9877
12052
|
PATIENTS_COLLECTION,
|
|
9878
12053
|
PATIENT_REQUIREMENTS_SUBCOLLECTION_NAME,
|
|
9879
12054
|
PATIENT_SENSITIVE_INFO_COLLECTION,
|
|
12055
|
+
PRACTITIONER_ANALYTICS_SUBCOLLECTION,
|
|
12056
|
+
PROCEDURE_ANALYTICS_SUBCOLLECTION,
|
|
9880
12057
|
PatientAggregationService,
|
|
9881
12058
|
PatientInstructionStatus,
|
|
9882
12059
|
PatientRequirementOverallStatus,
|
|
@@ -9887,8 +12064,10 @@ TimestampUtils.enableServerMode();
|
|
|
9887
12064
|
PractitionerInviteStatus,
|
|
9888
12065
|
PractitionerTokenStatus,
|
|
9889
12066
|
ProcedureAggregationService,
|
|
12067
|
+
REVENUE_ANALYTICS_SUBCOLLECTION,
|
|
9890
12068
|
ReviewsAggregationService,
|
|
9891
12069
|
SubscriptionStatus,
|
|
12070
|
+
TIME_EFFICIENCY_ANALYTICS_SUBCOLLECTION,
|
|
9892
12071
|
UserProfileAdminService,
|
|
9893
12072
|
UserRole,
|
|
9894
12073
|
freeConsultationInfrastructure
|