@blackcode_sa/metaestetics-api 1.8.11 → 1.8.13
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 +4 -0
- package/dist/admin/index.d.ts +4 -0
- package/dist/backoffice/index.d.mts +1 -0
- package/dist/backoffice/index.d.ts +1 -0
- package/dist/index.d.mts +36 -9
- package/dist/index.d.ts +36 -9
- package/dist/index.js +218 -375
- package/dist/index.mjs +221 -376
- package/package.json +1 -1
- package/src/admin/scripts/migrateProcedures.js +23 -0
- package/src/admin/scripts/serviceAccountKey.json +13 -0
- 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 +48 -115
- package/src/services/procedure/procedure.service.ts +123 -249
- package/src/types/clinic/index.ts +1 -0
- package/src/types/practitioner/index.ts +1 -0
- package/src/types/procedure/index.ts +6 -0
- package/src/validations/procedure.schema.ts +3 -0
package/package.json
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const admin = require("firebase-admin");
|
|
2
|
+
const serviceAccount = require("./serviceAccountKey.json");
|
|
3
|
+
|
|
4
|
+
admin.initializeApp({
|
|
5
|
+
credential: admin.credential.cert(serviceAccount),
|
|
6
|
+
projectId: "metaestetics"
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
const db = admin.firestore();
|
|
10
|
+
|
|
11
|
+
async function migrateProcedures() {
|
|
12
|
+
const snapshot = await db.collection("procedures").get();
|
|
13
|
+
for (const doc of snapshot.docs) {
|
|
14
|
+
const data = doc.data();
|
|
15
|
+
if (!data.nameLower && data.name) {
|
|
16
|
+
await doc.ref.update({ nameLower: data.name.toLowerCase() });
|
|
17
|
+
console.log(`Updated ${doc.id}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
console.log("Migration complete!");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
migrateProcedures();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "service_account",
|
|
3
|
+
"project_id": "metaestetics",
|
|
4
|
+
"private_key_id": "e2dccd35845667bdebcea99959ca40a52f54d27f",
|
|
5
|
+
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7yzLPjlr/6pSA\ns3Og/2kuwdAfoBr8+M2HoVlydy/WXm73u29i9ps+ilvUBB90PRkidBCaBmqOwjdV\n0MEgUWF+7Zi3PLbfuFGX929uzpAEWuNR3ZqiyrtqsnGycNw0tdccH/RM4pTgDrLP\nETyN6HlnKIcaPgpU/qKXasOYEv8UlMRuSAFG6ZjsMr15fHzECgHNUqDwHz4u1aN3\nNDwYAnucVIpCQqKjwp6nxtSO8WSLKP5P/E3J2Dob1EYEPf8UvhXwtWVXXI/rbIQe\nfnQopHSLesvw2poks9hCQXNsLNptCRG3hj1asNZ6HutMPWqQPAKss/FpkkF6/RNu\niOnWOzf/AgMBAAECggEAFnQpUx/WSZsmvmy2ep2PWgPaeq2ODIlDKeBk7YbKtXr9\nEanbm52Y2lV4vVTw3dkgVDpEceYqf39BVoVrUg3o9mA6Tk54Hy/OsbjoHfucxKiJ\nXZR9lNFgr1U+uvM7oSHM4pP/heHhoxie0Jti/iS5v1fdL4oTei4oCqq9UEWVMkSS\n52yoBH7oeH15WVp2B3KEVU7cAI5MeFXQxQ8idFfqqShhR5uQnwx8uR38b9vaq+Cy\nK5zV+dStsphVq3/gEsjmFkYe7BgpMQyHkw7kmNuISDYdy/W1G6t0kUNn5LzhJ88E\nICr1FttaNQDeBE4VgnC3Daj1hBOQyrbGszbgxmklgQKBgQDm1f3uyLwmVTsTF3sE\nsggTYsmz9AppI778IuWjhiegfmbWI5YsgGl9c9XOcBCZ+2vI7rIDeFneRxBX4TrT\nAPcT+z8AG5PiJuyOa7Nny+tNzplfPf/2X2MrQXaYvWihPWDZtXmqYY3p/Z8wkYhA\n1C/2vMLyKiP/Md7mtSBXmjgnrwKBgQDQRAQL69osb07mJDNoln+FWyQhJQoVQFSw\npPBkcic0d1gglW7ne2O43mOUG7t3EQBPgXg8xXMiv4dqz1PQpVJp4CewCklbAh/C\nfFmhWCUvgG21Sc3t0hpvsPLN3xeqVOUeB5FNhf5t+j4HqTGClD98Plks0iXVnfFO\nPmNYK764sQKBgD4W+UKtQ86bxlQQUMqmiH2OaOq6jcJSFyEC0fn2L9p/pXGcCNzX\nfYh9C9mHUy/X7NoTOlasnJ+pRcAdmREAhXUec4e340NFbQOx/IPC2fwHwkFYD+1Z\nIveTmC7lY6tbMx3cLmmh6+Ywjg0mWBv39x7LDzTMGPqfk3FC7vwhQ1GJAoGARpoY\nKRZuYsvlGl3BU75ZQpMQH3BYB7ZEP5HasKKGKeIfbQRbkXuh5cT2SvpPxeBsk4dX\nhHqHOotlU88vIbc5xgyoR6RlE8YXkC3pkKm6CW1nQ6LefbXRInYBCcuMUUDwXwq/\ntmErTIsdxikUUKkDEJJuVqRzEQS3DghWU0iZIjECgYEA5PMdyGMZJQVTzP/QjSjC\no5uEDGOAjg/wMGFfXRbKBcJZ1yoqUuSOKuuacyNborfTj3RTckl9uO/I68wnFk5p\nRNNndYQXWAmjSvPBKnRuE+eSE1eDPmpZPPBgJqwbSxUfWMprY7yEWpKF2oEbkzpD\nrUkzrniMVABYZnj8CoNV3DM=\n-----END PRIVATE KEY-----\n",
|
|
6
|
+
"client_email": "firebase-adminsdk-5nsyj@metaestetics.iam.gserviceaccount.com",
|
|
7
|
+
"client_id": "114278988963249785723",
|
|
8
|
+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
|
|
9
|
+
"token_uri": "https://oauth2.googleapis.com/token",
|
|
10
|
+
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
|
|
11
|
+
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-5nsyj%40metaestetics.iam.gserviceaccount.com",
|
|
12
|
+
"universe_domain": "googleapis.com"
|
|
13
|
+
}
|
|
@@ -244,6 +244,7 @@ export class ClinicService extends BaseService {
|
|
|
244
244
|
id: clinicId,
|
|
245
245
|
clinicGroupId: validatedData.clinicGroupId,
|
|
246
246
|
name: validatedData.name,
|
|
247
|
+
nameLower: validatedData.name.toLowerCase(), // Add this line
|
|
247
248
|
description: validatedData.description,
|
|
248
249
|
location: { ...location, geohash: hash },
|
|
249
250
|
contactInfo: validatedData.contactInfo,
|
|
@@ -384,6 +385,11 @@ export class ClinicService extends BaseService {
|
|
|
384
385
|
}
|
|
385
386
|
}
|
|
386
387
|
|
|
388
|
+
// Always update nameLower if name is changed
|
|
389
|
+
if (validatedData.name) {
|
|
390
|
+
updatePayload.nameLower = validatedData.name.toLowerCase();
|
|
391
|
+
}
|
|
392
|
+
|
|
387
393
|
// Handle location update with geohash
|
|
388
394
|
if (validatedData.location) {
|
|
389
395
|
const loc = validatedData.location;
|
|
@@ -646,4 +652,31 @@ export class ClinicService extends BaseService {
|
|
|
646
652
|
}> {
|
|
647
653
|
return FilterUtils.getClinicsByFilters(this.db, filters);
|
|
648
654
|
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Gets all clinics with minimal info for map display (id, name, address, latitude, longitude)
|
|
658
|
+
* This is optimized for mobile map usage to reduce payload size.
|
|
659
|
+
* @returns Array of minimal clinic info for map
|
|
660
|
+
*/
|
|
661
|
+
async getClinicsForMap(): Promise<{
|
|
662
|
+
id: string;
|
|
663
|
+
name: string;
|
|
664
|
+
address: string;
|
|
665
|
+
latitude: number | undefined;
|
|
666
|
+
longitude: number | undefined;
|
|
667
|
+
}[]> {
|
|
668
|
+
const clinicsRef = collection(this.db, CLINICS_COLLECTION);
|
|
669
|
+
const snapshot = await getDocs(clinicsRef);
|
|
670
|
+
const clinicsForMap = snapshot.docs.map(doc => {
|
|
671
|
+
const data = doc.data();
|
|
672
|
+
return {
|
|
673
|
+
id: doc.id,
|
|
674
|
+
name: data.name,
|
|
675
|
+
address: data.location?.address || '',
|
|
676
|
+
latitude: data.location?.latitude,
|
|
677
|
+
longitude: data.location?.longitude,
|
|
678
|
+
};
|
|
679
|
+
});
|
|
680
|
+
return clinicsForMap;
|
|
681
|
+
}
|
|
649
682
|
}
|
|
@@ -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 || [],
|
|
@@ -1036,22 +1041,14 @@ export class PractitionerService extends BaseService {
|
|
|
1036
1041
|
includeDraftPractitioners?: boolean;
|
|
1037
1042
|
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
1038
1043
|
try {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
filters
|
|
1042
|
-
);
|
|
1043
|
-
|
|
1044
|
-
const constraints = [];
|
|
1045
|
-
|
|
1046
|
-
// Filter by status if not including drafts
|
|
1044
|
+
// 1. Prepare Firestore constraints
|
|
1045
|
+
const constraints: any[] = [];
|
|
1047
1046
|
if (!filters.includeDraftPractitioners) {
|
|
1048
1047
|
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1049
1048
|
}
|
|
1050
|
-
|
|
1051
|
-
// Filter by active status
|
|
1052
1049
|
constraints.push(where("isActive", "==", true));
|
|
1053
1050
|
|
|
1054
|
-
//
|
|
1051
|
+
// Certifications
|
|
1055
1052
|
if (filters.certifications && filters.certifications.length > 0) {
|
|
1056
1053
|
constraints.push(
|
|
1057
1054
|
where(
|
|
@@ -1062,138 +1059,73 @@ export class PractitionerService extends BaseService {
|
|
|
1062
1059
|
);
|
|
1063
1060
|
}
|
|
1064
1061
|
|
|
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));
|
|
1062
|
+
// Text search by fullNameLower
|
|
1063
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1064
|
+
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1065
|
+
constraints.push(where("fullNameLower", ">=", searchTerm));
|
|
1066
|
+
constraints.push(where("fullNameLower", "<=", searchTerm + "\uf8ff"));
|
|
1075
1067
|
}
|
|
1076
1068
|
|
|
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
|
-
});
|
|
1069
|
+
// Procedure filters (if mapped to fields)
|
|
1070
|
+
if (filters.procedureTechnology) {
|
|
1071
|
+
constraints.push(where("proceduresInfo.technologyName", "==", filters.procedureTechnology));
|
|
1072
|
+
} else if (filters.procedureSubcategory) {
|
|
1073
|
+
constraints.push(where("proceduresInfo.subcategoryName", "==", filters.procedureSubcategory));
|
|
1074
|
+
} else if (filters.procedureCategory) {
|
|
1075
|
+
constraints.push(where("proceduresInfo.categoryName", "==", filters.procedureCategory));
|
|
1076
|
+
} else if (filters.procedureFamily) {
|
|
1077
|
+
constraints.push(where("proceduresInfo.family", "==", filters.procedureFamily));
|
|
1109
1078
|
}
|
|
1110
1079
|
|
|
1111
|
-
//
|
|
1112
|
-
if (filters.
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
});
|
|
1080
|
+
// Rating filters
|
|
1081
|
+
if (filters.minRating !== undefined) {
|
|
1082
|
+
constraints.push(where("reviewInfo.averageRating", ">=", filters.minRating));
|
|
1083
|
+
}
|
|
1084
|
+
if (filters.maxRating !== undefined) {
|
|
1085
|
+
constraints.push(where("reviewInfo.averageRating", "<=", filters.maxRating));
|
|
1118
1086
|
}
|
|
1119
1087
|
|
|
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
|
-
});
|
|
1088
|
+
// Pagination and ordering
|
|
1089
|
+
constraints.push(orderBy("fullNameLower"));
|
|
1090
|
+
if (filters.lastDoc) {
|
|
1091
|
+
if (typeof filters.lastDoc.data === "function") {
|
|
1092
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1093
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
1094
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
1095
|
+
} else {
|
|
1096
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1097
|
+
}
|
|
1146
1098
|
}
|
|
1099
|
+
constraints.push(limit(filters.pagination || 5));
|
|
1100
|
+
|
|
1101
|
+
// 2. Firestore query
|
|
1102
|
+
const q = query(collection(this.db, PRACTITIONERS_COLLECTION), ...constraints);
|
|
1103
|
+
const querySnapshot = await getDocs(q);
|
|
1104
|
+
let practitioners = querySnapshot.docs.map(doc => ({ ...doc.data(), id: doc.id } as Practitioner));
|
|
1147
1105
|
|
|
1148
|
-
//
|
|
1106
|
+
// 3. In-memory filter ONLY for geo-radius (if needed)
|
|
1149
1107
|
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1150
1108
|
const location = filters.location;
|
|
1151
1109
|
const radiusInKm = filters.radiusInKm;
|
|
1152
|
-
|
|
1153
1110
|
practitioners = practitioners.filter((practitioner) => {
|
|
1154
1111
|
// Use the aggregated clinicsInfo to check if any clinic is within range
|
|
1155
1112
|
const clinics = practitioner.clinicsInfo || [];
|
|
1156
|
-
|
|
1157
|
-
// Check if any clinic is within the specified radius
|
|
1158
1113
|
return clinics.some((clinic) => {
|
|
1159
1114
|
// Calculate distance
|
|
1160
1115
|
const distance = distanceBetween(
|
|
1161
1116
|
[location.latitude, location.longitude],
|
|
1162
1117
|
[clinic.location.latitude, clinic.location.longitude]
|
|
1163
1118
|
);
|
|
1164
|
-
|
|
1165
1119
|
// Convert to kilometers
|
|
1166
1120
|
const distanceInKm = distance / 1000;
|
|
1167
|
-
|
|
1168
1121
|
// Check if within radius
|
|
1169
1122
|
return distanceInKm <= radiusInKm;
|
|
1170
1123
|
});
|
|
1171
1124
|
});
|
|
1172
1125
|
}
|
|
1173
1126
|
|
|
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
|
-
|
|
1127
|
+
// 4. Return results and lastDoc
|
|
1128
|
+
const lastDoc = querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
1197
1129
|
return {
|
|
1198
1130
|
practitioners,
|
|
1199
1131
|
lastDoc,
|
|
@@ -1286,6 +1218,7 @@ export class PractitionerService extends BaseService {
|
|
|
1286
1218
|
// Create procedure data for free consultation (without productId)
|
|
1287
1219
|
const consultationData: Omit<CreateProcedureData, "productId"> = {
|
|
1288
1220
|
name: "Free Consultation",
|
|
1221
|
+
nameLower: "free consultation",
|
|
1289
1222
|
description:
|
|
1290
1223
|
"Free initial consultation to discuss treatment options and assess patient needs.",
|
|
1291
1224
|
family: ProcedureFamily.AESTHETICS,
|