@blackcode_sa/metaestetics-api 1.13.0 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +106 -1
- package/dist/admin/index.d.ts +106 -1
- package/dist/admin/index.js +1303 -130
- package/dist/admin/index.mjs +1303 -130
- package/dist/index.d.mts +326 -2
- package/dist/index.d.ts +326 -2
- package/dist/index.js +3188 -1888
- package/dist/index.mjs +2909 -1610
- package/package.json +1 -1
- package/src/services/analytics/README.md +17 -0
- package/src/services/analytics/TRENDS.md +380 -0
- package/src/services/analytics/analytics.service.ts +540 -30
- package/src/services/analytics/index.ts +1 -0
- package/src/services/analytics/review-analytics.service.ts +941 -0
- package/src/services/analytics/utils/cost-calculation.utils.ts +32 -4
- package/src/services/analytics/utils/grouping.utils.ts +40 -0
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -0
- package/src/services/appointment/appointment.service.ts +9 -0
- package/src/services/procedure/procedure.service.ts +117 -4
- package/src/services/reviews/reviews.service.ts +58 -7
- package/src/types/analytics/analytics.types.ts +98 -1
- package/src/types/analytics/grouped-analytics.types.ts +25 -0
|
@@ -67,7 +67,11 @@ export function calculateAppointmentCost(appointment: Appointment): {
|
|
|
67
67
|
/**
|
|
68
68
|
* Calculates total revenue from an array of appointments
|
|
69
69
|
*
|
|
70
|
-
*
|
|
70
|
+
* IMPORTANT: This function should only be called with COMPLETED appointments.
|
|
71
|
+
* Only completed procedures generate revenue. Confirmed, pending, canceled, and no-show
|
|
72
|
+
* appointments should be filtered out before calling this function.
|
|
73
|
+
*
|
|
74
|
+
* @param appointments - Array of appointments (should be filtered to COMPLETED only)
|
|
71
75
|
* @returns Total revenue and currency
|
|
72
76
|
*/
|
|
73
77
|
export function calculateTotalRevenue(appointments: Appointment[]): {
|
|
@@ -96,8 +100,19 @@ export function calculateTotalRevenue(appointments: Appointment[]): {
|
|
|
96
100
|
/**
|
|
97
101
|
* Extracts product usage from appointment metadata
|
|
98
102
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
103
|
+
* IMPORTANT: This function should only be called with COMPLETED appointments.
|
|
104
|
+
* Products are only considered "used" when the procedure has been completed.
|
|
105
|
+
* Only completed procedures generate product usage and revenue.
|
|
106
|
+
*
|
|
107
|
+
* NOTE ON PRICING:
|
|
108
|
+
* - Price overrides (priceOverrideAmount) are always applied if available
|
|
109
|
+
* - Subtotal is recalculated to ensure price overrides are reflected
|
|
110
|
+
* - Product revenue shows SUBTOTAL (before tax) - tax is applied at appointment level
|
|
111
|
+
* - Tax is included in appointment revenue (finalbilling.finalPrice) but not in product revenue
|
|
112
|
+
* - Product subtotals should sum to finalbilling.subtotalAll (before tax)
|
|
113
|
+
*
|
|
114
|
+
* @param appointment - The appointment to extract products from (should be COMPLETED)
|
|
115
|
+
* @returns Array of product usage data with subtotal (before tax)
|
|
101
116
|
*/
|
|
102
117
|
export function extractProductUsage(appointment: Appointment): Array<{
|
|
103
118
|
productId: string;
|
|
@@ -131,9 +146,22 @@ export function extractProductUsage(appointment: Appointment): Array<{
|
|
|
131
146
|
Object.values(zonesData).forEach(items => {
|
|
132
147
|
items.forEach(item => {
|
|
133
148
|
if (item.type === 'item' && item.productId) {
|
|
149
|
+
// Always use priceOverrideAmount if available, otherwise use price
|
|
150
|
+
// This ensures price overrides are properly applied to product calculations
|
|
134
151
|
const price = item.priceOverrideAmount || item.price || 0;
|
|
135
152
|
const quantity = item.quantity || 1;
|
|
136
|
-
|
|
153
|
+
|
|
154
|
+
// Always recalculate subtotal based on the actual price (with override)
|
|
155
|
+
// This ensures price overrides are reflected in product revenue
|
|
156
|
+
// Use stored subtotal only if it matches the calculated value (to handle rounding)
|
|
157
|
+
const calculatedSubtotal = price * quantity;
|
|
158
|
+
const storedSubtotal = item.subtotal || 0;
|
|
159
|
+
|
|
160
|
+
// Use stored subtotal if it's close to calculated (within 0.01 for rounding differences)
|
|
161
|
+
// Otherwise recalculate to ensure price override is applied correctly
|
|
162
|
+
const subtotal = Math.abs(storedSubtotal - calculatedSubtotal) < 0.01
|
|
163
|
+
? storedSubtotal
|
|
164
|
+
: calculatedSubtotal;
|
|
137
165
|
|
|
138
166
|
products.push({
|
|
139
167
|
productId: item.productId,
|
|
@@ -101,6 +101,9 @@ export function groupAppointmentsByEntity(
|
|
|
101
101
|
|
|
102
102
|
/**
|
|
103
103
|
* Calculates grouped revenue metrics
|
|
104
|
+
*
|
|
105
|
+
* IMPORTANT: Only COMPLETED appointments are included in revenue calculations.
|
|
106
|
+
* Confirmed, pending, canceled, and no-show appointments are excluded from financial metrics.
|
|
104
107
|
*/
|
|
105
108
|
export function calculateGroupedRevenueMetrics(
|
|
106
109
|
appointments: Appointment[],
|
|
@@ -139,6 +142,15 @@ export function calculateGroupedRevenueMetrics(
|
|
|
139
142
|
}
|
|
140
143
|
});
|
|
141
144
|
|
|
145
|
+
// Get practitioner info when grouping by procedure
|
|
146
|
+
let practitionerId: string | undefined;
|
|
147
|
+
let practitionerName: string | undefined;
|
|
148
|
+
if (entityType === 'procedure' && entityAppointments.length > 0) {
|
|
149
|
+
const firstAppointment = entityAppointments[0];
|
|
150
|
+
practitionerId = firstAppointment.practitionerId;
|
|
151
|
+
practitionerName = firstAppointment.practitionerInfo?.name;
|
|
152
|
+
}
|
|
153
|
+
|
|
142
154
|
return {
|
|
143
155
|
entityId,
|
|
144
156
|
entityName: data.name,
|
|
@@ -153,12 +165,18 @@ export function calculateGroupedRevenueMetrics(
|
|
|
153
165
|
refundedRevenue,
|
|
154
166
|
totalTax,
|
|
155
167
|
totalSubtotal,
|
|
168
|
+
...(practitionerId && { practitionerId }),
|
|
169
|
+
...(practitionerName && { practitionerName }),
|
|
156
170
|
};
|
|
157
171
|
});
|
|
158
172
|
}
|
|
159
173
|
|
|
160
174
|
/**
|
|
161
175
|
* Calculates grouped product usage metrics
|
|
176
|
+
*
|
|
177
|
+
* IMPORTANT: Only COMPLETED appointments are included in product usage calculations.
|
|
178
|
+
* Products are only considered "used" when the procedure has been completed.
|
|
179
|
+
* Confirmed, pending, canceled, and no-show appointments are excluded from product metrics.
|
|
162
180
|
*/
|
|
163
181
|
export function calculateGroupedProductUsageMetrics(
|
|
164
182
|
appointments: Appointment[],
|
|
@@ -220,6 +238,15 @@ export function calculateGroupedProductUsageMetrics(
|
|
|
220
238
|
const totalProductRevenue = topProducts.reduce((sum, p) => sum + p.totalRevenue, 0);
|
|
221
239
|
const totalProductQuantity = topProducts.reduce((sum, p) => sum + p.totalQuantity, 0);
|
|
222
240
|
|
|
241
|
+
// Get practitioner info when grouping by procedure
|
|
242
|
+
let practitionerId: string | undefined;
|
|
243
|
+
let practitionerName: string | undefined;
|
|
244
|
+
if (entityType === 'procedure' && entityAppointments.length > 0) {
|
|
245
|
+
const firstAppointment = entityAppointments[0];
|
|
246
|
+
practitionerId = firstAppointment.practitionerId;
|
|
247
|
+
practitionerName = firstAppointment.practitionerInfo?.name;
|
|
248
|
+
}
|
|
249
|
+
|
|
223
250
|
return {
|
|
224
251
|
entityId,
|
|
225
252
|
entityName: data.name,
|
|
@@ -231,6 +258,8 @@ export function calculateGroupedProductUsageMetrics(
|
|
|
231
258
|
averageProductsPerAppointment:
|
|
232
259
|
entityCompleted.length > 0 ? productMap.size / entityCompleted.length : 0,
|
|
233
260
|
topProducts,
|
|
261
|
+
...(practitionerId && { practitionerId }),
|
|
262
|
+
...(practitionerName && { practitionerName }),
|
|
234
263
|
};
|
|
235
264
|
});
|
|
236
265
|
}
|
|
@@ -253,6 +282,15 @@ export function calculateGroupedTimeEfficiencyMetrics(
|
|
|
253
282
|
|
|
254
283
|
const timeMetrics = calculateAverageTimeMetrics(entityCompleted);
|
|
255
284
|
|
|
285
|
+
// Get practitioner info when grouping by procedure
|
|
286
|
+
let practitionerId: string | undefined;
|
|
287
|
+
let practitionerName: string | undefined;
|
|
288
|
+
if (entityType === 'procedure' && entityAppointments.length > 0) {
|
|
289
|
+
const firstAppointment = entityAppointments[0];
|
|
290
|
+
practitionerId = firstAppointment.practitionerId;
|
|
291
|
+
practitionerName = firstAppointment.practitionerInfo?.name;
|
|
292
|
+
}
|
|
293
|
+
|
|
256
294
|
return {
|
|
257
295
|
entityId,
|
|
258
296
|
entityName: data.name,
|
|
@@ -266,6 +304,8 @@ export function calculateGroupedTimeEfficiencyMetrics(
|
|
|
266
304
|
totalUnderutilization: timeMetrics.totalUnderutilization,
|
|
267
305
|
averageOverrun: timeMetrics.averageOverrun,
|
|
268
306
|
averageUnderutilization: timeMetrics.averageUnderutilization,
|
|
307
|
+
...(practitionerId && { practitionerId }),
|
|
308
|
+
...(practitionerName && { practitionerName }),
|
|
269
309
|
};
|
|
270
310
|
});
|
|
271
311
|
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { Appointment } from '../../../types/appointment';
|
|
2
|
+
import { Timestamp } from 'firebase/firestore';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Trend period type
|
|
6
|
+
*/
|
|
7
|
+
export type TrendPeriod = 'week' | 'month' | 'quarter' | 'year';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Period information for trend grouping
|
|
11
|
+
*/
|
|
12
|
+
export interface PeriodInfo {
|
|
13
|
+
period: string; // e.g., "2024-W01", "2024-01", "2024-Q1", "2024"
|
|
14
|
+
startDate: Date;
|
|
15
|
+
endDate: Date;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Calculates the start and end dates for a given period
|
|
20
|
+
*/
|
|
21
|
+
export function getPeriodDates(date: Date, period: TrendPeriod): PeriodInfo {
|
|
22
|
+
const year = date.getFullYear();
|
|
23
|
+
const month = date.getMonth();
|
|
24
|
+
const day = date.getDate();
|
|
25
|
+
|
|
26
|
+
let startDate: Date;
|
|
27
|
+
let endDate: Date;
|
|
28
|
+
let periodString: string;
|
|
29
|
+
|
|
30
|
+
switch (period) {
|
|
31
|
+
case 'week': {
|
|
32
|
+
// Get Monday of the week
|
|
33
|
+
const dayOfWeek = date.getDay();
|
|
34
|
+
const diff = date.getDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1); // Adjust when day is Sunday
|
|
35
|
+
startDate = new Date(year, month, diff);
|
|
36
|
+
startDate.setHours(0, 0, 0, 0);
|
|
37
|
+
|
|
38
|
+
endDate = new Date(startDate);
|
|
39
|
+
endDate.setDate(endDate.getDate() + 6);
|
|
40
|
+
endDate.setHours(23, 59, 59, 999);
|
|
41
|
+
|
|
42
|
+
// ISO week format: YYYY-Www
|
|
43
|
+
const weekNumber = getWeekNumber(date);
|
|
44
|
+
periodString = `${year}-W${weekNumber.toString().padStart(2, '0')}`;
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
case 'month': {
|
|
49
|
+
startDate = new Date(year, month, 1);
|
|
50
|
+
startDate.setHours(0, 0, 0, 0);
|
|
51
|
+
|
|
52
|
+
endDate = new Date(year, month + 1, 0);
|
|
53
|
+
endDate.setHours(23, 59, 59, 999);
|
|
54
|
+
|
|
55
|
+
periodString = `${year}-${(month + 1).toString().padStart(2, '0')}`;
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
case 'quarter': {
|
|
60
|
+
const quarter = Math.floor(month / 3);
|
|
61
|
+
const quarterStartMonth = quarter * 3;
|
|
62
|
+
|
|
63
|
+
startDate = new Date(year, quarterStartMonth, 1);
|
|
64
|
+
startDate.setHours(0, 0, 0, 0);
|
|
65
|
+
|
|
66
|
+
endDate = new Date(year, quarterStartMonth + 3, 0);
|
|
67
|
+
endDate.setHours(23, 59, 59, 999);
|
|
68
|
+
|
|
69
|
+
periodString = `${year}-Q${quarter + 1}`;
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
case 'year': {
|
|
74
|
+
startDate = new Date(year, 0, 1);
|
|
75
|
+
startDate.setHours(0, 0, 0, 0);
|
|
76
|
+
|
|
77
|
+
endDate = new Date(year, 11, 31);
|
|
78
|
+
endDate.setHours(23, 59, 59, 999);
|
|
79
|
+
|
|
80
|
+
periodString = `${year}`;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
period: periodString,
|
|
87
|
+
startDate,
|
|
88
|
+
endDate,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Gets ISO week number for a date
|
|
94
|
+
*/
|
|
95
|
+
function getWeekNumber(date: Date): number {
|
|
96
|
+
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
|
97
|
+
const dayNum = d.getUTCDay() || 7;
|
|
98
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
99
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
100
|
+
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Groups appointments by trend period
|
|
105
|
+
*/
|
|
106
|
+
export function groupAppointmentsByPeriod(
|
|
107
|
+
appointments: Appointment[],
|
|
108
|
+
period: TrendPeriod,
|
|
109
|
+
): Map<string, Appointment[]> {
|
|
110
|
+
const periodMap = new Map<string, Appointment[]>();
|
|
111
|
+
|
|
112
|
+
appointments.forEach(appointment => {
|
|
113
|
+
const appointmentDate = appointment.appointmentStartTime.toDate();
|
|
114
|
+
const periodInfo = getPeriodDates(appointmentDate, period);
|
|
115
|
+
const periodKey = periodInfo.period;
|
|
116
|
+
|
|
117
|
+
if (!periodMap.has(periodKey)) {
|
|
118
|
+
periodMap.set(periodKey, []);
|
|
119
|
+
}
|
|
120
|
+
periodMap.get(periodKey)!.push(appointment);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
return periodMap;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Generates all periods between start and end date for a given period type
|
|
128
|
+
*/
|
|
129
|
+
export function generatePeriods(
|
|
130
|
+
startDate: Date,
|
|
131
|
+
endDate: Date,
|
|
132
|
+
period: TrendPeriod,
|
|
133
|
+
): PeriodInfo[] {
|
|
134
|
+
const periods: PeriodInfo[] = [];
|
|
135
|
+
const current = new Date(startDate);
|
|
136
|
+
|
|
137
|
+
while (current <= endDate) {
|
|
138
|
+
const periodInfo = getPeriodDates(current, period);
|
|
139
|
+
|
|
140
|
+
// Only add if period overlaps with our date range
|
|
141
|
+
if (periodInfo.endDate >= startDate && periodInfo.startDate <= endDate) {
|
|
142
|
+
periods.push(periodInfo);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Move to next period
|
|
146
|
+
switch (period) {
|
|
147
|
+
case 'week':
|
|
148
|
+
current.setDate(current.getDate() + 7);
|
|
149
|
+
break;
|
|
150
|
+
case 'month':
|
|
151
|
+
current.setMonth(current.getMonth() + 1);
|
|
152
|
+
break;
|
|
153
|
+
case 'quarter':
|
|
154
|
+
current.setMonth(current.getMonth() + 3);
|
|
155
|
+
break;
|
|
156
|
+
case 'year':
|
|
157
|
+
current.setFullYear(current.getFullYear() + 1);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return periods.sort((a, b) => a.startDate.getTime() - b.startDate.getTime());
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Calculates percentage change between two values
|
|
167
|
+
* @param current - Current value
|
|
168
|
+
* @param previous - Previous value
|
|
169
|
+
* @returns Percentage change (positive = increase, negative = decrease)
|
|
170
|
+
*/
|
|
171
|
+
export function calculatePercentageChange(current: number, previous: number): number {
|
|
172
|
+
if (previous === 0) {
|
|
173
|
+
return current > 0 ? 100 : 0;
|
|
174
|
+
}
|
|
175
|
+
return ((current - previous) / previous) * 100;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Gets trend direction and percentage change
|
|
180
|
+
*/
|
|
181
|
+
export interface TrendChange {
|
|
182
|
+
value: number;
|
|
183
|
+
previousValue: number;
|
|
184
|
+
percentageChange: number;
|
|
185
|
+
direction: 'up' | 'down' | 'stable';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getTrendChange(current: number, previous: number): TrendChange {
|
|
189
|
+
const percentageChange = calculatePercentageChange(current, previous);
|
|
190
|
+
const direction: 'up' | 'down' | 'stable' =
|
|
191
|
+
percentageChange > 0.01 ? 'up' : percentageChange < -0.01 ? 'down' : 'stable';
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
value: current,
|
|
195
|
+
previousValue: previous,
|
|
196
|
+
percentageChange: Math.abs(percentageChange),
|
|
197
|
+
direction,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
@@ -29,6 +29,57 @@ export class ReviewService extends BaseService {
|
|
|
29
29
|
super(db, auth, app);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Helper function to convert Firestore Timestamps to Date objects
|
|
34
|
+
* @param timestamp The timestamp to convert
|
|
35
|
+
* @returns A JavaScript Date object or null
|
|
36
|
+
*/
|
|
37
|
+
private convertTimestamp(timestamp: any): Date {
|
|
38
|
+
if (!timestamp) {
|
|
39
|
+
return new Date();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Firebase Timestamp object with __isTimestamp
|
|
43
|
+
if (timestamp && timestamp.__isTimestamp === true && typeof timestamp.seconds === 'number') {
|
|
44
|
+
return new Date(timestamp.seconds * 1000 + (timestamp.nanoseconds || 0) / 1000000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Firebase Firestore Timestamp with toDate method
|
|
48
|
+
if (timestamp && timestamp.toDate && typeof timestamp.toDate === 'function') {
|
|
49
|
+
return timestamp.toDate();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Already a Date object
|
|
53
|
+
if (timestamp instanceof Date) {
|
|
54
|
+
return timestamp;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// String or number
|
|
58
|
+
if (typeof timestamp === 'string' || typeof timestamp === 'number') {
|
|
59
|
+
const date = new Date(timestamp);
|
|
60
|
+
if (!isNaN(date.getTime())) {
|
|
61
|
+
return date;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Date();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Converts a Firestore document to a Review object with proper date handling
|
|
70
|
+
* @param docData The raw Firestore document data
|
|
71
|
+
* @returns A Review object with properly converted dates
|
|
72
|
+
*/
|
|
73
|
+
private convertDocToReview(docData: any): Review {
|
|
74
|
+
const review = docData as Review;
|
|
75
|
+
|
|
76
|
+
// Convert main review timestamps (all sub-reviews share the same creation date)
|
|
77
|
+
review.createdAt = this.convertTimestamp(docData.createdAt);
|
|
78
|
+
review.updatedAt = this.convertTimestamp(docData.updatedAt);
|
|
79
|
+
|
|
80
|
+
return review;
|
|
81
|
+
}
|
|
82
|
+
|
|
32
83
|
/**
|
|
33
84
|
* Creates a new review
|
|
34
85
|
* @param data - The review data to create
|
|
@@ -206,7 +257,7 @@ export class ReviewService extends BaseService {
|
|
|
206
257
|
return null;
|
|
207
258
|
}
|
|
208
259
|
|
|
209
|
-
const review = { ...docSnap.data(), id: reviewId }
|
|
260
|
+
const review = { ...this.convertDocToReview(docSnap.data()), id: reviewId };
|
|
210
261
|
|
|
211
262
|
try {
|
|
212
263
|
// Fetch the associated appointment to enhance with entity names
|
|
@@ -293,7 +344,7 @@ export class ReviewService extends BaseService {
|
|
|
293
344
|
async getReviewsByPatient(patientId: string): Promise<Review[]> {
|
|
294
345
|
const q = query(collection(this.db, REVIEWS_COLLECTION), where('patientId', '==', patientId));
|
|
295
346
|
const snapshot = await getDocs(q);
|
|
296
|
-
const reviews = snapshot.docs.map(doc => doc.data()
|
|
347
|
+
const reviews = snapshot.docs.map(doc => this.convertDocToReview(doc.data()));
|
|
297
348
|
|
|
298
349
|
// Enhance reviews with entity names from appointments
|
|
299
350
|
const enhancedReviews = await Promise.all(
|
|
@@ -364,8 +415,8 @@ export class ReviewService extends BaseService {
|
|
|
364
415
|
);
|
|
365
416
|
const snapshot = await getDocs(q);
|
|
366
417
|
const reviews = snapshot.docs.map(doc => {
|
|
367
|
-
const
|
|
368
|
-
return { ...
|
|
418
|
+
const review = this.convertDocToReview(doc.data());
|
|
419
|
+
return { ...review, id: doc.id };
|
|
369
420
|
});
|
|
370
421
|
|
|
371
422
|
console.log('🔍 ReviewService.getReviewsByClinic - Found reviews before enhancement:', {
|
|
@@ -459,8 +510,8 @@ export class ReviewService extends BaseService {
|
|
|
459
510
|
);
|
|
460
511
|
const snapshot = await getDocs(q);
|
|
461
512
|
const reviews = snapshot.docs.map(doc => {
|
|
462
|
-
const
|
|
463
|
-
return { ...
|
|
513
|
+
const review = this.convertDocToReview(doc.data());
|
|
514
|
+
return { ...review, id: doc.id };
|
|
464
515
|
});
|
|
465
516
|
|
|
466
517
|
console.log('🔍 ReviewService.getReviewsByPractitioner - Found reviews before enhancement:', {
|
|
@@ -644,7 +695,7 @@ export class ReviewService extends BaseService {
|
|
|
644
695
|
return null;
|
|
645
696
|
}
|
|
646
697
|
|
|
647
|
-
return snapshot.docs[0].data()
|
|
698
|
+
return this.convertDocToReview(snapshot.docs[0].data());
|
|
648
699
|
}
|
|
649
700
|
|
|
650
701
|
/**
|