@blackcode_sa/metaestetics-api 1.8.12 → 1.8.14
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 +2 -0
- package/dist/admin/index.d.ts +2 -0
- package/dist/backoffice/index.d.mts +1 -0
- package/dist/backoffice/index.d.ts +1 -0
- package/dist/index.d.mts +28 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +141 -222
- package/dist/index.mjs +144 -223
- package/package.json +1 -1
- package/src/services/clinic/clinic.service.ts +33 -0
- package/src/services/clinic/utils/filter.utils.ts +54 -225
- package/src/services/practitioner/practitioner.service.ts +51 -117
- package/src/services/procedure/procedure.service.ts +43 -3
- package/src/types/clinic/index.ts +1 -0
- package/src/types/practitioner/index.ts +1 -0
|
@@ -32,6 +32,7 @@ export async function getClinicsByFilters(
|
|
|
32
32
|
procedureTechnology?: string;
|
|
33
33
|
minRating?: number;
|
|
34
34
|
maxRating?: number;
|
|
35
|
+
nameSearch?: string;
|
|
35
36
|
pagination?: number;
|
|
36
37
|
lastDoc?: any;
|
|
37
38
|
isActive?: boolean;
|
|
@@ -40,251 +41,79 @@ export async function getClinicsByFilters(
|
|
|
40
41
|
clinics: (Clinic & { distance?: number })[];
|
|
41
42
|
lastDoc: any;
|
|
42
43
|
}> {
|
|
43
|
-
|
|
44
|
-
"[FILTER_UTILS] Starting clinic filtering with criteria:",
|
|
45
|
-
filters
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
// Determine if we're doing a geo query or a regular query
|
|
49
|
-
const isGeoQuery =
|
|
50
|
-
filters.center && filters.radiusInKm && filters.radiusInKm > 0;
|
|
51
|
-
|
|
52
|
-
// Initialize base constraints
|
|
44
|
+
// 1. Prepare Firestore constraints
|
|
53
45
|
const constraints: QueryConstraint[] = [];
|
|
46
|
+
constraints.push(where("isActive", "==", filters.isActive ?? true));
|
|
54
47
|
|
|
55
|
-
//
|
|
56
|
-
if (filters.isActive !== undefined) {
|
|
57
|
-
constraints.push(where("isActive", "==", filters.isActive));
|
|
58
|
-
} else {
|
|
59
|
-
constraints.push(where("isActive", "==", true));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Add tag filtering if specified
|
|
48
|
+
// Tag (only first, Firestore limitation)
|
|
63
49
|
if (filters.tags && filters.tags.length > 0) {
|
|
64
|
-
// Note: We can only use array-contains-any once per query, so we're selecting one tag
|
|
65
|
-
// for the query and we'll filter the remaining tags in memory
|
|
66
50
|
constraints.push(where("tags", "array-contains", filters.tags[0]));
|
|
67
51
|
}
|
|
68
52
|
|
|
69
|
-
//
|
|
70
|
-
// Order of specificity: technology > subcategory > category > family
|
|
53
|
+
// Procedure filters (most specific)
|
|
71
54
|
if (filters.procedureTechnology) {
|
|
72
|
-
constraints.push(
|
|
73
|
-
where("servicesInfo.technology", "==", filters.procedureTechnology)
|
|
74
|
-
);
|
|
55
|
+
constraints.push(where("servicesInfo.technology", "==", filters.procedureTechnology));
|
|
75
56
|
} else if (filters.procedureSubcategory) {
|
|
76
|
-
constraints.push(
|
|
77
|
-
where("servicesInfo.subCategory", "==", filters.procedureSubcategory)
|
|
78
|
-
);
|
|
57
|
+
constraints.push(where("servicesInfo.subCategory", "==", filters.procedureSubcategory));
|
|
79
58
|
} else if (filters.procedureCategory) {
|
|
80
|
-
constraints.push(
|
|
81
|
-
where("servicesInfo.category", "==", filters.procedureCategory)
|
|
82
|
-
);
|
|
59
|
+
constraints.push(where("servicesInfo.category", "==", filters.procedureCategory));
|
|
83
60
|
} else if (filters.procedureFamily) {
|
|
84
|
-
constraints.push(
|
|
85
|
-
where("servicesInfo.procedureFamily", "==", filters.procedureFamily)
|
|
86
|
-
);
|
|
61
|
+
constraints.push(where("servicesInfo.procedureFamily", "==", filters.procedureFamily));
|
|
87
62
|
}
|
|
88
63
|
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
constraints.push(
|
|
64
|
+
// Text search by nameLower
|
|
65
|
+
let useNameLower = false;
|
|
66
|
+
let searchTerm = "";
|
|
67
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
68
|
+
searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
69
|
+
constraints.push(where("nameLower", ">=", searchTerm));
|
|
70
|
+
constraints.push(where("nameLower", "<=", searchTerm + "\uf8ff"));
|
|
71
|
+
useNameLower = true;
|
|
95
72
|
}
|
|
96
73
|
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
if (isGeoQuery) {
|
|
105
|
-
const center = filters.center!;
|
|
106
|
-
const radiusInKm = filters.radiusInKm!;
|
|
107
|
-
|
|
108
|
-
// Get the geohash query bounds
|
|
109
|
-
const bounds = geohashQueryBounds(
|
|
110
|
-
[center.latitude, center.longitude],
|
|
111
|
-
radiusInKm * 1000 // Convert to meters
|
|
112
|
-
);
|
|
113
|
-
|
|
114
|
-
// Collect matching clinics from all bounds
|
|
115
|
-
const matchingClinics: (Clinic & { distance: number })[] = [];
|
|
116
|
-
|
|
117
|
-
// Execute queries for each bound
|
|
118
|
-
for (const bound of bounds) {
|
|
119
|
-
// Create a geo query for this bound
|
|
120
|
-
const geoConstraints = [
|
|
121
|
-
...constraints,
|
|
122
|
-
where("location.geohash", ">=", bound[0]),
|
|
123
|
-
where("location.geohash", "<=", bound[1]),
|
|
124
|
-
];
|
|
125
|
-
|
|
126
|
-
const q = query(collection(db, CLINICS_COLLECTION), ...geoConstraints);
|
|
127
|
-
const querySnapshot = await getDocs(q);
|
|
128
|
-
|
|
129
|
-
console.log(
|
|
130
|
-
`[FILTER_UTILS] Found ${querySnapshot.docs.length} clinics in geo bound`
|
|
131
|
-
);
|
|
132
|
-
|
|
133
|
-
// Process results and filter by actual distance
|
|
134
|
-
for (const doc of querySnapshot.docs) {
|
|
135
|
-
const clinic = { ...doc.data(), id: doc.id } as Clinic;
|
|
136
|
-
|
|
137
|
-
// Calculate actual distance
|
|
138
|
-
const distance = distanceBetween(
|
|
139
|
-
[center.latitude, center.longitude],
|
|
140
|
-
[clinic.location.latitude, clinic.location.longitude]
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
// Convert to kilometers
|
|
144
|
-
const distanceInKm = distance / 1000;
|
|
145
|
-
|
|
146
|
-
// Check if within radius
|
|
147
|
-
if (distanceInKm <= radiusInKm) {
|
|
148
|
-
// Add distance to clinic object
|
|
149
|
-
matchingClinics.push({
|
|
150
|
-
...clinic,
|
|
151
|
-
distance: distanceInKm,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Apply additional filters that couldn't be applied in the query
|
|
158
|
-
let filteredClinics = matchingClinics;
|
|
159
|
-
|
|
160
|
-
// Filter by multiple tags if more than one tag was specified
|
|
161
|
-
if (filters.tags && filters.tags.length > 1) {
|
|
162
|
-
filteredClinics = filteredClinics.filter((clinic) => {
|
|
163
|
-
// Check if clinic has all specified tags
|
|
164
|
-
return filters.tags!.every((tag) => clinic.tags.includes(tag));
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Filter by rating
|
|
169
|
-
if (filters.minRating !== undefined) {
|
|
170
|
-
filteredClinics = filteredClinics.filter(
|
|
171
|
-
(clinic) => clinic.reviewInfo.averageRating >= filters.minRating!
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (filters.maxRating !== undefined) {
|
|
176
|
-
filteredClinics = filteredClinics.filter(
|
|
177
|
-
(clinic) => clinic.reviewInfo.averageRating <= filters.maxRating!
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Sort by distance
|
|
182
|
-
filteredClinics.sort((a, b) => a.distance - b.distance);
|
|
183
|
-
|
|
184
|
-
// Apply pagination after all filters have been applied
|
|
185
|
-
if (filters.pagination && filters.pagination > 0) {
|
|
186
|
-
// If we have a lastDoc, find its index
|
|
187
|
-
let startIndex = 0;
|
|
188
|
-
if (filters.lastDoc) {
|
|
189
|
-
const lastDocIndex = filteredClinics.findIndex(
|
|
190
|
-
(clinic) => clinic.id === filters.lastDoc.id
|
|
191
|
-
);
|
|
192
|
-
if (lastDocIndex !== -1) {
|
|
193
|
-
startIndex = lastDocIndex + 1;
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Get paginated subset
|
|
198
|
-
const paginatedClinics = filteredClinics.slice(
|
|
199
|
-
startIndex,
|
|
200
|
-
startIndex + filters.pagination
|
|
201
|
-
);
|
|
202
|
-
|
|
203
|
-
// Set last document for next pagination
|
|
204
|
-
lastVisibleDoc =
|
|
205
|
-
paginatedClinics.length > 0
|
|
206
|
-
? paginatedClinics[paginatedClinics.length - 1]
|
|
207
|
-
: null;
|
|
74
|
+
// Rating filters
|
|
75
|
+
if (filters.minRating !== undefined) {
|
|
76
|
+
constraints.push(where("reviewInfo.averageRating", ">=", filters.minRating));
|
|
77
|
+
}
|
|
78
|
+
if (filters.maxRating !== undefined) {
|
|
79
|
+
constraints.push(where("reviewInfo.averageRating", "<=", filters.maxRating));
|
|
80
|
+
}
|
|
208
81
|
|
|
209
|
-
|
|
82
|
+
// Pagination and ordering
|
|
83
|
+
constraints.push(orderBy("nameLower"));
|
|
84
|
+
if (filters.lastDoc) {
|
|
85
|
+
if (typeof filters.lastDoc.data === "function") {
|
|
86
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
87
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
88
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
210
89
|
} else {
|
|
211
|
-
|
|
90
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
212
91
|
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
216
|
-
const querySnapshot = await getDocs(q);
|
|
92
|
+
}
|
|
93
|
+
constraints.push(limit(filters.pagination || 5));
|
|
217
94
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
95
|
+
// 2. Firestore query
|
|
96
|
+
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
97
|
+
const querySnapshot = await getDocs(q);
|
|
98
|
+
let clinics = querySnapshot.docs.map(doc => ({ ...doc.data(), id: doc.id } as Clinic));
|
|
221
99
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
100
|
+
// 3. In-memory filters for multi-tag and geo-radius
|
|
101
|
+
if (filters.tags && filters.tags.length > 1) {
|
|
102
|
+
clinics = clinics.filter(clinic => filters.tags!.every(tag => clinic.tags.includes(tag)));
|
|
103
|
+
}
|
|
104
|
+
if (filters.center && filters.radiusInKm) {
|
|
105
|
+
clinics = clinics.filter(clinic => {
|
|
106
|
+
const distance = distanceBetween(
|
|
107
|
+
[filters.center!.latitude, filters.center!.longitude],
|
|
108
|
+
[clinic.location.latitude, clinic.location.longitude]
|
|
109
|
+
) / 1000;
|
|
110
|
+
// Optionally attach distance for frontend
|
|
111
|
+
(clinic as any).distance = distance;
|
|
112
|
+
return distance <= filters.radiusInKm!;
|
|
225
113
|
});
|
|
226
|
-
|
|
227
|
-
// Apply filters that couldn't be applied in the query
|
|
228
|
-
let filteredClinics = clinics;
|
|
229
|
-
|
|
230
|
-
// Calculate distance for each clinic if center coordinates are provided
|
|
231
|
-
if (filters.center) {
|
|
232
|
-
const center = filters.center;
|
|
233
|
-
const clinicsWithDistance: (Clinic & { distance: number })[] = [];
|
|
234
|
-
|
|
235
|
-
filteredClinics.forEach((clinic) => {
|
|
236
|
-
const distance = distanceBetween(
|
|
237
|
-
[center.latitude, center.longitude],
|
|
238
|
-
[clinic.location.latitude, clinic.location.longitude]
|
|
239
|
-
);
|
|
240
|
-
|
|
241
|
-
clinicsWithDistance.push({
|
|
242
|
-
...clinic,
|
|
243
|
-
distance: distance / 1000, // Convert to kilometers
|
|
244
|
-
});
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
// Replace filtered clinics with the version that includes distances
|
|
248
|
-
filteredClinics = clinicsWithDistance;
|
|
249
|
-
|
|
250
|
-
// Sort by distance - use type assertion to fix type error
|
|
251
|
-
(filteredClinics as (Clinic & { distance: number })[]).sort(
|
|
252
|
-
(a, b) => a.distance - b.distance
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// Filter by multiple tags if more than one tag was specified
|
|
257
|
-
if (filters.tags && filters.tags.length > 1) {
|
|
258
|
-
filteredClinics = filteredClinics.filter((clinic) => {
|
|
259
|
-
// Check if clinic has all specified tags
|
|
260
|
-
return filters.tags!.every((tag) => clinic.tags.includes(tag));
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// Filter by rating
|
|
265
|
-
if (filters.minRating !== undefined) {
|
|
266
|
-
filteredClinics = filteredClinics.filter(
|
|
267
|
-
(clinic) => clinic.reviewInfo.averageRating >= filters.minRating!
|
|
268
|
-
);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
if (filters.maxRating !== undefined) {
|
|
272
|
-
filteredClinics = filteredClinics.filter(
|
|
273
|
-
(clinic) => clinic.reviewInfo.averageRating <= filters.maxRating!
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Set last document for pagination
|
|
278
|
-
lastVisibleDoc =
|
|
279
|
-
querySnapshot.docs.length > 0
|
|
280
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
281
|
-
: null;
|
|
282
|
-
|
|
283
|
-
clinicsResult = filteredClinics;
|
|
284
114
|
}
|
|
285
115
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
};
|
|
116
|
+
// 4. Return results and lastDoc
|
|
117
|
+
const lastVisibleDoc = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
118
|
+
return { clinics, lastDoc: lastVisibleDoc };
|
|
290
119
|
}
|
|
@@ -193,6 +193,7 @@ export class PractitionerService extends BaseService {
|
|
|
193
193
|
};
|
|
194
194
|
|
|
195
195
|
// Create practitioner object
|
|
196
|
+
const fullNameLower = `${validData.basicInfo.firstName} ${validData.basicInfo.lastName}`.toLowerCase();
|
|
196
197
|
const practitioner: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
197
198
|
createdAt: FieldValue;
|
|
198
199
|
updatedAt: FieldValue;
|
|
@@ -203,6 +204,7 @@ export class PractitionerService extends BaseService {
|
|
|
203
204
|
validData.basicInfo,
|
|
204
205
|
practitionerId
|
|
205
206
|
),
|
|
207
|
+
fullNameLower: fullNameLower, // Ensure this is present
|
|
206
208
|
certification: validData.certification,
|
|
207
209
|
clinics: validData.clinics || [],
|
|
208
210
|
clinicWorkingHours: validData.clinicWorkingHours || [],
|
|
@@ -346,6 +348,8 @@ export class PractitionerService extends BaseService {
|
|
|
346
348
|
|
|
347
349
|
const proceduresInfo: ProcedureSummaryInfo[] = [];
|
|
348
350
|
|
|
351
|
+
// Add fullNameLower for draft
|
|
352
|
+
const fullNameLowerDraft = `${validatedData.basicInfo.firstName} ${validatedData.basicInfo.lastName}`.toLowerCase();
|
|
349
353
|
const practitionerData: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
350
354
|
createdAt: ReturnType<typeof serverTimestamp>;
|
|
351
355
|
updatedAt: ReturnType<typeof serverTimestamp>;
|
|
@@ -356,6 +360,7 @@ export class PractitionerService extends BaseService {
|
|
|
356
360
|
validatedData.basicInfo,
|
|
357
361
|
practitionerId
|
|
358
362
|
),
|
|
363
|
+
fullNameLower: fullNameLowerDraft, // Ensure this is present
|
|
359
364
|
certification: validatedData.certification,
|
|
360
365
|
clinics: clinics,
|
|
361
366
|
clinicWorkingHours: validatedData.clinicWorkingHours || [],
|
|
@@ -721,7 +726,7 @@ export class PractitionerService extends BaseService {
|
|
|
721
726
|
const currentPractitioner = practitionerDoc.data() as Practitioner;
|
|
722
727
|
|
|
723
728
|
// Process basicInfo if it's being updated to handle profile photo uploads
|
|
724
|
-
let processedData = { ...validData };
|
|
729
|
+
let processedData: UpdatePractitionerData & { fullNameLower?: string } = { ...validData };
|
|
725
730
|
if (validData.basicInfo) {
|
|
726
731
|
processedData.basicInfo = await this.processBasicInfo(
|
|
727
732
|
validData.basicInfo as PractitionerBasicInfo & {
|
|
@@ -729,10 +734,12 @@ export class PractitionerService extends BaseService {
|
|
|
729
734
|
},
|
|
730
735
|
practitionerId
|
|
731
736
|
);
|
|
737
|
+
// Always update fullNameLower when basicInfo changes
|
|
738
|
+
processedData.fullNameLower = `${processedData.basicInfo.firstName} ${processedData.basicInfo.lastName}`.toLowerCase();
|
|
732
739
|
}
|
|
733
740
|
|
|
734
741
|
// Prepare update data
|
|
735
|
-
const updateData = {
|
|
742
|
+
const updateData: any = {
|
|
736
743
|
...processedData,
|
|
737
744
|
updatedAt: serverTimestamp(),
|
|
738
745
|
};
|
|
@@ -1036,22 +1043,14 @@ export class PractitionerService extends BaseService {
|
|
|
1036
1043
|
includeDraftPractitioners?: boolean;
|
|
1037
1044
|
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
1038
1045
|
try {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
filters
|
|
1042
|
-
);
|
|
1043
|
-
|
|
1044
|
-
const constraints = [];
|
|
1045
|
-
|
|
1046
|
-
// Filter by status if not including drafts
|
|
1046
|
+
// 1. Prepare Firestore constraints
|
|
1047
|
+
const constraints: any[] = [];
|
|
1047
1048
|
if (!filters.includeDraftPractitioners) {
|
|
1048
1049
|
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1049
1050
|
}
|
|
1050
|
-
|
|
1051
|
-
// Filter by active status
|
|
1052
1051
|
constraints.push(where("isActive", "==", true));
|
|
1053
1052
|
|
|
1054
|
-
//
|
|
1053
|
+
// Certifications
|
|
1055
1054
|
if (filters.certifications && filters.certifications.length > 0) {
|
|
1056
1055
|
constraints.push(
|
|
1057
1056
|
where(
|
|
@@ -1062,138 +1061,73 @@ export class PractitionerService extends BaseService {
|
|
|
1062
1061
|
);
|
|
1063
1062
|
}
|
|
1064
1063
|
|
|
1065
|
-
//
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
if (filters.pagination && filters.pagination > 0) {
|
|
1071
|
-
if (filters.lastDoc) {
|
|
1072
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1073
|
-
}
|
|
1074
|
-
constraints.push(limit(filters.pagination));
|
|
1064
|
+
// Text search by fullNameLower
|
|
1065
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1066
|
+
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1067
|
+
constraints.push(where("fullNameLower", ">=", searchTerm));
|
|
1068
|
+
constraints.push(where("fullNameLower", "<=", searchTerm + "\uf8ff"));
|
|
1075
1069
|
}
|
|
1076
1070
|
|
|
1077
|
-
//
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
);
|
|
1087
|
-
|
|
1088
|
-
// Convert docs to practitioners
|
|
1089
|
-
let practitioners = querySnapshot.docs.map((doc) => {
|
|
1090
|
-
return { ...doc.data(), id: doc.id } as Practitioner;
|
|
1091
|
-
});
|
|
1092
|
-
|
|
1093
|
-
// Get last document for pagination
|
|
1094
|
-
const lastDoc =
|
|
1095
|
-
querySnapshot.docs.length > 0
|
|
1096
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1097
|
-
: null;
|
|
1098
|
-
|
|
1099
|
-
// Further filter results in memory
|
|
1100
|
-
|
|
1101
|
-
// Filter by name search if specified
|
|
1102
|
-
if (filters.nameSearch && filters.nameSearch.trim() !== "") {
|
|
1103
|
-
const searchTerm = filters.nameSearch.toLowerCase().trim();
|
|
1104
|
-
practitioners = practitioners.filter((practitioner) => {
|
|
1105
|
-
const fullName =
|
|
1106
|
-
`${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`.toLowerCase();
|
|
1107
|
-
return fullName.includes(searchTerm);
|
|
1108
|
-
});
|
|
1071
|
+
// Procedure filters (if mapped to fields)
|
|
1072
|
+
if (filters.procedureTechnology) {
|
|
1073
|
+
constraints.push(where("proceduresInfo.technologyName", "==", filters.procedureTechnology));
|
|
1074
|
+
} else if (filters.procedureSubcategory) {
|
|
1075
|
+
constraints.push(where("proceduresInfo.subcategoryName", "==", filters.procedureSubcategory));
|
|
1076
|
+
} else if (filters.procedureCategory) {
|
|
1077
|
+
constraints.push(where("proceduresInfo.categoryName", "==", filters.procedureCategory));
|
|
1078
|
+
} else if (filters.procedureFamily) {
|
|
1079
|
+
constraints.push(where("proceduresInfo.family", "==", filters.procedureFamily));
|
|
1109
1080
|
}
|
|
1110
1081
|
|
|
1111
|
-
//
|
|
1112
|
-
if (filters.
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
});
|
|
1082
|
+
// Rating filters
|
|
1083
|
+
if (filters.minRating !== undefined) {
|
|
1084
|
+
constraints.push(where("reviewInfo.averageRating", ">=", filters.minRating));
|
|
1085
|
+
}
|
|
1086
|
+
if (filters.maxRating !== undefined) {
|
|
1087
|
+
constraints.push(where("reviewInfo.averageRating", "<=", filters.maxRating));
|
|
1118
1088
|
}
|
|
1119
1089
|
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
filters.
|
|
1124
|
-
|
|
1125
|
-
filters.
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
// Apply hierarchical filter - most specific first
|
|
1131
|
-
if (filters.procedureTechnology) {
|
|
1132
|
-
return procedure.technologyName === filters.procedureTechnology;
|
|
1133
|
-
}
|
|
1134
|
-
if (filters.procedureSubcategory) {
|
|
1135
|
-
return procedure.subcategoryName === filters.procedureSubcategory;
|
|
1136
|
-
}
|
|
1137
|
-
if (filters.procedureCategory) {
|
|
1138
|
-
return procedure.categoryName === filters.procedureCategory;
|
|
1139
|
-
}
|
|
1140
|
-
if (filters.procedureFamily) {
|
|
1141
|
-
return procedure.family === filters.procedureFamily;
|
|
1142
|
-
}
|
|
1143
|
-
return false;
|
|
1144
|
-
});
|
|
1145
|
-
});
|
|
1090
|
+
// Pagination and ordering
|
|
1091
|
+
constraints.push(orderBy("fullNameLower"));
|
|
1092
|
+
if (filters.lastDoc) {
|
|
1093
|
+
if (typeof filters.lastDoc.data === "function") {
|
|
1094
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1095
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
1096
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
1097
|
+
} else {
|
|
1098
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1099
|
+
}
|
|
1146
1100
|
}
|
|
1101
|
+
constraints.push(limit(filters.pagination || 5));
|
|
1102
|
+
|
|
1103
|
+
// 2. Firestore query
|
|
1104
|
+
const q = query(collection(this.db, PRACTITIONERS_COLLECTION), ...constraints);
|
|
1105
|
+
const querySnapshot = await getDocs(q);
|
|
1106
|
+
let practitioners = querySnapshot.docs.map(doc => ({ ...doc.data(), id: doc.id } as Practitioner));
|
|
1147
1107
|
|
|
1148
|
-
//
|
|
1108
|
+
// 3. In-memory filter ONLY for geo-radius (if needed)
|
|
1149
1109
|
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1150
1110
|
const location = filters.location;
|
|
1151
1111
|
const radiusInKm = filters.radiusInKm;
|
|
1152
|
-
|
|
1153
1112
|
practitioners = practitioners.filter((practitioner) => {
|
|
1154
1113
|
// Use the aggregated clinicsInfo to check if any clinic is within range
|
|
1155
1114
|
const clinics = practitioner.clinicsInfo || [];
|
|
1156
|
-
|
|
1157
|
-
// Check if any clinic is within the specified radius
|
|
1158
1115
|
return clinics.some((clinic) => {
|
|
1159
1116
|
// Calculate distance
|
|
1160
1117
|
const distance = distanceBetween(
|
|
1161
1118
|
[location.latitude, location.longitude],
|
|
1162
1119
|
[clinic.location.latitude, clinic.location.longitude]
|
|
1163
1120
|
);
|
|
1164
|
-
|
|
1165
1121
|
// Convert to kilometers
|
|
1166
1122
|
const distanceInKm = distance / 1000;
|
|
1167
|
-
|
|
1168
1123
|
// Check if within radius
|
|
1169
1124
|
return distanceInKm <= radiusInKm;
|
|
1170
1125
|
});
|
|
1171
1126
|
});
|
|
1172
1127
|
}
|
|
1173
1128
|
|
|
1174
|
-
//
|
|
1175
|
-
|
|
1176
|
-
practitioners = practitioners.filter(
|
|
1177
|
-
(p) => p.reviewInfo.averageRating >= filters.minRating!
|
|
1178
|
-
);
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
if (filters.maxRating !== undefined) {
|
|
1182
|
-
practitioners = practitioners.filter(
|
|
1183
|
-
(p) => p.reviewInfo.averageRating <= filters.maxRating!
|
|
1184
|
-
);
|
|
1185
|
-
}
|
|
1186
|
-
|
|
1187
|
-
console.log(
|
|
1188
|
-
`[PRACTITIONER_SERVICE] Filtered to ${practitioners.length} practitioners`
|
|
1189
|
-
);
|
|
1190
|
-
|
|
1191
|
-
// Apply pagination after all filters have been applied
|
|
1192
|
-
// This is a secondary pagination for in-memory filtered results
|
|
1193
|
-
if (filters.pagination && filters.pagination > 0) {
|
|
1194
|
-
practitioners = practitioners.slice(0, filters.pagination);
|
|
1195
|
-
}
|
|
1196
|
-
|
|
1129
|
+
// 4. Return results and lastDoc
|
|
1130
|
+
const lastDoc = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
1197
1131
|
return {
|
|
1198
1132
|
practitioners,
|
|
1199
1133
|
lastDoc,
|
|
@@ -660,7 +660,7 @@ export class ProcedureService extends BaseService {
|
|
|
660
660
|
// Handle Category/Subcategory/Technology/Product Changes
|
|
661
661
|
let finalCategoryId = existingProcedure.category.id;
|
|
662
662
|
if (validatedData.name) {
|
|
663
|
-
updatedProcedureData.nameLower = validatedData.
|
|
663
|
+
updatedProcedureData.nameLower = validatedData.name.toLowerCase();
|
|
664
664
|
}
|
|
665
665
|
if (validatedData.categoryId) {
|
|
666
666
|
const category = await this.categoryService.getById(
|
|
@@ -955,8 +955,17 @@ export class ProcedureService extends BaseService {
|
|
|
955
955
|
constraints.push(orderBy("nameLower"));
|
|
956
956
|
}
|
|
957
957
|
if (filters.lastDoc) {
|
|
958
|
-
//
|
|
959
|
-
|
|
958
|
+
// Firestore pagination: support both QueryDocumentSnapshot and value arrays
|
|
959
|
+
if (typeof filters.lastDoc.data === "function") {
|
|
960
|
+
// QueryDocumentSnapshot
|
|
961
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
962
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
963
|
+
// Array of values for multiple orderBy fields
|
|
964
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
965
|
+
} else {
|
|
966
|
+
// Single value
|
|
967
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
968
|
+
}
|
|
960
969
|
}
|
|
961
970
|
if (filters.pagination && filters.pagination > 0) {
|
|
962
971
|
constraints.push(limit(filters.pagination));
|
|
@@ -1174,4 +1183,35 @@ export class ProcedureService extends BaseService {
|
|
|
1174
1183
|
const savedDoc = await getDoc(procedureRef);
|
|
1175
1184
|
return savedDoc.data() as Procedure;
|
|
1176
1185
|
}
|
|
1186
|
+
|
|
1187
|
+
/**
|
|
1188
|
+
* Gets all procedures with minimal info for map display (id, name, clinicId, clinicName, address, latitude, longitude)
|
|
1189
|
+
* This is optimized for mobile map usage to reduce payload size.
|
|
1190
|
+
* @returns Array of minimal procedure info for map
|
|
1191
|
+
*/
|
|
1192
|
+
async getProceduresForMap(): Promise<{
|
|
1193
|
+
id: string;
|
|
1194
|
+
name: string;
|
|
1195
|
+
clinicId: string | undefined;
|
|
1196
|
+
clinicName: string | undefined;
|
|
1197
|
+
address: string;
|
|
1198
|
+
latitude: number | undefined;
|
|
1199
|
+
longitude: number | undefined;
|
|
1200
|
+
}[]> {
|
|
1201
|
+
const proceduresRef = collection(this.db, PROCEDURES_COLLECTION);
|
|
1202
|
+
const snapshot = await getDocs(proceduresRef);
|
|
1203
|
+
const proceduresForMap = snapshot.docs.map(doc => {
|
|
1204
|
+
const data = doc.data();
|
|
1205
|
+
return {
|
|
1206
|
+
id: doc.id,
|
|
1207
|
+
name: data.name,
|
|
1208
|
+
clinicId: data.clinicInfo?.id,
|
|
1209
|
+
clinicName: data.clinicInfo?.name,
|
|
1210
|
+
address: data.clinicInfo?.location?.address || '',
|
|
1211
|
+
latitude: data.clinicInfo?.location?.latitude,
|
|
1212
|
+
longitude: data.clinicInfo?.location?.longitude,
|
|
1213
|
+
};
|
|
1214
|
+
});
|
|
1215
|
+
return proceduresForMap;
|
|
1216
|
+
}
|
|
1177
1217
|
}
|
|
@@ -89,6 +89,7 @@ export interface Practitioner {
|
|
|
89
89
|
id: string;
|
|
90
90
|
userRef: string;
|
|
91
91
|
basicInfo: PractitionerBasicInfo;
|
|
92
|
+
fullNameLower: string; // Add this line
|
|
92
93
|
certification: PractitionerCertification;
|
|
93
94
|
clinics: string[]; // Reference na klinike gde radi
|
|
94
95
|
clinicWorkingHours: PractitionerClinicWorkingHours[]; // Radno vreme za svaku kliniku
|