@blackcode_sa/metaestetics-api 1.12.68 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) 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/index.d.mts +1057 -2
  6. package/dist/index.d.ts +1057 -2
  7. package/dist/index.js +4150 -2117
  8. package/dist/index.mjs +3832 -1810
  9. package/package.json +1 -1
  10. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +140 -0
  11. package/src/admin/analytics/analytics.admin.service.ts +278 -0
  12. package/src/admin/analytics/index.ts +2 -0
  13. package/src/admin/index.ts +6 -0
  14. package/src/backoffice/services/README.md +17 -0
  15. package/src/backoffice/services/analytics.service.proposal.md +863 -0
  16. package/src/backoffice/services/analytics.service.summary.md +143 -0
  17. package/src/services/analytics/ARCHITECTURE.md +199 -0
  18. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -0
  19. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -0
  20. package/src/services/analytics/QUICK_START.md +393 -0
  21. package/src/services/analytics/README.md +287 -0
  22. package/src/services/analytics/SUMMARY.md +141 -0
  23. package/src/services/analytics/USAGE_GUIDE.md +518 -0
  24. package/src/services/analytics/analytics-cloud.service.ts +222 -0
  25. package/src/services/analytics/analytics.service.ts +1632 -0
  26. package/src/services/analytics/index.ts +3 -0
  27. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -0
  28. package/src/services/analytics/utils/cost-calculation.utils.ts +154 -0
  29. package/src/services/analytics/utils/grouping.utils.ts +394 -0
  30. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -0
  31. package/src/services/analytics/utils/time-calculation.utils.ts +186 -0
  32. package/src/services/appointment/appointment.service.ts +50 -6
  33. package/src/services/index.ts +1 -0
  34. package/src/types/analytics/analytics.types.ts +500 -0
  35. package/src/types/analytics/grouped-analytics.types.ts +148 -0
  36. package/src/types/analytics/index.ts +4 -0
  37. package/src/types/analytics/stored-analytics.types.ts +137 -0
  38. package/src/types/index.ts +3 -0
  39. package/src/types/notifications/index.ts +21 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.12.68",
4
+ "version": "1.13.0",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -37,6 +37,7 @@ import { AppointmentMailingService } from '../../mailing/appointment/appointment
37
37
  import { Logger } from '../../logger';
38
38
  import { UserRole } from '../../../types';
39
39
  import { CalendarEventStatus } from '../../../types/calendar';
40
+ import { NotificationType } from '../../../types/notifications';
40
41
 
41
42
  // Mailgun client will be injected via constructor
42
43
 
@@ -540,6 +541,13 @@ export class AppointmentAggregationService {
540
541
  await this.handleZonePhotosUpdate(before, after);
541
542
  }
542
543
 
544
+ // Handle Recommended Procedures Added
545
+ const recommendationsChanged = this.hasRecommendationsChanged(before, after);
546
+ if (recommendationsChanged) {
547
+ Logger.info(`[AggService] Recommended procedures changed for appointment ${after.id}`);
548
+ await this.handleRecommendedProceduresUpdate(before, after, patientProfile);
549
+ }
550
+
543
551
  // TODO: Handle Review Added
544
552
  // const reviewAdded = !before.reviewInfo && after.reviewInfo;
545
553
  // if (reviewAdded) { ... }
@@ -1841,4 +1849,136 @@ export class AppointmentAggregationService {
1841
1849
  // Don't throw - this is a side effect and shouldn't break the main update flow
1842
1850
  }
1843
1851
  }
1852
+
1853
+ /**
1854
+ * Checks if recommended procedures have changed between two appointment states
1855
+ * @param before - The appointment state before update
1856
+ * @param after - The appointment state after update
1857
+ * @returns True if recommendations have changed, false otherwise
1858
+ */
1859
+ private hasRecommendationsChanged(before: Appointment, after: Appointment): boolean {
1860
+ const beforeRecommendations = before.metadata?.recommendedProcedures || [];
1861
+ const afterRecommendations = after.metadata?.recommendedProcedures || [];
1862
+
1863
+ // If lengths differ, there's a change
1864
+ if (beforeRecommendations.length !== afterRecommendations.length) {
1865
+ return true;
1866
+ }
1867
+
1868
+ // Compare each recommendation (simple comparison - if any differ, return true)
1869
+ // For simplicity, we compare by procedure ID and note
1870
+ for (let i = 0; i < afterRecommendations.length; i++) {
1871
+ const beforeRec = beforeRecommendations[i];
1872
+ const afterRec = afterRecommendations[i];
1873
+
1874
+ if (!beforeRec || !afterRec) {
1875
+ return true;
1876
+ }
1877
+
1878
+ if (
1879
+ beforeRec.procedure.procedureId !== afterRec.procedure.procedureId ||
1880
+ beforeRec.note !== afterRec.note ||
1881
+ beforeRec.timeframe.value !== afterRec.timeframe.value ||
1882
+ beforeRec.timeframe.unit !== afterRec.timeframe.unit
1883
+ ) {
1884
+ return true;
1885
+ }
1886
+ }
1887
+
1888
+ return false;
1889
+ }
1890
+
1891
+ /**
1892
+ * Handles recommended procedures update - creates notifications for newly added recommendations
1893
+ * @param before - The appointment state before update
1894
+ * @param after - The appointment state after update
1895
+ * @param patientProfile - The patient profile (for expo tokens)
1896
+ */
1897
+ private async handleRecommendedProceduresUpdate(
1898
+ before: Appointment,
1899
+ after: Appointment,
1900
+ patientProfile: PatientProfile | null,
1901
+ ): Promise<void> {
1902
+ try {
1903
+ const beforeRecommendations = before.metadata?.recommendedProcedures || [];
1904
+ const afterRecommendations = after.metadata?.recommendedProcedures || [];
1905
+
1906
+ // Find newly added recommendations
1907
+ const newRecommendations = afterRecommendations.slice(beforeRecommendations.length);
1908
+
1909
+ if (newRecommendations.length === 0) {
1910
+ Logger.info(
1911
+ `[AggService] No new recommendations detected for appointment ${after.id}`,
1912
+ );
1913
+ return;
1914
+ }
1915
+
1916
+ Logger.info(
1917
+ `[AggService] Found ${newRecommendations.length} new recommendation(s) for appointment ${after.id}`,
1918
+ );
1919
+
1920
+ // Create notifications for each new recommendation
1921
+ for (let i = 0; i < newRecommendations.length; i++) {
1922
+ const recommendation = newRecommendations[i];
1923
+ const recommendationIndex = beforeRecommendations.length + i;
1924
+ const recommendationId = `${after.id}:${recommendationIndex}`;
1925
+
1926
+ // Format timeframe for display
1927
+ const timeframeText = `${recommendation.timeframe.value} ${recommendation.timeframe.unit}${recommendation.timeframe.value > 1 ? 's' : ''}`;
1928
+
1929
+ // Create notification
1930
+ const notificationPayload: Omit<
1931
+ any,
1932
+ 'id' | 'createdAt' | 'updatedAt' | 'status' | 'isRead'
1933
+ > = {
1934
+ userId: after.patientId,
1935
+ userRole: UserRole.PATIENT,
1936
+ notificationType: NotificationType.PROCEDURE_RECOMMENDATION,
1937
+ notificationTime: admin.firestore.Timestamp.now(),
1938
+ notificationTokens: patientProfile?.expoTokens || [],
1939
+ title: 'New Procedure Recommendation',
1940
+ body: `${after.practitionerInfo?.name || 'Your doctor'} recommended "${recommendation.procedure.procedureName}" for you. Suggested timeframe: in ${timeframeText}`,
1941
+ appointmentId: after.id,
1942
+ recommendationId,
1943
+ procedureId: recommendation.procedure.procedureId,
1944
+ procedureName: recommendation.procedure.procedureName,
1945
+ practitionerName: after.practitionerInfo?.name || 'Unknown Practitioner',
1946
+ clinicName: after.clinicInfo?.name || 'Unknown Clinic',
1947
+ note: recommendation.note,
1948
+ timeframe: recommendation.timeframe,
1949
+ };
1950
+
1951
+ try {
1952
+ const notificationId = await this.notificationsAdmin.createNotification(
1953
+ notificationPayload as any,
1954
+ );
1955
+
1956
+ Logger.info(
1957
+ `[AggService] Created notification ${notificationId} for recommendation ${recommendationId}`,
1958
+ );
1959
+
1960
+ // Send push notification immediately if patient has tokens
1961
+ if (patientProfile?.expoTokens && patientProfile.expoTokens.length > 0) {
1962
+ const notification = await this.notificationsAdmin.getNotification(notificationId);
1963
+ if (notification) {
1964
+ await this.notificationsAdmin.sendPushNotification(notification);
1965
+ Logger.info(
1966
+ `[AggService] Sent push notification for recommendation ${recommendationId}`,
1967
+ );
1968
+ }
1969
+ }
1970
+ } catch (error) {
1971
+ Logger.error(
1972
+ `[AggService] Error creating notification for recommendation ${recommendationId}:`,
1973
+ error,
1974
+ );
1975
+ }
1976
+ }
1977
+ } catch (error) {
1978
+ Logger.error(
1979
+ `[AggService] Error handling recommended procedures update for appointment ${after.id}:`,
1980
+ error,
1981
+ );
1982
+ }
1983
+ }
1844
1984
  }
@@ -0,0 +1,278 @@
1
+ import * as admin from 'firebase-admin';
2
+ import { AnalyticsService } from '../../services/analytics/analytics.service';
3
+ import {
4
+ AnalyticsDateRange,
5
+ AnalyticsFilters,
6
+ EntityType,
7
+ } from '../../types/analytics';
8
+ import {
9
+ PractitionerAnalytics,
10
+ ProcedureAnalytics,
11
+ TimeEfficiencyMetrics,
12
+ CancellationMetrics,
13
+ NoShowMetrics,
14
+ RevenueMetrics,
15
+ ProductUsageMetrics,
16
+ PatientAnalytics,
17
+ ClinicAnalytics,
18
+ DashboardAnalytics,
19
+ GroupedRevenueMetrics,
20
+ GroupedProductUsageMetrics,
21
+ GroupedTimeEfficiencyMetrics,
22
+ GroupedPatientBehaviorMetrics,
23
+ } from '../../types/analytics';
24
+ import { Appointment } from '../../types/appointment';
25
+ import { APPOINTMENTS_COLLECTION } from '../../types/appointment';
26
+
27
+ /**
28
+ * Admin version of AnalyticsService that uses Firebase Admin SDK
29
+ * This is intended for use in Cloud Functions and server-side code
30
+ */
31
+ export class AnalyticsAdminService {
32
+ private analyticsService: AnalyticsService;
33
+ private db: admin.firestore.Firestore;
34
+
35
+ /**
36
+ * Creates a new AnalyticsAdminService instance
37
+ *
38
+ * @param firestore - Admin Firestore instance (optional, defaults to admin.firestore())
39
+ */
40
+ constructor(firestore?: admin.firestore.Firestore) {
41
+ this.db = firestore || admin.firestore();
42
+
43
+ // Create a mock Firebase App for client SDK compatibility
44
+ const mockApp = {
45
+ name: '[DEFAULT]',
46
+ options: {},
47
+ automaticDataCollectionEnabled: false,
48
+ } as any;
49
+
50
+ // Create mock Auth (not used by AnalyticsService)
51
+ const mockAuth = {} as any;
52
+
53
+ // Create AppointmentService adapter that uses admin SDK
54
+ const appointmentService = this.createAppointmentServiceAdapter();
55
+
56
+ // Initialize AnalyticsService with adapted dependencies
57
+ this.analyticsService = new AnalyticsService(
58
+ this.db as any, // Cast admin Firestore to client Firestore type
59
+ mockAuth,
60
+ mockApp,
61
+ appointmentService,
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Creates an adapter for AppointmentService to work with admin SDK
67
+ */
68
+ private createAppointmentServiceAdapter(): any {
69
+ return {
70
+ searchAppointments: async (params: any) => {
71
+ // Build query using admin SDK
72
+ let query: admin.firestore.Query = this.db.collection(APPOINTMENTS_COLLECTION);
73
+
74
+ if (params.clinicBranchId) {
75
+ query = query.where('clinicBranchId', '==', params.clinicBranchId);
76
+ }
77
+ if (params.practitionerId) {
78
+ query = query.where('practitionerId', '==', params.practitionerId);
79
+ }
80
+ if (params.procedureId) {
81
+ query = query.where('procedureId', '==', params.procedureId);
82
+ }
83
+ if (params.patientId) {
84
+ query = query.where('patientId', '==', params.patientId);
85
+ }
86
+ if (params.startDate) {
87
+ const startDate = params.startDate instanceof Date
88
+ ? params.startDate
89
+ : params.startDate.toDate();
90
+ const startTimestamp = admin.firestore.Timestamp.fromDate(startDate);
91
+ query = query.where('appointmentStartTime', '>=', startTimestamp);
92
+ }
93
+ if (params.endDate) {
94
+ const endDate = params.endDate instanceof Date
95
+ ? params.endDate
96
+ : params.endDate.toDate();
97
+ const endTimestamp = admin.firestore.Timestamp.fromDate(endDate);
98
+ query = query.where('appointmentStartTime', '<=', endTimestamp);
99
+ }
100
+
101
+ const snapshot = await query.get();
102
+ const appointments = snapshot.docs.map(doc => ({
103
+ id: doc.id,
104
+ ...doc.data(),
105
+ })) as Appointment[];
106
+
107
+ return {
108
+ appointments,
109
+ total: appointments.length,
110
+ };
111
+ },
112
+ };
113
+ }
114
+
115
+ // Delegate all methods to the underlying AnalyticsService
116
+ // We expose them here so they can be called with admin SDK context
117
+
118
+ async getPractitionerAnalytics(
119
+ practitionerId: string,
120
+ dateRange?: AnalyticsDateRange,
121
+ options?: any,
122
+ ): Promise<PractitionerAnalytics> {
123
+ return this.analyticsService.getPractitionerAnalytics(practitionerId, dateRange, options);
124
+ }
125
+
126
+ async getProcedureAnalytics(
127
+ procedureId?: string,
128
+ dateRange?: AnalyticsDateRange,
129
+ options?: any,
130
+ ): Promise<ProcedureAnalytics | ProcedureAnalytics[]> {
131
+ return this.analyticsService.getProcedureAnalytics(procedureId, dateRange, options);
132
+ }
133
+
134
+ async getTimeEfficiencyMetrics(
135
+ filters?: AnalyticsFilters,
136
+ dateRange?: AnalyticsDateRange,
137
+ options?: any,
138
+ ): Promise<TimeEfficiencyMetrics> {
139
+ return this.analyticsService.getTimeEfficiencyMetrics(filters, dateRange, options);
140
+ }
141
+
142
+ async getTimeEfficiencyMetricsByEntity(
143
+ groupBy: EntityType,
144
+ dateRange?: AnalyticsDateRange,
145
+ filters?: AnalyticsFilters,
146
+ ): Promise<GroupedTimeEfficiencyMetrics[]> {
147
+ return this.analyticsService.getTimeEfficiencyMetricsByEntity(groupBy, dateRange, filters);
148
+ }
149
+
150
+ async getCancellationMetrics(
151
+ groupBy: EntityType,
152
+ dateRange?: AnalyticsDateRange,
153
+ options?: any,
154
+ ): Promise<CancellationMetrics | CancellationMetrics[]> {
155
+ return this.analyticsService.getCancellationMetrics(groupBy, dateRange, options);
156
+ }
157
+
158
+ async getNoShowMetrics(
159
+ groupBy: EntityType,
160
+ dateRange?: AnalyticsDateRange,
161
+ options?: any,
162
+ ): Promise<NoShowMetrics | NoShowMetrics[]> {
163
+ return this.analyticsService.getNoShowMetrics(groupBy, dateRange, options);
164
+ }
165
+
166
+ async getRevenueMetrics(
167
+ filters?: AnalyticsFilters,
168
+ dateRange?: AnalyticsDateRange,
169
+ options?: any,
170
+ ): Promise<RevenueMetrics> {
171
+ return this.analyticsService.getRevenueMetrics(filters, dateRange, options);
172
+ }
173
+
174
+ async getRevenueMetricsByEntity(
175
+ groupBy: EntityType,
176
+ dateRange?: AnalyticsDateRange,
177
+ filters?: AnalyticsFilters,
178
+ ): Promise<GroupedRevenueMetrics[]> {
179
+ return this.analyticsService.getRevenueMetricsByEntity(groupBy, dateRange, filters);
180
+ }
181
+
182
+ async getProductUsageMetrics(
183
+ productId?: string,
184
+ dateRange?: AnalyticsDateRange,
185
+ ): Promise<ProductUsageMetrics | ProductUsageMetrics[]> {
186
+ return this.analyticsService.getProductUsageMetrics(productId, dateRange);
187
+ }
188
+
189
+ async getProductUsageMetricsByEntity(
190
+ groupBy: EntityType,
191
+ dateRange?: AnalyticsDateRange,
192
+ filters?: AnalyticsFilters,
193
+ ): Promise<GroupedProductUsageMetrics[]> {
194
+ return this.analyticsService.getProductUsageMetricsByEntity(groupBy, dateRange, filters);
195
+ }
196
+
197
+ async getPatientAnalytics(
198
+ patientId?: string,
199
+ dateRange?: AnalyticsDateRange,
200
+ ): Promise<PatientAnalytics | PatientAnalytics[]> {
201
+ return this.analyticsService.getPatientAnalytics(patientId, dateRange);
202
+ }
203
+
204
+ async getPatientBehaviorMetricsByEntity(
205
+ groupBy: 'clinic' | 'practitioner' | 'procedure' | 'technology',
206
+ dateRange?: AnalyticsDateRange,
207
+ filters?: AnalyticsFilters,
208
+ ): Promise<GroupedPatientBehaviorMetrics[]> {
209
+ return this.analyticsService.getPatientBehaviorMetricsByEntity(groupBy, dateRange, filters);
210
+ }
211
+
212
+ async getClinicAnalytics(
213
+ clinicBranchId: string,
214
+ dateRange?: AnalyticsDateRange,
215
+ ): Promise<ClinicAnalytics | ClinicAnalytics[]> {
216
+ // Use getDashboardData to get clinic-level analytics
217
+ const dashboard = await this.analyticsService.getDashboardData(
218
+ { clinicBranchId },
219
+ dateRange,
220
+ );
221
+
222
+ // Get clinic info
223
+ const clinicDoc = await this.db.collection('clinics').doc(clinicBranchId).get();
224
+ const clinicData = clinicDoc.data();
225
+ const clinicName = clinicData?.name || 'Unknown';
226
+
227
+ // Convert DashboardAnalytics to ClinicAnalytics format
228
+ return {
229
+ clinicBranchId,
230
+ clinicName,
231
+ totalAppointments: dashboard.overview.totalAppointments,
232
+ completedAppointments: dashboard.overview.completedAppointments,
233
+ canceledAppointments: dashboard.overview.canceledAppointments,
234
+ noShowAppointments: dashboard.overview.noShowAppointments,
235
+ cancellationRate: dashboard.overview.cancellationRate,
236
+ noShowRate: dashboard.overview.noShowRate,
237
+ totalRevenue: dashboard.overview.totalRevenue,
238
+ averageRevenuePerAppointment: dashboard.overview.averageRevenuePerAppointment,
239
+ currency: dashboard.overview.currency,
240
+ practitionerCount: dashboard.overview.uniquePractitioners,
241
+ patientCount: dashboard.overview.uniquePatients,
242
+ procedureCount: dashboard.overview.uniqueProcedures,
243
+ topPractitioners: dashboard.practitionerMetrics.slice(0, 5).map(p => ({
244
+ practitionerId: p.practitionerId,
245
+ practitionerName: p.practitionerName,
246
+ appointmentCount: p.totalAppointments,
247
+ revenue: p.totalRevenue,
248
+ })),
249
+ topProcedures: dashboard.procedureMetrics.slice(0, 5).map(p => ({
250
+ procedureId: p.procedureId,
251
+ procedureName: p.procedureName,
252
+ appointmentCount: p.totalAppointments,
253
+ revenue: p.totalRevenue,
254
+ })),
255
+ };
256
+ }
257
+
258
+ async getDashboardData(
259
+ filters?: AnalyticsFilters,
260
+ dateRange?: AnalyticsDateRange,
261
+ options?: any,
262
+ ): Promise<DashboardAnalytics> {
263
+ return this.analyticsService.getDashboardData(filters, dateRange, options);
264
+ }
265
+
266
+ /**
267
+ * Expose fetchAppointments for direct access if needed
268
+ * This method is used internally by AnalyticsService
269
+ */
270
+ async fetchAppointments(
271
+ filters?: AnalyticsFilters,
272
+ dateRange?: AnalyticsDateRange,
273
+ ): Promise<any[]> {
274
+ // Access the private method via the service
275
+ return (this.analyticsService as any).fetchAppointments(filters, dateRange);
276
+ }
277
+ }
278
+
@@ -0,0 +1,2 @@
1
+ export * from './analytics.admin.service';
2
+
@@ -64,6 +64,7 @@ TimestampUtils.enableServerMode();
64
64
 
65
65
  // Export all services and utilities from the admin sub-modules.
66
66
  export * from './aggregation';
67
+ export * from './analytics';
67
68
  export * from './booking';
68
69
  export * from './calendar';
69
70
  export * from './documentation-templates';
@@ -73,3 +74,8 @@ export * from './mailing';
73
74
  export * from './notifications';
74
75
  export * from './requirements';
75
76
  export * from './users';
77
+
78
+ // Export analytics types for Cloud Functions
79
+ export type * from '../types/analytics';
80
+ export * from '../types/analytics';
81
+ export { CLINICS_COLLECTION } from '../types/clinic';
@@ -38,3 +38,20 @@ Manages administrative constants like treatment benefits and contraindications.
38
38
  - **`addContraindication(contraindication)`**: Adds a new contraindication.
39
39
  - **`updateContraindication(contraindication)`**: Updates an existing contraindication.
40
40
  - **`deleteContraindication(contraindicationId)`**: Deletes a contraindication.
41
+
42
+ ### `AnalyticsService` (Proposed)
43
+
44
+ Comprehensive financial and analytical intelligence service for the Clinic Admin app. Provides insights about doctors, procedures, appointments, patients, products, and clinic operations.
45
+
46
+ **Status**: Proposal phase - See [analytics.service.proposal.md](./analytics.service.proposal.md) for detailed design and implementation plan.
47
+
48
+ **Planned Features**:
49
+ - Practitioner performance analytics (appointments, cancellations, time efficiency, revenue)
50
+ - Procedure analytics (popularity, profitability, product usage)
51
+ - Appointment time analytics (booked vs actual time, efficiency metrics)
52
+ - Cancellation & no-show analytics (by clinic, practitioner, patient, procedure)
53
+ - Financial analytics (revenue, costs, payment status, trends)
54
+ - Product usage analytics (usage patterns, revenue contribution)
55
+ - Patient analytics (lifetime value, retention, appointment frequency)
56
+ - Clinic analytics (performance metrics, comparisons)
57
+ - Comprehensive dashboard data aggregation