@blackcode_sa/metaestetics-api 1.12.67 → 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.
Files changed (47) hide show
  1. package/dist/admin/index.d.mts +801 -2
  2. package/dist/admin/index.d.ts +801 -2
  3. package/dist/admin/index.js +2332 -153
  4. package/dist/admin/index.mjs +2321 -153
  5. package/dist/backoffice/index.d.mts +40 -0
  6. package/dist/backoffice/index.d.ts +40 -0
  7. package/dist/backoffice/index.js +118 -18
  8. package/dist/backoffice/index.mjs +118 -20
  9. package/dist/index.d.mts +1097 -2
  10. package/dist/index.d.ts +1097 -2
  11. package/dist/index.js +4224 -2091
  12. package/dist/index.mjs +3941 -1821
  13. package/package.json +1 -1
  14. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +140 -0
  15. package/src/admin/analytics/analytics.admin.service.ts +278 -0
  16. package/src/admin/analytics/index.ts +2 -0
  17. package/src/admin/index.ts +6 -0
  18. package/src/backoffice/services/README.md +17 -0
  19. package/src/backoffice/services/analytics.service.proposal.md +863 -0
  20. package/src/backoffice/services/analytics.service.summary.md +143 -0
  21. package/src/backoffice/services/category.service.ts +49 -6
  22. package/src/backoffice/services/subcategory.service.ts +50 -6
  23. package/src/backoffice/services/technology.service.ts +53 -6
  24. package/src/services/analytics/ARCHITECTURE.md +199 -0
  25. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
  26. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
  27. package/src/services/analytics/QUICK_START.md +393 -0
  28. package/src/services/analytics/README.md +287 -0
  29. package/src/services/analytics/SUMMARY.md +141 -0
  30. package/src/services/analytics/USAGE_GUIDE.md +518 -0
  31. package/src/services/analytics/analytics-cloud.service.ts +222 -0
  32. package/src/services/analytics/analytics.service.ts +1632 -0
  33. package/src/services/analytics/index.ts +3 -0
  34. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
  35. package/src/services/analytics/utils/cost-calculation.utils.ts +154 -0
  36. package/src/services/analytics/utils/grouping.utils.ts +394 -0
  37. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
  38. package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
  39. package/src/services/appointment/appointment.service.ts +50 -6
  40. package/src/services/index.ts +1 -0
  41. package/src/services/procedure/procedure.service.ts +3 -3
  42. package/src/types/analytics/analytics.types.ts +500 -0
  43. package/src/types/analytics/grouped-analytics.types.ts +148 -0
  44. package/src/types/analytics/index.ts +4 -0
  45. package/src/types/analytics/stored-analytics.types.ts +137 -0
  46. package/src/types/index.ts +3 -0
  47. package/src/types/notifications/index.ts +21 -0
@@ -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(firestore18) {
684
+ constructor(firestore19) {
654
685
  this.expo = new import_expo_server_sdk.Expo();
655
- this.db = firestore18 || admin2.firestore();
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 doc = await this.db.collection("notifications").doc(id).get();
662
- return doc.exists ? { id: doc.id, ...doc.data() } : null;
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 (doc) => {
880
+ pendingNotifications.docs.map(async (doc2) => {
850
881
  const notification = {
851
- id: doc.id,
852
- ...doc.data()
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((doc) => {
894
- batch.delete(doc.ref);
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(firestore18) {
1165
- this.db = firestore18 || admin3.firestore();
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(firestore18) {
1494
- this.db = firestore18 || admin4.firestore();
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(firestore18, mailgunClient) {
1811
+ constructor(firestore19, mailgunClient) {
1781
1812
  var _a;
1782
- this.db = firestore18;
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(firestore18, mailgunClient) {
2325
- super(firestore18, mailgunClient);
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, firestore18) {
2555
- this.db = firestore18 || admin6.firestore();
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((doc) => {
3578
- const instance = doc.data();
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(doc.ref, {
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 ${doc.id} to batch. New status: ${newOverallStatus}`
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 doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).get();
3765
- return doc.exists ? doc.data() : null;
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 doc = await this.db.collection(PATIENTS_COLLECTION).doc(patientId).collection(PATIENT_SENSITIVE_INFO_COLLECTION).doc(patientId).get();
3779
- if (!doc.exists) {
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 doc.data();
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 doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
3801
- if (!doc.exists) {
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 doc.data();
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 doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
3823
- if (!doc.exists) {
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 doc.data();
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(firestore18) {
3974
- this.db = firestore18 || admin7.firestore();
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((doc) => {
4330
+ snapshot.docs.forEach((doc2) => {
4197
4331
  console.log(
4198
- `[ClinicAggregationService] Updating location for calendar event ${doc.ref.path}`
4332
+ `[ClinicAggregationService] Updating location for calendar event ${doc2.ref.path}`
4199
4333
  );
4200
- batch.update(doc.ref, {
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((doc) => {
4377
+ snapshot.docs.forEach((doc2) => {
4244
4378
  console.log(
4245
- `[ClinicAggregationService] Updating clinic info for calendar event ${doc.ref.path}`
4379
+ `[ClinicAggregationService] Updating clinic info for calendar event ${doc2.ref.path}`
4246
4380
  );
4247
- batch.update(doc.ref, {
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((doc) => {
4592
+ snapshot.docs.forEach((doc2) => {
4459
4593
  console.log(
4460
- `[ClinicAggregationService] Canceling calendar event ${doc.ref.path}`
4594
+ `[ClinicAggregationService] Canceling calendar event ${doc2.ref.path}`
4461
4595
  );
4462
- batch.update(doc.ref, {
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(firestore18) {
4490
- this.db = firestore18 || admin8.firestore();
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(firestore18) {
4698
- this.db = firestore18 || admin9.firestore();
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((doc) => {
4864
+ snapshot.docs.forEach((doc2) => {
4731
4865
  console.log(
4732
- `[PatientAggregationService] Updating patient info for calendar event ${doc.ref.path}`
4866
+ `[PatientAggregationService] Updating patient info for calendar event ${doc2.ref.path}`
4733
4867
  );
4734
- batch.update(doc.ref, {
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((doc) => {
4912
+ snapshot.docs.forEach((doc2) => {
4779
4913
  console.log(
4780
- `[PatientAggregationService] Canceling calendar event ${doc.ref.path}`
4914
+ `[PatientAggregationService] Canceling calendar event ${doc2.ref.path}`
4781
4915
  );
4782
- batch.update(doc.ref, {
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(firestore18) {
4807
- this.db = firestore18 || admin10.firestore();
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((doc) => {
5087
+ snapshot.docs.forEach((doc2) => {
4954
5088
  console.log(
4955
- `[PractitionerAggregationService] Updating practitioner info for calendar event ${doc.ref.path}`
5089
+ `[PractitionerAggregationService] Updating practitioner info for calendar event ${doc2.ref.path}`
4956
5090
  );
4957
- batch.update(doc.ref, {
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((doc) => {
5175
+ snapshot.docs.forEach((doc2) => {
5042
5176
  console.log(
5043
- `[PractitionerAggregationService] Canceling calendar event ${doc.ref.path}`
5177
+ `[PractitionerAggregationService] Canceling calendar event ${doc2.ref.path}`
5044
5178
  );
5045
- batch.update(doc.ref, {
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(firestore18, mailingService) {
5147
- this.db = firestore18 || admin11.firestore();
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 doc = await this.db.collection(CLINIC_ADMINS_COLLECTION).doc(adminId).get();
5598
- return doc.exists ? doc.data() : null;
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 doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
5615
- return doc.exists ? doc.data() : null;
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 doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
5632
- return doc.exists ? doc.data() : null;
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 admin18 = await this.fetchClinicAdminById(invite.invitedBy);
5654
- if (!admin18) {
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 = `${admin18.contactInfo.firstName} ${admin18.contactInfo.lastName}`;
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: admin18.contactInfo.email
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 admin18 = await this.fetchClinicAdminById(invite.invitedBy);
5745
- if (!admin18) {
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 = `${admin18.contactInfo.firstName} ${admin18.contactInfo.lastName}`;
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: admin18.contactInfo.email
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(firestore18) {
5830
- this.db = firestore18 || admin12.firestore();
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((doc) => {
6201
+ snapshot.docs.forEach((doc2) => {
6068
6202
  console.log(
6069
- `[ProcedureAggregationService] Updating procedure info for calendar event ${doc.ref.path}`
6203
+ `[ProcedureAggregationService] Updating procedure info for calendar event ${doc2.ref.path}`
6070
6204
  );
6071
- batch.update(doc.ref, {
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((doc) => {
6248
+ snapshot.docs.forEach((doc2) => {
6115
6249
  console.log(
6116
- `[ProcedureAggregationService] Canceling calendar event ${doc.ref.path}`
6250
+ `[ProcedureAggregationService] Canceling calendar event ${doc2.ref.path}`
6117
6251
  );
6118
- batch.update(doc.ref, {
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(firestore18) {
6349
- this.db = firestore18 || admin13.firestore();
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((doc) => doc.data());
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((doc) => doc.data());
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((doc) => doc.data());
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/booking/booking.calculator.ts
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: import_firestore2.Timestamp.fromMillis(intervalStart.toMillis()),
6956
- end: import_firestore2.Timestamp.fromMillis(intervalEnd.toMillis())
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: import_firestore2.Timestamp.fromMillis(breakStart.toMillis()),
6977
- end: import_firestore2.Timestamp.fromMillis(breakEnd.toMillis())
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: import_firestore2.Timestamp.fromMillis(intervalStart.toMillis()),
7088
- end: import_firestore2.Timestamp.fromMillis(intervalEnd.toMillis())
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: import_firestore2.Timestamp.fromMillis(slotStart.toMillis())
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 admin15 = __toESM(require("firebase-admin"));
9455
+ var admin16 = __toESM(require("firebase-admin"));
7288
9456
 
7289
9457
  // src/admin/documentation-templates/document-manager.admin.ts
7290
- var admin14 = __toESM(require("firebase-admin"));
9458
+ var admin15 = __toESM(require("firebase-admin"));
7291
9459
  var DocumentManagerAdminService = class {
7292
- constructor(firestore18) {
7293
- this.db = firestore18;
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(admin14.firestore.FieldPath.documentId(), "in", templateIds).get();
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((doc) => {
7399
- templatesMap.set(doc.id, doc.data());
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(firestore18) {
7463
- this.db = firestore18 || admin15.firestore();
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 ? admin15.firestore.Timestamp.fromDate(timeframe.start) : timeframe.start;
7486
- const end = timeframe.end instanceof Date ? admin15.firestore.Timestamp.fromDate(timeframe.end) : timeframe.end;
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: admin15.firestore.Timestamp.fromMillis(slot.start.toMillis())
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 = admin15.firestore.Timestamp.fromMillis(
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((doc) => ({
7641
- ...doc.data(),
7642
- id: doc.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 = admin15.firestore.Timestamp.fromMillis(
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((doc) => ({
7687
- ...doc.data(),
7688
- id: doc.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 = admin15.firestore.Timestamp.now();
7751
- const serverTimestampValue = admin15.firestore.FieldValue.serverTimestamp();
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 || admin15.firestore.Timestamp.now(),
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 admin16 = __toESM(require("firebase-admin"));
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 firestore18 = db || admin16.firestore();
10294
+ const firestore19 = db || admin17.firestore();
8127
10295
  try {
8128
10296
  console.log("[freeConsultationInfrastructure] Checking free consultation infrastructure...");
8129
- const technologyRef = firestore18.collection(TECHNOLOGIES_COLLECTION).doc("free-consultation-tech");
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(firestore18);
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(firestore18, mailgunClient) {
8336
- super(firestore18, mailgunClient);
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(firestore18, mailgunClient) {
9200
- super(firestore18, mailgunClient);
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 doc = await this.db.collection(PRACTITIONERS_COLLECTION).doc(practitionerId).get();
9544
- return doc.exists ? doc.data() : null;
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 doc = await this.db.collection(CLINICS_COLLECTION).doc(clinicId).get();
9561
- return doc.exists ? doc.data() : null;
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 admin17 = __toESM(require("firebase-admin"));
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(firestore18) {
9580
- this.db = firestore18 || admin17.firestore();
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: admin17.firestore.FieldValue.serverTimestamp(),
9598
- updatedAt: admin17.firestore.FieldValue.serverTimestamp(),
9599
- lastLoginAt: admin17.firestore.FieldValue.serverTimestamp()
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: admin17.firestore.FieldValue.serverTimestamp(),
9676
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
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: admin17.firestore.FieldValue.serverTimestamp(),
9716
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
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: admin17.firestore.FieldValue.serverTimestamp(),
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: admin17.firestore.FieldValue.arrayUnion("patient" /* PATIENT */),
9760
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
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: admin17.firestore.FieldValue.serverTimestamp()
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: admin17.firestore.FieldValue.arrayUnion("clinic_admin" /* CLINIC_ADMIN */),
9808
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
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: admin17.firestore.FieldValue.arrayUnion("practitioner" /* PRACTITIONER */),
9840
- updatedAt: admin17.firestore.FieldValue.serverTimestamp()
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