@blackcode_sa/metaestetics-api 1.12.68 → 1.12.69

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.
@@ -0,0 +1,143 @@
1
+ # Analytics Service - Executive Summary
2
+
3
+ ## Overview
4
+
5
+ This document provides a high-level summary of the proposed Analytics Service for the Clinic Admin app. The full detailed proposal can be found in [analytics.service.proposal.md](./analytics.service.proposal.md).
6
+
7
+ ## Purpose
8
+
9
+ The Analytics Service will provide comprehensive financial and operational intelligence to clinic administrators, enabling data-driven decision making across:
10
+
11
+ - **Practitioner Performance**: Track doctor efficiency, revenue generation, and patient satisfaction
12
+ - **Procedure Analytics**: Understand procedure popularity, profitability, and product usage
13
+ - **Financial Intelligence**: Monitor revenue, costs, payment status, and trends
14
+ - **Operational Efficiency**: Analyze time utilization, cancellations, and no-shows
15
+ - **Patient Insights**: Track patient lifetime value, retention, and behavior patterns
16
+
17
+ ## Key Data Sources
18
+
19
+ The service will aggregate data from:
20
+
21
+ 1. **Appointments Collection**: Primary source for all appointment-related metrics
22
+ 2. **Procedures Collection**: Procedure metadata and categorization
23
+ 3. **Practitioners Collection**: Doctor information and associations
24
+ 4. **Patients Collection**: Patient profiles and history
25
+ 5. **Clinics Collection**: Clinic and branch information
26
+ 6. **Products Collection**: Product usage and pricing data
27
+
28
+ ## Core Analytics Categories
29
+
30
+ ### 1. Practitioner Analytics
31
+ - Total and completed appointments
32
+ - Cancellation and no-show rates
33
+ - Time efficiency (booked vs actual)
34
+ - Revenue generation
35
+ - Patient retention
36
+
37
+ ### 2. Procedure Analytics
38
+ - Appointment counts and popularity
39
+ - Revenue and profitability
40
+ - Cancellation rates
41
+ - Product usage patterns
42
+ - Performance by category/technology
43
+
44
+ ### 3. Time Analytics
45
+ - Booked vs actual time comparison
46
+ - Efficiency percentages
47
+ - Overrun/underutilization analysis
48
+ - Peak hours and trends
49
+
50
+ ### 4. Cancellation & No-Show Analytics
51
+ - Rates by clinic, practitioner, patient, procedure
52
+ - Cancellation reasons breakdown
53
+ - Patterns and trends
54
+ - Lead time analysis
55
+
56
+ ### 5. Financial Analytics
57
+ - Total and average revenue
58
+ - Cost per patient/appointment
59
+ - Payment status breakdown
60
+ - Revenue trends
61
+ - Product cost analysis
62
+
63
+ ### 6. Product Usage Analytics
64
+ - Products used per appointment
65
+ - Revenue contribution
66
+ - Usage by procedure/zone
67
+ - Quantity and pricing trends
68
+
69
+ ### 7. Patient Analytics
70
+ - Lifetime value
71
+ - Retention rates
72
+ - Appointment frequency
73
+ - Cancellation patterns
74
+
75
+ ### 8. Clinic Analytics
76
+ - Overall performance metrics
77
+ - Practitioner comparisons
78
+ - Procedure popularity
79
+ - Efficiency metrics
80
+
81
+ ## Proposed Service Structure
82
+
83
+ The `AnalyticsService` will extend `BaseService` and provide methods organized by analytics category:
84
+
85
+ - `getPractitionerAnalytics()` - Comprehensive practitioner metrics
86
+ - `getProcedureAnalytics()` - Procedure performance data
87
+ - `getTimeEfficiencyMetrics()` - Time utilization analysis
88
+ - `getCancellationMetrics()` - Cancellation insights
89
+ - `getRevenueMetrics()` - Financial intelligence
90
+ - `getProductUsageMetrics()` - Product analytics
91
+ - `getPatientAnalytics()` - Patient insights
92
+ - `getClinicAnalytics()` - Clinic performance
93
+ - `getDashboardData()` - Comprehensive dashboard aggregation
94
+
95
+ ## Key Calculations
96
+
97
+ ### Cost Calculation Priority
98
+ 1. `metadata.finalbilling.finalPrice` (if available)
99
+ 2. Sum of `metadata.zonesData` subtotals
100
+ 3. `appointment.cost` (fallback)
101
+
102
+ ### Time Efficiency
103
+ ```
104
+ efficiency = (actualDuration / bookedDuration) * 100
105
+ overrun = actualDuration > bookedDuration ? difference : 0
106
+ ```
107
+
108
+ ### Cancellation Rate
109
+ ```
110
+ cancellationRate = (canceledCount / totalAppointments) * 100
111
+ ```
112
+
113
+ ## Implementation Phases
114
+
115
+ 1. **Phase 1**: Core infrastructure and utilities
116
+ 2. **Phase 2**: Basic metrics (practitioner, procedure, financial)
117
+ 3. **Phase 3**: Advanced analytics (time, products, patients)
118
+ 4. **Phase 4**: Dashboard and trend analysis
119
+
120
+ ## Performance Considerations
121
+
122
+ - Firestore composite indexes for query optimization
123
+ - Pagination for large datasets
124
+ - Caching strategy for frequently accessed metrics
125
+ - Batch processing for complex aggregations
126
+ - Server-side calculations via Cloud Functions
127
+
128
+ ## Next Steps
129
+
130
+ 1. Review and approve the detailed proposal
131
+ 2. Create TypeScript type definitions
132
+ 3. Implement core service structure
133
+ 4. Build utility functions for calculations
134
+ 5. Implement metrics methods incrementally
135
+ 6. Add comprehensive tests
136
+ 7. Create API documentation
137
+ 8. Integrate with Clinic Admin app
138
+
139
+ ## Documentation
140
+
141
+ - **Full Proposal**: [analytics.service.proposal.md](./analytics.service.proposal.md)
142
+ - **Service README**: [README.md](./README.md)
143
+
@@ -1833,6 +1833,12 @@ export class AppointmentService extends BaseService {
1833
1833
  finalizationNotes: null,
1834
1834
  };
1835
1835
 
1836
+ // Update payment status if billing data exists but status is NOT_APPLICABLE
1837
+ // This handles cases where appointment was created with price 0 but billing was added later
1838
+ const shouldUpdatePaymentStatus =
1839
+ finalbilling.finalPrice > 0 &&
1840
+ appointment.paymentStatus === PaymentStatus.NOT_APPLICABLE;
1841
+
1836
1842
  const updateData: UpdateAppointmentData = {
1837
1843
  metadata: {
1838
1844
  selectedZones: currentMetadata.selectedZones,
@@ -1848,6 +1854,9 @@ export class AppointmentService extends BaseService {
1848
1854
  finalbilling,
1849
1855
  finalizationNotes: currentMetadata.finalizationNotes,
1850
1856
  },
1857
+ ...(shouldUpdatePaymentStatus && {
1858
+ paymentStatus: PaymentStatus.UNPAID,
1859
+ }),
1851
1860
  updatedAt: serverTimestamp(),
1852
1861
  };
1853
1862
 
@@ -2123,10 +2132,58 @@ export class AppointmentService extends BaseService {
2123
2132
  showNoShow: false,
2124
2133
  });
2125
2134
 
2135
+ // Also get appointments that have recommendations but might not be COMPLETED yet
2136
+ // (e.g., doctor finalized but appointment status is still CONFIRMED/CHECKED_IN)
2137
+ // Get all patient appointments that are past their end time
2138
+ const now = new Date();
2139
+ const allPastAppointments = await this.getPatientAppointments(patientId, {
2140
+ endDate: now,
2141
+ status: [
2142
+ AppointmentStatus.COMPLETED,
2143
+ AppointmentStatus.CONFIRMED,
2144
+ AppointmentStatus.CHECKED_IN,
2145
+ AppointmentStatus.IN_PROGRESS,
2146
+ ],
2147
+ });
2148
+
2149
+ // Filter to only include appointments that are past their end time AND have recommendations
2150
+ const appointmentsWithRecommendations = allPastAppointments.appointments.filter(
2151
+ appointment => {
2152
+ const endTime = appointment.appointmentEndTime?.toMillis
2153
+ ? appointment.appointmentEndTime.toMillis()
2154
+ : appointment.appointmentEndTime?.seconds
2155
+ ? appointment.appointmentEndTime.seconds * 1000
2156
+ : null;
2157
+
2158
+ if (!endTime) return false;
2159
+
2160
+ const isPastEndTime = endTime < now.getTime();
2161
+ const hasRecommendations =
2162
+ (appointment.metadata?.recommendedProcedures?.length || 0) > 0;
2163
+
2164
+ return isPastEndTime && hasRecommendations;
2165
+ },
2166
+ );
2167
+
2168
+ // Combine and deduplicate by appointment ID
2169
+ const allAppointmentsMap = new Map<string, Appointment>();
2170
+
2171
+ // Add completed appointments
2172
+ pastAppointments.appointments.forEach(apt => {
2173
+ allAppointmentsMap.set(apt.id, apt);
2174
+ });
2175
+
2176
+ // Add appointments with recommendations (will overwrite if duplicate)
2177
+ appointmentsWithRecommendations.forEach(apt => {
2178
+ allAppointmentsMap.set(apt.id, apt);
2179
+ });
2180
+
2181
+ const allAppointments = Array.from(allAppointmentsMap.values());
2182
+
2126
2183
  const recommendations: NextStepsRecommendation[] = [];
2127
2184
 
2128
- // Iterate through past appointments and extract recommendations
2129
- for (const appointment of pastAppointments.appointments) {
2185
+ // Iterate through all appointments and extract recommendations
2186
+ for (const appointment of allAppointments) {
2130
2187
  // Filter by clinic if specified
2131
2188
  if (options?.clinicBranchId && appointment.clinicBranchId !== options.clinicBranchId) {
2132
2189
  continue;
@@ -2181,10 +2238,6 @@ export class AppointmentService extends BaseService {
2181
2238
  ? recommendations.slice(0, options.limit)
2182
2239
  : recommendations;
2183
2240
 
2184
- console.log(
2185
- `[APPOINTMENT_SERVICE] Found ${limitedRecommendations.length} next steps recommendations for patient ${patientId}`,
2186
- );
2187
-
2188
2241
  return limitedRecommendations;
2189
2242
  } catch (error) {
2190
2243
  console.error(
@@ -1088,6 +1088,14 @@ export class ProcedureService extends BaseService {
1088
1088
  console.log('[PROCEDURE_SERVICE] Strategy 1: Trying nameLower search');
1089
1089
  const searchTerm = filters.nameSearch.trim().toLowerCase();
1090
1090
  const constraints = getBaseConstraints();
1091
+
1092
+ // Check if we have nested field filters that might conflict with orderBy
1093
+ const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1094
+
1095
+ if (hasNestedFilters) {
1096
+ console.log('[PROCEDURE_SERVICE] Strategy 1: Has nested filters, will apply client-side after query');
1097
+ }
1098
+
1091
1099
  constraints.push(where('nameLower', '>=', searchTerm));
1092
1100
  constraints.push(where('nameLower', '<=', searchTerm + '\uf8ff'));
1093
1101
  constraints.push(orderBy('nameLower'));
@@ -1105,9 +1113,15 @@ export class ProcedureService extends BaseService {
1105
1113
 
1106
1114
  const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1107
1115
  const querySnapshot = await getDocs(q);
1108
- const procedures = querySnapshot.docs.map(
1116
+ let procedures = querySnapshot.docs.map(
1109
1117
  doc => ({ ...doc.data(), id: doc.id } as Procedure),
1110
1118
  );
1119
+
1120
+ // Apply client-side filters for nested fields if needed
1121
+ if (hasNestedFilters) {
1122
+ procedures = this.applyInMemoryFilters(procedures, filters);
1123
+ }
1124
+
1111
1125
  const lastDoc =
1112
1126
  querySnapshot.docs.length > 0
1113
1127
  ? querySnapshot.docs[querySnapshot.docs.length - 1]
@@ -1131,6 +1145,14 @@ export class ProcedureService extends BaseService {
1131
1145
  console.log('[PROCEDURE_SERVICE] Strategy 2: Trying name field search');
1132
1146
  const searchTerm = filters.nameSearch.trim().toLowerCase();
1133
1147
  const constraints = getBaseConstraints();
1148
+
1149
+ // Check if we have nested field filters that might conflict with orderBy
1150
+ const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1151
+
1152
+ if (hasNestedFilters) {
1153
+ console.log('[PROCEDURE_SERVICE] Strategy 2: Has nested filters, will apply client-side after query');
1154
+ }
1155
+
1134
1156
  constraints.push(where('name', '>=', searchTerm));
1135
1157
  constraints.push(where('name', '<=', searchTerm + '\uf8ff'));
1136
1158
  constraints.push(orderBy('name'));
@@ -1148,9 +1170,15 @@ export class ProcedureService extends BaseService {
1148
1170
 
1149
1171
  const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1150
1172
  const querySnapshot = await getDocs(q);
1151
- const procedures = querySnapshot.docs.map(
1173
+ let procedures = querySnapshot.docs.map(
1152
1174
  doc => ({ ...doc.data(), id: doc.id } as Procedure),
1153
1175
  );
1176
+
1177
+ // Apply client-side filters for nested fields if needed
1178
+ if (hasNestedFilters) {
1179
+ procedures = this.applyInMemoryFilters(procedures, filters);
1180
+ }
1181
+
1154
1182
  const lastDoc =
1155
1183
  querySnapshot.docs.length > 0
1156
1184
  ? querySnapshot.docs[querySnapshot.docs.length - 1]
@@ -1169,11 +1197,66 @@ export class ProcedureService extends BaseService {
1169
1197
  }
1170
1198
 
1171
1199
  // Strategy 3: orderBy createdAt with client-side filtering
1200
+ // NOTE: This strategy excludes nested field filters (technology.id, category.id, subcategory.id)
1201
+ // from Firestore query because Firestore doesn't support orderBy on different field
1202
+ // when using where on nested fields without a composite index.
1203
+ // These filters are applied client-side instead.
1172
1204
  try {
1173
1205
  console.log(
1174
1206
  '[PROCEDURE_SERVICE] Strategy 3: Using createdAt orderBy with client-side filtering',
1207
+ {
1208
+ procedureTechnology: filters.procedureTechnology,
1209
+ hasTechnologyFilter: !!filters.procedureTechnology,
1210
+ },
1211
+ );
1212
+
1213
+ // Build constraints WITHOUT nested field filters (these will be applied client-side)
1214
+ const constraints: QueryConstraint[] = [];
1215
+
1216
+ // Active status filter
1217
+ if (filters.isActive !== undefined) {
1218
+ constraints.push(where('isActive', '==', filters.isActive));
1219
+ } else {
1220
+ constraints.push(where('isActive', '==', true));
1221
+ }
1222
+
1223
+ // Only include non-nested field filters in Firestore query
1224
+ if (filters.procedureFamily) {
1225
+ constraints.push(where('family', '==', filters.procedureFamily));
1226
+ }
1227
+ if (filters.practitionerId) {
1228
+ constraints.push(where('practitionerId', '==', filters.practitionerId));
1229
+ }
1230
+ if (filters.clinicId) {
1231
+ constraints.push(where('clinicBranchId', '==', filters.clinicId));
1232
+ }
1233
+ if (filters.minPrice !== undefined) {
1234
+ constraints.push(where('price', '>=', filters.minPrice));
1235
+ }
1236
+ if (filters.maxPrice !== undefined) {
1237
+ constraints.push(where('price', '<=', filters.maxPrice));
1238
+ }
1239
+ if (filters.minRating !== undefined) {
1240
+ constraints.push(where('reviewInfo.averageRating', '>=', filters.minRating));
1241
+ }
1242
+ if (filters.maxRating !== undefined) {
1243
+ constraints.push(where('reviewInfo.averageRating', '<=', filters.maxRating));
1244
+ }
1245
+ if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
1246
+ const benefitIdsToMatch = filters.treatmentBenefits;
1247
+ constraints.push(where('treatmentBenefitIds', 'array-contains-any', benefitIdsToMatch));
1248
+ }
1249
+
1250
+ // NOTE: We intentionally EXCLUDE these nested field filters from Firestore query:
1251
+ // - filters.procedureTechnology (technology.id)
1252
+ // - filters.procedureCategory (category.id)
1253
+ // - filters.procedureSubcategory (subcategory.id)
1254
+ // These will be applied client-side in applyInMemoryFilters
1255
+
1256
+ console.log(
1257
+ '[PROCEDURE_SERVICE] Strategy 3 Firestore constraints (nested filters excluded):',
1258
+ constraints.map(c => (c as any).fieldPath || 'unknown'),
1175
1259
  );
1176
- const constraints = getBaseConstraints();
1177
1260
  constraints.push(orderBy('createdAt', 'desc'));
1178
1261
 
1179
1262
  if (filters.lastDoc) {
@@ -1194,7 +1277,20 @@ export class ProcedureService extends BaseService {
1194
1277
  );
1195
1278
 
1196
1279
  // Apply all client-side filters using centralized function
1280
+ console.log('[PROCEDURE_SERVICE] Before applyInMemoryFilters (Strategy 3):', {
1281
+ procedureCount: procedures.length,
1282
+ procedureTechnology: filters.procedureTechnology,
1283
+ filtersObject: {
1284
+ procedureTechnology: filters.procedureTechnology,
1285
+ procedureFamily: filters.procedureFamily,
1286
+ procedureCategory: filters.procedureCategory,
1287
+ procedureSubcategory: filters.procedureSubcategory,
1288
+ },
1289
+ });
1197
1290
  procedures = this.applyInMemoryFilters(procedures, filters);
1291
+ console.log('[PROCEDURE_SERVICE] After applyInMemoryFilters (Strategy 3):', {
1292
+ procedureCount: procedures.length,
1293
+ });
1198
1294
 
1199
1295
  const lastDoc =
1200
1296
  querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
@@ -1265,6 +1361,14 @@ export class ProcedureService extends BaseService {
1265
1361
  ): (Procedure & { distance?: number })[] {
1266
1362
  let filteredProcedures = [...procedures]; // Create copy to avoid mutating original
1267
1363
 
1364
+ // Debug: Log what filters we received
1365
+ console.log('[PROCEDURE_SERVICE] applyInMemoryFilters called:', {
1366
+ procedureCount: procedures.length,
1367
+ procedureTechnology: filters.procedureTechnology,
1368
+ hasTechnologyFilter: !!filters.procedureTechnology,
1369
+ allFilterKeys: Object.keys(filters).filter(k => filters[k] !== undefined && filters[k] !== null),
1370
+ });
1371
+
1268
1372
  // Name search filter
1269
1373
  if (filters.nameSearch && filters.nameSearch.trim()) {
1270
1374
  const searchTerm = filters.nameSearch.trim().toLowerCase();
@@ -1348,12 +1452,21 @@ export class ProcedureService extends BaseService {
1348
1452
 
1349
1453
  // Technology filtering
1350
1454
  if (filters.procedureTechnology) {
1455
+ const beforeCount = filteredProcedures.length;
1351
1456
  filteredProcedures = filteredProcedures.filter(
1352
1457
  procedure => procedure.technology?.id === filters.procedureTechnology,
1353
1458
  );
1354
1459
  console.log(
1355
- `[PROCEDURE_SERVICE] Applied technology filter, results: ${filteredProcedures.length}`,
1460
+ `[PROCEDURE_SERVICE] Applied technology filter (${filters.procedureTechnology}), before: ${beforeCount}, after: ${filteredProcedures.length}`,
1356
1461
  );
1462
+ // Log sample technology IDs for debugging
1463
+ if (beforeCount > filteredProcedures.length) {
1464
+ const filteredOut = procedures
1465
+ .filter(p => p.technology?.id !== filters.procedureTechnology)
1466
+ .slice(0, 3)
1467
+ .map(p => ({ id: p.id, techId: p.technology?.id, name: p.name }));
1468
+ console.log('[PROCEDURE_SERVICE] Filtered out sample procedures:', filteredOut);
1469
+ }
1357
1470
  }
1358
1471
 
1359
1472
  // Practitioner filtering
@@ -21,6 +21,7 @@ export enum NotificationType {
21
21
 
22
22
  // --- Patient Engagement ---
23
23
  REVIEW_REQUEST = "reviewRequest", // Request for patient review post-appointment
24
+ PROCEDURE_RECOMMENDATION = "procedureRecommendation", // Doctor recommended a procedure for follow-up
24
25
 
25
26
  // --- Payment Related (Examples) ---
26
27
  PAYMENT_DUE = "paymentDue",
@@ -231,6 +232,25 @@ export interface ReviewRequestNotification extends BaseNotification {
231
232
  procedureName?: string;
232
233
  }
233
234
 
235
+ /**
236
+ * Notification for when a doctor recommends a procedure for follow-up.
237
+ * Example: "Dr. Smith recommended [Procedure Name] for you. Suggested timeframe: in 2 weeks"
238
+ */
239
+ export interface ProcedureRecommendationNotification extends BaseNotification {
240
+ notificationType: NotificationType.PROCEDURE_RECOMMENDATION;
241
+ appointmentId: string; // The appointment where recommendation was made
242
+ recommendationId: string; // Format: `${appointmentId}:${index}`
243
+ procedureId: string;
244
+ procedureName: string;
245
+ practitionerName: string;
246
+ clinicName: string;
247
+ note?: string; // Doctor's note about the recommendation
248
+ timeframe: {
249
+ value: number;
250
+ unit: 'day' | 'week' | 'month' | 'year';
251
+ };
252
+ }
253
+
234
254
  /**
235
255
  * Generic notification for direct messages or announcements.
236
256
  */
@@ -261,5 +281,6 @@ export type Notification =
261
281
  | FormReminderNotification
262
282
  | FormSubmissionConfirmationNotification
263
283
  | ReviewRequestNotification
284
+ | ProcedureRecommendationNotification
264
285
  | GeneralMessageNotification
265
286
  | PaymentConfirmationNotification;