@blackcode_sa/metaestetics-api 1.13.3 → 1.13.5
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 +15 -28
- package/dist/admin/index.d.ts +15 -28
- package/dist/index.d.mts +18 -30
- package/dist/index.d.ts +18 -30
- package/dist/index.js +11 -3
- package/dist/index.mjs +11 -3
- package/package.json +121 -119
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +128 -128
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
- package/src/admin/analytics/analytics.admin.service.ts +278 -278
- package/src/admin/analytics/index.ts +2 -2
- package/src/admin/booking/README.md +125 -125
- package/src/admin/booking/booking.admin.ts +1037 -1037
- package/src/admin/booking/booking.calculator.ts +712 -712
- package/src/admin/booking/booking.types.ts +59 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +7 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +1 -1
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +81 -81
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +95 -95
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/index.ts +3 -3
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +710 -710
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +57 -57
- package/src/backoffice/services/analytics.service.proposal.md +863 -863
- package/src/backoffice/services/analytics.service.summary.md +143 -143
- package/src/backoffice/services/brand.service.ts +256 -256
- package/src/backoffice/services/category.service.ts +384 -384
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +10 -10
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +553 -553
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +461 -461
- package/src/backoffice/services/technology.service.ts +1151 -1151
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +67 -67
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +240 -240
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +168 -168
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -164
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +9 -9
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +200 -200
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +293 -293
- package/src/services/__tests__/auth.service.test.ts +346 -346
- package/src/services/__tests__/base.service.test.ts +77 -77
- package/src/services/__tests__/user.service.test.ts +528 -528
- package/src/services/analytics/ARCHITECTURE.md +199 -199
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
- package/src/services/analytics/QUICK_START.md +393 -393
- package/src/services/analytics/README.md +304 -304
- package/src/services/analytics/SUMMARY.md +141 -141
- package/src/services/analytics/TRENDS.md +380 -380
- package/src/services/analytics/USAGE_GUIDE.md +518 -518
- package/src/services/analytics/analytics-cloud.service.ts +222 -222
- package/src/services/analytics/analytics.service.ts +2142 -2142
- package/src/services/analytics/index.ts +4 -4
- package/src/services/analytics/review-analytics.service.ts +941 -941
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
- package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
- package/src/services/analytics/utils/grouping.utils.ts +434 -434
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2558 -2558
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +552 -552
- package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
- package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +353 -353
- package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
- package/src/services/auth/auth.service.ts +989 -989
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1683 -1683
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
- package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +708 -708
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +949 -949
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +446 -446
- package/src/services/clinic/utils/index.ts +11 -11
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +84 -84
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +587 -587
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +14 -14
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +294 -294
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +883 -883
- package/src/services/patient/patientRequirements.service.ts +285 -285
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/clinic.utils.ts +80 -80
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +1742 -1742
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +2200 -2191
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +734 -734
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +489 -489
- package/src/services/user/user.v2.service.ts +466 -466
- package/src/types/analytics/analytics.types.ts +597 -597
- package/src/types/analytics/grouped-analytics.types.ts +173 -173
- package/src/types/analytics/index.ts +4 -4
- package/src/types/analytics/stored-analytics.types.ts +137 -137
- package/src/types/appointment/index.ts +480 -480
- package/src/types/calendar/index.ts +258 -258
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +498 -489
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +47 -47
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +286 -286
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/index.ts +275 -275
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +206 -206
- package/src/types/procedure/index.ts +181 -181
- package/src/types/profile/index.ts +39 -39
- package/src/types/reviews/index.ts +132 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +38 -38
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/appointment.schema.ts +574 -574
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +494 -493
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +20 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/medical-info.schema.ts +125 -125
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -217
- package/src/validations/practitioner.schema.ts +222 -222
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +124 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/reviews.schema.ts +195 -195
- package/src/validations/schemas.ts +104 -104
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,446 +1,446 @@
|
|
|
1
|
-
import {
|
|
2
|
-
collection,
|
|
3
|
-
query,
|
|
4
|
-
where,
|
|
5
|
-
getDocs,
|
|
6
|
-
Firestore,
|
|
7
|
-
QueryConstraint,
|
|
8
|
-
startAfter,
|
|
9
|
-
limit,
|
|
10
|
-
documentId,
|
|
11
|
-
orderBy,
|
|
12
|
-
} from 'firebase/firestore';
|
|
13
|
-
import { Clinic, ClinicTag, CLINICS_COLLECTION } from '../../../types/clinic';
|
|
14
|
-
import { geohashQueryBounds, distanceBetween } from 'geofire-common';
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Get clinics based on multiple filtering criteria with fallback strategies
|
|
18
|
-
*
|
|
19
|
-
* @param db - Firestore database instance
|
|
20
|
-
* @param filters - Various filters to apply
|
|
21
|
-
* @returns Filtered clinics and the last document for pagination
|
|
22
|
-
*/
|
|
23
|
-
export async function getClinicsByFilters(
|
|
24
|
-
db: Firestore,
|
|
25
|
-
filters: {
|
|
26
|
-
center?: { latitude: number; longitude: number };
|
|
27
|
-
radiusInKm?: number;
|
|
28
|
-
tags?: ClinicTag[];
|
|
29
|
-
procedureFamily?: string;
|
|
30
|
-
procedureCategory?: string;
|
|
31
|
-
procedureSubcategory?: string;
|
|
32
|
-
procedureTechnology?: string;
|
|
33
|
-
minRating?: number;
|
|
34
|
-
maxRating?: number;
|
|
35
|
-
nameSearch?: string;
|
|
36
|
-
pagination?: number;
|
|
37
|
-
lastDoc?: any;
|
|
38
|
-
isActive?: boolean;
|
|
39
|
-
},
|
|
40
|
-
): Promise<{
|
|
41
|
-
clinics: (Clinic & { distance?: number })[];
|
|
42
|
-
lastDoc: any;
|
|
43
|
-
}> {
|
|
44
|
-
try {
|
|
45
|
-
console.log('[CLINIC_SERVICE] Starting clinic filtering with multiple strategies');
|
|
46
|
-
|
|
47
|
-
// Geo query debug i validacija
|
|
48
|
-
if (filters.center && filters.radiusInKm) {
|
|
49
|
-
console.log('[CLINIC_SERVICE] Executing geo query:', {
|
|
50
|
-
center: filters.center,
|
|
51
|
-
radius: filters.radiusInKm,
|
|
52
|
-
serviceName: 'ClinicService',
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
// Validacija location podataka
|
|
56
|
-
if (!filters.center.latitude || !filters.center.longitude) {
|
|
57
|
-
console.warn('[CLINIC_SERVICE] Invalid location data:', filters.center);
|
|
58
|
-
filters.center = undefined;
|
|
59
|
-
filters.radiusInKm = undefined;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Strategy 0: Geohash-bounds prefilter + in-memory refinement when geo radius is provided
|
|
64
|
-
if (filters.center && filters.radiusInKm) {
|
|
65
|
-
try {
|
|
66
|
-
console.log('[CLINIC_SERVICE] Strategy 0: Geohash bounds prefilter');
|
|
67
|
-
const bounds = geohashQueryBounds(
|
|
68
|
-
[filters.center.latitude, filters.center.longitude],
|
|
69
|
-
(filters.radiusInKm || 0) * 1000,
|
|
70
|
-
);
|
|
71
|
-
|
|
72
|
-
const collected: Clinic[] = [];
|
|
73
|
-
for (const b of bounds) {
|
|
74
|
-
const constraints: QueryConstraint[] = [
|
|
75
|
-
where('location.geohash', '>=', b[0]),
|
|
76
|
-
where('location.geohash', '<=', b[1]),
|
|
77
|
-
where('isActive', '==', filters.isActive ?? true),
|
|
78
|
-
];
|
|
79
|
-
|
|
80
|
-
// Single tag in query if provided; remaining tag logic is applied in-memory
|
|
81
|
-
if (filters.tags && filters.tags.length > 0) {
|
|
82
|
-
constraints.push(where('tags', 'array-contains', filters.tags[0]));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const q0 = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
86
|
-
const snap = await getDocs(q0);
|
|
87
|
-
snap.docs.forEach((d) => collected.push({ ...(d.data() as Clinic), id: d.id }));
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Deduplicate
|
|
91
|
-
const uniqueMap = new Map<string, Clinic>();
|
|
92
|
-
for (const c of collected) {
|
|
93
|
-
uniqueMap.set(c.id, c);
|
|
94
|
-
}
|
|
95
|
-
let clinics = Array.from(uniqueMap.values());
|
|
96
|
-
|
|
97
|
-
// Apply all remaining filters and compute exact distance + sorting
|
|
98
|
-
clinics = applyInMemoryFilters(clinics, filters);
|
|
99
|
-
|
|
100
|
-
// Manual pagination over materialized results
|
|
101
|
-
const pageSize = filters.pagination || 5;
|
|
102
|
-
let startIndex = 0;
|
|
103
|
-
if (filters.lastDoc && typeof filters.lastDoc === 'object' && (filters.lastDoc as any).id) {
|
|
104
|
-
const idx = clinics.findIndex((c) => c.id === (filters.lastDoc as any).id);
|
|
105
|
-
if (idx >= 0) startIndex = idx + 1;
|
|
106
|
-
}
|
|
107
|
-
const page = clinics.slice(startIndex, startIndex + pageSize);
|
|
108
|
-
const newLastDoc = page.length === pageSize ? page[page.length - 1] : null;
|
|
109
|
-
|
|
110
|
-
console.log(
|
|
111
|
-
`[CLINIC_SERVICE] Strategy 0 success: ${page.length} clinics (of ${clinics.length})`,
|
|
112
|
-
);
|
|
113
|
-
return { clinics: page, lastDoc: newLastDoc };
|
|
114
|
-
} catch (geoErr) {
|
|
115
|
-
console.log('[CLINIC_SERVICE] Strategy 0 failed:', geoErr);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// Base constraints function (used in all strategies)
|
|
120
|
-
const getBaseConstraints = () => {
|
|
121
|
-
const constraints: QueryConstraint[] = [];
|
|
122
|
-
constraints.push(where('isActive', '==', filters.isActive ?? true));
|
|
123
|
-
|
|
124
|
-
// Tag (only first, due to Firestore limitation)
|
|
125
|
-
if (filters.tags && filters.tags.length > 0) {
|
|
126
|
-
constraints.push(where('tags', 'array-contains', filters.tags[0]));
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Rating filters
|
|
130
|
-
if (filters.minRating !== undefined) {
|
|
131
|
-
constraints.push(where('reviewInfo.averageRating', '>=', filters.minRating));
|
|
132
|
-
}
|
|
133
|
-
if (filters.maxRating !== undefined) {
|
|
134
|
-
constraints.push(where('reviewInfo.averageRating', '<=', filters.maxRating));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
return constraints;
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
// Strategy 1: Try nameLower search if nameSearch exists
|
|
141
|
-
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
142
|
-
try {
|
|
143
|
-
console.log('[CLINIC_SERVICE] Strategy 1: Trying nameLower search');
|
|
144
|
-
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
145
|
-
const constraints = getBaseConstraints();
|
|
146
|
-
constraints.push(where('nameLower', '>=', searchTerm));
|
|
147
|
-
constraints.push(where('nameLower', '<=', searchTerm + '\uf8ff'));
|
|
148
|
-
constraints.push(orderBy('nameLower'));
|
|
149
|
-
|
|
150
|
-
if (filters.lastDoc) {
|
|
151
|
-
if (typeof filters.lastDoc.data === 'function') {
|
|
152
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
153
|
-
} else if (Array.isArray(filters.lastDoc)) {
|
|
154
|
-
constraints.push(startAfter(...filters.lastDoc));
|
|
155
|
-
} else {
|
|
156
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
constraints.push(limit(filters.pagination || 5));
|
|
160
|
-
|
|
161
|
-
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
162
|
-
const querySnapshot = await getDocs(q);
|
|
163
|
-
let clinics = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as Clinic));
|
|
164
|
-
|
|
165
|
-
// Apply in-memory filters
|
|
166
|
-
clinics = applyInMemoryFilters(clinics, filters);
|
|
167
|
-
|
|
168
|
-
const lastDoc =
|
|
169
|
-
querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
170
|
-
|
|
171
|
-
console.log(`[CLINIC_SERVICE] Strategy 1 success: ${clinics.length} clinics`);
|
|
172
|
-
|
|
173
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
174
|
-
if (clinics.length < (filters.pagination || 5)) {
|
|
175
|
-
return { clinics, lastDoc: null };
|
|
176
|
-
}
|
|
177
|
-
return { clinics, lastDoc };
|
|
178
|
-
} catch (error) {
|
|
179
|
-
console.log('[CLINIC_SERVICE] Strategy 1 failed:', error);
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Strategy 2: Try name field search as fallback
|
|
184
|
-
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
185
|
-
try {
|
|
186
|
-
console.log('[CLINIC_SERVICE] Strategy 2: Trying name field search');
|
|
187
|
-
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
188
|
-
const constraints = getBaseConstraints();
|
|
189
|
-
constraints.push(where('name', '>=', searchTerm));
|
|
190
|
-
constraints.push(where('name', '<=', searchTerm + '\uf8ff'));
|
|
191
|
-
constraints.push(orderBy('name'));
|
|
192
|
-
|
|
193
|
-
if (filters.lastDoc) {
|
|
194
|
-
if (typeof filters.lastDoc.data === 'function') {
|
|
195
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
196
|
-
} else if (Array.isArray(filters.lastDoc)) {
|
|
197
|
-
constraints.push(startAfter(...filters.lastDoc));
|
|
198
|
-
} else {
|
|
199
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
constraints.push(limit(filters.pagination || 5));
|
|
203
|
-
|
|
204
|
-
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
205
|
-
const querySnapshot = await getDocs(q);
|
|
206
|
-
let clinics = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as Clinic));
|
|
207
|
-
|
|
208
|
-
// Apply in-memory filters
|
|
209
|
-
clinics = applyInMemoryFilters(clinics, filters);
|
|
210
|
-
|
|
211
|
-
const lastDoc =
|
|
212
|
-
querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
213
|
-
|
|
214
|
-
console.log(`[CLINIC_SERVICE] Strategy 2 success: ${clinics.length} clinics`);
|
|
215
|
-
|
|
216
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
217
|
-
if (clinics.length < (filters.pagination || 5)) {
|
|
218
|
-
return { clinics, lastDoc: null };
|
|
219
|
-
}
|
|
220
|
-
return { clinics, lastDoc };
|
|
221
|
-
} catch (error) {
|
|
222
|
-
console.log('[CLINIC_SERVICE] Strategy 2 failed:', error);
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Strategy 3: createdAt ordering with client-side name filtering
|
|
227
|
-
try {
|
|
228
|
-
console.log(
|
|
229
|
-
'[CLINIC_SERVICE] Strategy 3: Using createdAt ordering with client-side filtering',
|
|
230
|
-
);
|
|
231
|
-
const constraints = getBaseConstraints();
|
|
232
|
-
constraints.push(orderBy('createdAt', 'desc'));
|
|
233
|
-
|
|
234
|
-
if (filters.lastDoc) {
|
|
235
|
-
if (typeof filters.lastDoc.data === 'function') {
|
|
236
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
237
|
-
} else if (Array.isArray(filters.lastDoc)) {
|
|
238
|
-
constraints.push(startAfter(...filters.lastDoc));
|
|
239
|
-
} else {
|
|
240
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
constraints.push(limit(filters.pagination || 5));
|
|
244
|
-
|
|
245
|
-
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
246
|
-
const querySnapshot = await getDocs(q);
|
|
247
|
-
let clinics = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as Clinic));
|
|
248
|
-
|
|
249
|
-
// Apply in-memory filters (includes name search)
|
|
250
|
-
clinics = applyInMemoryFilters(clinics, filters);
|
|
251
|
-
|
|
252
|
-
const lastDoc =
|
|
253
|
-
querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
254
|
-
|
|
255
|
-
console.log(`[CLINIC_SERVICE] Strategy 3 success: ${clinics.length} clinics`);
|
|
256
|
-
|
|
257
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
258
|
-
if (clinics.length < (filters.pagination || 5)) {
|
|
259
|
-
return { clinics, lastDoc: null };
|
|
260
|
-
}
|
|
261
|
-
return { clinics, lastDoc };
|
|
262
|
-
} catch (error) {
|
|
263
|
-
console.log('[CLINIC_SERVICE] Strategy 3 failed:', error);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// Strategy 4: Minimal fallback query
|
|
267
|
-
try {
|
|
268
|
-
console.log('[CLINIC_SERVICE] Strategy 4: Minimal fallback');
|
|
269
|
-
const constraints: QueryConstraint[] = [
|
|
270
|
-
where('isActive', '==', true),
|
|
271
|
-
orderBy('createdAt', 'desc'),
|
|
272
|
-
limit(filters.pagination || 5),
|
|
273
|
-
];
|
|
274
|
-
|
|
275
|
-
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
276
|
-
const querySnapshot = await getDocs(q);
|
|
277
|
-
let clinics = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as Clinic));
|
|
278
|
-
|
|
279
|
-
// Apply in-memory filters (includes name search)
|
|
280
|
-
clinics = applyInMemoryFilters(clinics, filters);
|
|
281
|
-
|
|
282
|
-
const lastDoc =
|
|
283
|
-
querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
284
|
-
|
|
285
|
-
console.log(`[CLINIC_SERVICE] Strategy 4 success: ${clinics.length} clinics`);
|
|
286
|
-
|
|
287
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
288
|
-
if (clinics.length < (filters.pagination || 5)) {
|
|
289
|
-
return { clinics, lastDoc: null };
|
|
290
|
-
}
|
|
291
|
-
return { clinics, lastDoc };
|
|
292
|
-
} catch (error) {
|
|
293
|
-
console.log('[CLINIC_SERVICE] Strategy 4 failed:', error);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
// All strategies failed
|
|
297
|
-
console.log('[CLINIC_SERVICE] All strategies failed, returning empty result');
|
|
298
|
-
return { clinics: [], lastDoc: null };
|
|
299
|
-
} catch (error) {
|
|
300
|
-
console.error('[CLINIC_SERVICE] Error filtering clinics:', error);
|
|
301
|
-
return { clinics: [], lastDoc: null };
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/**
|
|
306
|
-
* Helper function to apply in-memory filters that Firestore doesn't support well
|
|
307
|
-
*/
|
|
308
|
-
function applyInMemoryFilters(clinics: Clinic[], filters: any): (Clinic & { distance?: number })[] {
|
|
309
|
-
let filteredClinics = [...clinics]; // Kreiraj kopiju
|
|
310
|
-
|
|
311
|
-
console.log(
|
|
312
|
-
`[CLINIC_SERVICE] Applying in-memory filters - input: ${filteredClinics.length} clinics`,
|
|
313
|
-
);
|
|
314
|
-
|
|
315
|
-
// ✅ Multi-tag filter (postojeći kod)
|
|
316
|
-
if (filters.tags && filters.tags.length > 1) {
|
|
317
|
-
const initialCount = filteredClinics.length;
|
|
318
|
-
filteredClinics = filteredClinics.filter((clinic) =>
|
|
319
|
-
filters.tags.every((tag: ClinicTag) => clinic.tags.includes(tag)),
|
|
320
|
-
);
|
|
321
|
-
console.log(
|
|
322
|
-
`[CLINIC_SERVICE] Applied multi-tag filter: ${initialCount} → ${filteredClinics.length}`,
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// 🆕 DODAJTE: Single tag filter (za Strategy 4 fallback)
|
|
327
|
-
if (filters.tags && filters.tags.length === 1) {
|
|
328
|
-
const initialCount = filteredClinics.length;
|
|
329
|
-
filteredClinics = filteredClinics.filter((clinic) => clinic.tags.includes(filters.tags[0]));
|
|
330
|
-
console.log(
|
|
331
|
-
`[CLINIC_SERVICE] Applied single-tag filter: ${initialCount} → ${filteredClinics.length}`,
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
// 🆕 DODAJTE: Rating filter
|
|
336
|
-
if (filters.minRating !== undefined || filters.maxRating !== undefined) {
|
|
337
|
-
const initialCount = filteredClinics.length;
|
|
338
|
-
filteredClinics = filteredClinics.filter((clinic) => {
|
|
339
|
-
const rating = clinic.reviewInfo?.averageRating || 0;
|
|
340
|
-
if (filters.minRating !== undefined && rating < filters.minRating) return false;
|
|
341
|
-
if (filters.maxRating !== undefined && rating > filters.maxRating) return false;
|
|
342
|
-
return true;
|
|
343
|
-
});
|
|
344
|
-
console.log(
|
|
345
|
-
`[CLINIC_SERVICE] Applied rating filter (${filters.minRating}-${filters.maxRating}): ${initialCount} → ${filteredClinics.length}`,
|
|
346
|
-
);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// 🆕 DODAJTE: Name search filter
|
|
350
|
-
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
351
|
-
const initialCount = filteredClinics.length;
|
|
352
|
-
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
353
|
-
filteredClinics = filteredClinics.filter((clinic) => {
|
|
354
|
-
const name = (clinic.name || '').toLowerCase();
|
|
355
|
-
const nameLower = clinic.nameLower || '';
|
|
356
|
-
return name.includes(searchTerm) || nameLower.includes(searchTerm);
|
|
357
|
-
});
|
|
358
|
-
console.log(
|
|
359
|
-
`[CLINIC_SERVICE] Applied name search filter: ${initialCount} → ${filteredClinics.length}`,
|
|
360
|
-
);
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// 🆕 DODAJTE: Procedure family filtering
|
|
364
|
-
if (filters.procedureFamily) {
|
|
365
|
-
const initialCount = filteredClinics.length;
|
|
366
|
-
filteredClinics = filteredClinics.filter((clinic) => {
|
|
367
|
-
const proceduresInfo = clinic.proceduresInfo || [];
|
|
368
|
-
return proceduresInfo.some((proc) => proc.family === filters.procedureFamily);
|
|
369
|
-
});
|
|
370
|
-
console.log(
|
|
371
|
-
`[CLINIC_SERVICE] Applied procedure family filter: ${initialCount} → ${filteredClinics.length}`,
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// 🆕 DODAJTE: Procedure category filtering
|
|
376
|
-
if (filters.procedureCategory) {
|
|
377
|
-
const initialCount = filteredClinics.length;
|
|
378
|
-
filteredClinics = filteredClinics.filter((clinic) => {
|
|
379
|
-
const proceduresInfo = clinic.proceduresInfo || [];
|
|
380
|
-
return proceduresInfo.some((proc) => proc.categoryName === filters.procedureCategory);
|
|
381
|
-
});
|
|
382
|
-
console.log(
|
|
383
|
-
`[CLINIC_SERVICE] Applied procedure category filter: ${initialCount} → ${filteredClinics.length}`,
|
|
384
|
-
);
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// 🆕 DODAJTE: Procedure subcategory filtering
|
|
388
|
-
if (filters.procedureSubcategory) {
|
|
389
|
-
const initialCount = filteredClinics.length;
|
|
390
|
-
filteredClinics = filteredClinics.filter((clinic) => {
|
|
391
|
-
const proceduresInfo = clinic.proceduresInfo || [];
|
|
392
|
-
return proceduresInfo.some((proc) => proc.subcategoryName === filters.procedureSubcategory);
|
|
393
|
-
});
|
|
394
|
-
console.log(
|
|
395
|
-
`[CLINIC_SERVICE] Applied procedure subcategory filter: ${initialCount} → ${filteredClinics.length}`,
|
|
396
|
-
);
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// 🆕 DODAJTE: Procedure technology filtering
|
|
400
|
-
if (filters.procedureTechnology) {
|
|
401
|
-
const initialCount = filteredClinics.length;
|
|
402
|
-
filteredClinics = filteredClinics.filter((clinic) => {
|
|
403
|
-
const proceduresInfo = clinic.proceduresInfo || [];
|
|
404
|
-
return proceduresInfo.some((proc) => proc.technologyName === filters.procedureTechnology);
|
|
405
|
-
});
|
|
406
|
-
console.log(
|
|
407
|
-
`[CLINIC_SERVICE] Applied procedure technology filter: ${initialCount} → ${filteredClinics.length}`,
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// ✅ Geo-radius filter (postojeći kod)
|
|
412
|
-
if (filters.center && filters.radiusInKm) {
|
|
413
|
-
const initialCount = filteredClinics.length;
|
|
414
|
-
filteredClinics = filteredClinics.filter((clinic) => {
|
|
415
|
-
if (!clinic.location?.latitude || !clinic.location?.longitude) {
|
|
416
|
-
console.log(`[CLINIC_SERVICE] Clinic ${clinic.id} missing location data`);
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
const distance =
|
|
421
|
-
distanceBetween(
|
|
422
|
-
[filters.center.latitude, filters.center.longitude],
|
|
423
|
-
[clinic.location.latitude, clinic.location.longitude],
|
|
424
|
-
) / 1000; // Convert to km
|
|
425
|
-
|
|
426
|
-
console.log(
|
|
427
|
-
`[CLINIC_SERVICE] Clinic ${clinic.name}: distance ${distance.toFixed(2)}km (limit: ${
|
|
428
|
-
filters.radiusInKm
|
|
429
|
-
}km)`,
|
|
430
|
-
);
|
|
431
|
-
|
|
432
|
-
// Attach distance for frontend sorting/display
|
|
433
|
-
(clinic as any).distance = distance;
|
|
434
|
-
return distance <= filters.radiusInKm;
|
|
435
|
-
});
|
|
436
|
-
console.log(
|
|
437
|
-
`[CLINIC_SERVICE] Applied geo filter (${filters.radiusInKm}km): ${initialCount} → ${filteredClinics.length}`,
|
|
438
|
-
);
|
|
439
|
-
|
|
440
|
-
// Sort by distance when geo filtering is applied
|
|
441
|
-
filteredClinics.sort((a, b) => ((a as any).distance || 0) - ((b as any).distance || 0));
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
console.log(`[CLINIC_SERVICE] Final filtered result: ${filteredClinics.length} clinics`);
|
|
445
|
-
return filteredClinics as (Clinic & { distance?: number })[];
|
|
446
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
collection,
|
|
3
|
+
query,
|
|
4
|
+
where,
|
|
5
|
+
getDocs,
|
|
6
|
+
Firestore,
|
|
7
|
+
QueryConstraint,
|
|
8
|
+
startAfter,
|
|
9
|
+
limit,
|
|
10
|
+
documentId,
|
|
11
|
+
orderBy,
|
|
12
|
+
} from 'firebase/firestore';
|
|
13
|
+
import { Clinic, ClinicTag, CLINICS_COLLECTION } from '../../../types/clinic';
|
|
14
|
+
import { geohashQueryBounds, distanceBetween } from 'geofire-common';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Get clinics based on multiple filtering criteria with fallback strategies
|
|
18
|
+
*
|
|
19
|
+
* @param db - Firestore database instance
|
|
20
|
+
* @param filters - Various filters to apply
|
|
21
|
+
* @returns Filtered clinics and the last document for pagination
|
|
22
|
+
*/
|
|
23
|
+
export async function getClinicsByFilters(
|
|
24
|
+
db: Firestore,
|
|
25
|
+
filters: {
|
|
26
|
+
center?: { latitude: number; longitude: number };
|
|
27
|
+
radiusInKm?: number;
|
|
28
|
+
tags?: ClinicTag[];
|
|
29
|
+
procedureFamily?: string;
|
|
30
|
+
procedureCategory?: string;
|
|
31
|
+
procedureSubcategory?: string;
|
|
32
|
+
procedureTechnology?: string;
|
|
33
|
+
minRating?: number;
|
|
34
|
+
maxRating?: number;
|
|
35
|
+
nameSearch?: string;
|
|
36
|
+
pagination?: number;
|
|
37
|
+
lastDoc?: any;
|
|
38
|
+
isActive?: boolean;
|
|
39
|
+
},
|
|
40
|
+
): Promise<{
|
|
41
|
+
clinics: (Clinic & { distance?: number })[];
|
|
42
|
+
lastDoc: any;
|
|
43
|
+
}> {
|
|
44
|
+
try {
|
|
45
|
+
console.log('[CLINIC_SERVICE] Starting clinic filtering with multiple strategies');
|
|
46
|
+
|
|
47
|
+
// Geo query debug i validacija
|
|
48
|
+
if (filters.center && filters.radiusInKm) {
|
|
49
|
+
console.log('[CLINIC_SERVICE] Executing geo query:', {
|
|
50
|
+
center: filters.center,
|
|
51
|
+
radius: filters.radiusInKm,
|
|
52
|
+
serviceName: 'ClinicService',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// Validacija location podataka
|
|
56
|
+
if (!filters.center.latitude || !filters.center.longitude) {
|
|
57
|
+
console.warn('[CLINIC_SERVICE] Invalid location data:', filters.center);
|
|
58
|
+
filters.center = undefined;
|
|
59
|
+
filters.radiusInKm = undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Strategy 0: Geohash-bounds prefilter + in-memory refinement when geo radius is provided
|
|
64
|
+
if (filters.center && filters.radiusInKm) {
|
|
65
|
+
try {
|
|
66
|
+
console.log('[CLINIC_SERVICE] Strategy 0: Geohash bounds prefilter');
|
|
67
|
+
const bounds = geohashQueryBounds(
|
|
68
|
+
[filters.center.latitude, filters.center.longitude],
|
|
69
|
+
(filters.radiusInKm || 0) * 1000,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const collected: Clinic[] = [];
|
|
73
|
+
for (const b of bounds) {
|
|
74
|
+
const constraints: QueryConstraint[] = [
|
|
75
|
+
where('location.geohash', '>=', b[0]),
|
|
76
|
+
where('location.geohash', '<=', b[1]),
|
|
77
|
+
where('isActive', '==', filters.isActive ?? true),
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
// Single tag in query if provided; remaining tag logic is applied in-memory
|
|
81
|
+
if (filters.tags && filters.tags.length > 0) {
|
|
82
|
+
constraints.push(where('tags', 'array-contains', filters.tags[0]));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const q0 = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
86
|
+
const snap = await getDocs(q0);
|
|
87
|
+
snap.docs.forEach((d) => collected.push({ ...(d.data() as Clinic), id: d.id }));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Deduplicate
|
|
91
|
+
const uniqueMap = new Map<string, Clinic>();
|
|
92
|
+
for (const c of collected) {
|
|
93
|
+
uniqueMap.set(c.id, c);
|
|
94
|
+
}
|
|
95
|
+
let clinics = Array.from(uniqueMap.values());
|
|
96
|
+
|
|
97
|
+
// Apply all remaining filters and compute exact distance + sorting
|
|
98
|
+
clinics = applyInMemoryFilters(clinics, filters);
|
|
99
|
+
|
|
100
|
+
// Manual pagination over materialized results
|
|
101
|
+
const pageSize = filters.pagination || 5;
|
|
102
|
+
let startIndex = 0;
|
|
103
|
+
if (filters.lastDoc && typeof filters.lastDoc === 'object' && (filters.lastDoc as any).id) {
|
|
104
|
+
const idx = clinics.findIndex((c) => c.id === (filters.lastDoc as any).id);
|
|
105
|
+
if (idx >= 0) startIndex = idx + 1;
|
|
106
|
+
}
|
|
107
|
+
const page = clinics.slice(startIndex, startIndex + pageSize);
|
|
108
|
+
const newLastDoc = page.length === pageSize ? page[page.length - 1] : null;
|
|
109
|
+
|
|
110
|
+
console.log(
|
|
111
|
+
`[CLINIC_SERVICE] Strategy 0 success: ${page.length} clinics (of ${clinics.length})`,
|
|
112
|
+
);
|
|
113
|
+
return { clinics: page, lastDoc: newLastDoc };
|
|
114
|
+
} catch (geoErr) {
|
|
115
|
+
console.log('[CLINIC_SERVICE] Strategy 0 failed:', geoErr);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Base constraints function (used in all strategies)
|
|
120
|
+
const getBaseConstraints = () => {
|
|
121
|
+
const constraints: QueryConstraint[] = [];
|
|
122
|
+
constraints.push(where('isActive', '==', filters.isActive ?? true));
|
|
123
|
+
|
|
124
|
+
// Tag (only first, due to Firestore limitation)
|
|
125
|
+
if (filters.tags && filters.tags.length > 0) {
|
|
126
|
+
constraints.push(where('tags', 'array-contains', filters.tags[0]));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Rating filters
|
|
130
|
+
if (filters.minRating !== undefined) {
|
|
131
|
+
constraints.push(where('reviewInfo.averageRating', '>=', filters.minRating));
|
|
132
|
+
}
|
|
133
|
+
if (filters.maxRating !== undefined) {
|
|
134
|
+
constraints.push(where('reviewInfo.averageRating', '<=', filters.maxRating));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return constraints;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// Strategy 1: Try nameLower search if nameSearch exists
|
|
141
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
142
|
+
try {
|
|
143
|
+
console.log('[CLINIC_SERVICE] Strategy 1: Trying nameLower search');
|
|
144
|
+
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
145
|
+
const constraints = getBaseConstraints();
|
|
146
|
+
constraints.push(where('nameLower', '>=', searchTerm));
|
|
147
|
+
constraints.push(where('nameLower', '<=', searchTerm + '\uf8ff'));
|
|
148
|
+
constraints.push(orderBy('nameLower'));
|
|
149
|
+
|
|
150
|
+
if (filters.lastDoc) {
|
|
151
|
+
if (typeof filters.lastDoc.data === 'function') {
|
|
152
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
153
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
154
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
155
|
+
} else {
|
|
156
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
constraints.push(limit(filters.pagination || 5));
|
|
160
|
+
|
|
161
|
+
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
162
|
+
const querySnapshot = await getDocs(q);
|
|
163
|
+
let clinics = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as Clinic));
|
|
164
|
+
|
|
165
|
+
// Apply in-memory filters
|
|
166
|
+
clinics = applyInMemoryFilters(clinics, filters);
|
|
167
|
+
|
|
168
|
+
const lastDoc =
|
|
169
|
+
querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
170
|
+
|
|
171
|
+
console.log(`[CLINIC_SERVICE] Strategy 1 success: ${clinics.length} clinics`);
|
|
172
|
+
|
|
173
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
174
|
+
if (clinics.length < (filters.pagination || 5)) {
|
|
175
|
+
return { clinics, lastDoc: null };
|
|
176
|
+
}
|
|
177
|
+
return { clinics, lastDoc };
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.log('[CLINIC_SERVICE] Strategy 1 failed:', error);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Strategy 2: Try name field search as fallback
|
|
184
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
185
|
+
try {
|
|
186
|
+
console.log('[CLINIC_SERVICE] Strategy 2: Trying name field search');
|
|
187
|
+
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
188
|
+
const constraints = getBaseConstraints();
|
|
189
|
+
constraints.push(where('name', '>=', searchTerm));
|
|
190
|
+
constraints.push(where('name', '<=', searchTerm + '\uf8ff'));
|
|
191
|
+
constraints.push(orderBy('name'));
|
|
192
|
+
|
|
193
|
+
if (filters.lastDoc) {
|
|
194
|
+
if (typeof filters.lastDoc.data === 'function') {
|
|
195
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
196
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
197
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
198
|
+
} else {
|
|
199
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
constraints.push(limit(filters.pagination || 5));
|
|
203
|
+
|
|
204
|
+
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
205
|
+
const querySnapshot = await getDocs(q);
|
|
206
|
+
let clinics = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as Clinic));
|
|
207
|
+
|
|
208
|
+
// Apply in-memory filters
|
|
209
|
+
clinics = applyInMemoryFilters(clinics, filters);
|
|
210
|
+
|
|
211
|
+
const lastDoc =
|
|
212
|
+
querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
213
|
+
|
|
214
|
+
console.log(`[CLINIC_SERVICE] Strategy 2 success: ${clinics.length} clinics`);
|
|
215
|
+
|
|
216
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
217
|
+
if (clinics.length < (filters.pagination || 5)) {
|
|
218
|
+
return { clinics, lastDoc: null };
|
|
219
|
+
}
|
|
220
|
+
return { clinics, lastDoc };
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.log('[CLINIC_SERVICE] Strategy 2 failed:', error);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Strategy 3: createdAt ordering with client-side name filtering
|
|
227
|
+
try {
|
|
228
|
+
console.log(
|
|
229
|
+
'[CLINIC_SERVICE] Strategy 3: Using createdAt ordering with client-side filtering',
|
|
230
|
+
);
|
|
231
|
+
const constraints = getBaseConstraints();
|
|
232
|
+
constraints.push(orderBy('createdAt', 'desc'));
|
|
233
|
+
|
|
234
|
+
if (filters.lastDoc) {
|
|
235
|
+
if (typeof filters.lastDoc.data === 'function') {
|
|
236
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
237
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
238
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
239
|
+
} else {
|
|
240
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
constraints.push(limit(filters.pagination || 5));
|
|
244
|
+
|
|
245
|
+
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
246
|
+
const querySnapshot = await getDocs(q);
|
|
247
|
+
let clinics = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as Clinic));
|
|
248
|
+
|
|
249
|
+
// Apply in-memory filters (includes name search)
|
|
250
|
+
clinics = applyInMemoryFilters(clinics, filters);
|
|
251
|
+
|
|
252
|
+
const lastDoc =
|
|
253
|
+
querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
254
|
+
|
|
255
|
+
console.log(`[CLINIC_SERVICE] Strategy 3 success: ${clinics.length} clinics`);
|
|
256
|
+
|
|
257
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
258
|
+
if (clinics.length < (filters.pagination || 5)) {
|
|
259
|
+
return { clinics, lastDoc: null };
|
|
260
|
+
}
|
|
261
|
+
return { clinics, lastDoc };
|
|
262
|
+
} catch (error) {
|
|
263
|
+
console.log('[CLINIC_SERVICE] Strategy 3 failed:', error);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Strategy 4: Minimal fallback query
|
|
267
|
+
try {
|
|
268
|
+
console.log('[CLINIC_SERVICE] Strategy 4: Minimal fallback');
|
|
269
|
+
const constraints: QueryConstraint[] = [
|
|
270
|
+
where('isActive', '==', true),
|
|
271
|
+
orderBy('createdAt', 'desc'),
|
|
272
|
+
limit(filters.pagination || 5),
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
|
|
276
|
+
const querySnapshot = await getDocs(q);
|
|
277
|
+
let clinics = querySnapshot.docs.map((doc) => ({ ...doc.data(), id: doc.id } as Clinic));
|
|
278
|
+
|
|
279
|
+
// Apply in-memory filters (includes name search)
|
|
280
|
+
clinics = applyInMemoryFilters(clinics, filters);
|
|
281
|
+
|
|
282
|
+
const lastDoc =
|
|
283
|
+
querySnapshot.docs.length > 0 ? querySnapshot.docs[querySnapshot.docs.length - 1] : null;
|
|
284
|
+
|
|
285
|
+
console.log(`[CLINIC_SERVICE] Strategy 4 success: ${clinics.length} clinics`);
|
|
286
|
+
|
|
287
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
288
|
+
if (clinics.length < (filters.pagination || 5)) {
|
|
289
|
+
return { clinics, lastDoc: null };
|
|
290
|
+
}
|
|
291
|
+
return { clinics, lastDoc };
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.log('[CLINIC_SERVICE] Strategy 4 failed:', error);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// All strategies failed
|
|
297
|
+
console.log('[CLINIC_SERVICE] All strategies failed, returning empty result');
|
|
298
|
+
return { clinics: [], lastDoc: null };
|
|
299
|
+
} catch (error) {
|
|
300
|
+
console.error('[CLINIC_SERVICE] Error filtering clinics:', error);
|
|
301
|
+
return { clinics: [], lastDoc: null };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Helper function to apply in-memory filters that Firestore doesn't support well
|
|
307
|
+
*/
|
|
308
|
+
function applyInMemoryFilters(clinics: Clinic[], filters: any): (Clinic & { distance?: number })[] {
|
|
309
|
+
let filteredClinics = [...clinics]; // Kreiraj kopiju
|
|
310
|
+
|
|
311
|
+
console.log(
|
|
312
|
+
`[CLINIC_SERVICE] Applying in-memory filters - input: ${filteredClinics.length} clinics`,
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// ✅ Multi-tag filter (postojeći kod)
|
|
316
|
+
if (filters.tags && filters.tags.length > 1) {
|
|
317
|
+
const initialCount = filteredClinics.length;
|
|
318
|
+
filteredClinics = filteredClinics.filter((clinic) =>
|
|
319
|
+
filters.tags.every((tag: ClinicTag) => clinic.tags.includes(tag)),
|
|
320
|
+
);
|
|
321
|
+
console.log(
|
|
322
|
+
`[CLINIC_SERVICE] Applied multi-tag filter: ${initialCount} → ${filteredClinics.length}`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 🆕 DODAJTE: Single tag filter (za Strategy 4 fallback)
|
|
327
|
+
if (filters.tags && filters.tags.length === 1) {
|
|
328
|
+
const initialCount = filteredClinics.length;
|
|
329
|
+
filteredClinics = filteredClinics.filter((clinic) => clinic.tags.includes(filters.tags[0]));
|
|
330
|
+
console.log(
|
|
331
|
+
`[CLINIC_SERVICE] Applied single-tag filter: ${initialCount} → ${filteredClinics.length}`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 🆕 DODAJTE: Rating filter
|
|
336
|
+
if (filters.minRating !== undefined || filters.maxRating !== undefined) {
|
|
337
|
+
const initialCount = filteredClinics.length;
|
|
338
|
+
filteredClinics = filteredClinics.filter((clinic) => {
|
|
339
|
+
const rating = clinic.reviewInfo?.averageRating || 0;
|
|
340
|
+
if (filters.minRating !== undefined && rating < filters.minRating) return false;
|
|
341
|
+
if (filters.maxRating !== undefined && rating > filters.maxRating) return false;
|
|
342
|
+
return true;
|
|
343
|
+
});
|
|
344
|
+
console.log(
|
|
345
|
+
`[CLINIC_SERVICE] Applied rating filter (${filters.minRating}-${filters.maxRating}): ${initialCount} → ${filteredClinics.length}`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 🆕 DODAJTE: Name search filter
|
|
350
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
351
|
+
const initialCount = filteredClinics.length;
|
|
352
|
+
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
353
|
+
filteredClinics = filteredClinics.filter((clinic) => {
|
|
354
|
+
const name = (clinic.name || '').toLowerCase();
|
|
355
|
+
const nameLower = clinic.nameLower || '';
|
|
356
|
+
return name.includes(searchTerm) || nameLower.includes(searchTerm);
|
|
357
|
+
});
|
|
358
|
+
console.log(
|
|
359
|
+
`[CLINIC_SERVICE] Applied name search filter: ${initialCount} → ${filteredClinics.length}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// 🆕 DODAJTE: Procedure family filtering
|
|
364
|
+
if (filters.procedureFamily) {
|
|
365
|
+
const initialCount = filteredClinics.length;
|
|
366
|
+
filteredClinics = filteredClinics.filter((clinic) => {
|
|
367
|
+
const proceduresInfo = clinic.proceduresInfo || [];
|
|
368
|
+
return proceduresInfo.some((proc) => proc.family === filters.procedureFamily);
|
|
369
|
+
});
|
|
370
|
+
console.log(
|
|
371
|
+
`[CLINIC_SERVICE] Applied procedure family filter: ${initialCount} → ${filteredClinics.length}`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 🆕 DODAJTE: Procedure category filtering
|
|
376
|
+
if (filters.procedureCategory) {
|
|
377
|
+
const initialCount = filteredClinics.length;
|
|
378
|
+
filteredClinics = filteredClinics.filter((clinic) => {
|
|
379
|
+
const proceduresInfo = clinic.proceduresInfo || [];
|
|
380
|
+
return proceduresInfo.some((proc) => proc.categoryName === filters.procedureCategory);
|
|
381
|
+
});
|
|
382
|
+
console.log(
|
|
383
|
+
`[CLINIC_SERVICE] Applied procedure category filter: ${initialCount} → ${filteredClinics.length}`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// 🆕 DODAJTE: Procedure subcategory filtering
|
|
388
|
+
if (filters.procedureSubcategory) {
|
|
389
|
+
const initialCount = filteredClinics.length;
|
|
390
|
+
filteredClinics = filteredClinics.filter((clinic) => {
|
|
391
|
+
const proceduresInfo = clinic.proceduresInfo || [];
|
|
392
|
+
return proceduresInfo.some((proc) => proc.subcategoryName === filters.procedureSubcategory);
|
|
393
|
+
});
|
|
394
|
+
console.log(
|
|
395
|
+
`[CLINIC_SERVICE] Applied procedure subcategory filter: ${initialCount} → ${filteredClinics.length}`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// 🆕 DODAJTE: Procedure technology filtering
|
|
400
|
+
if (filters.procedureTechnology) {
|
|
401
|
+
const initialCount = filteredClinics.length;
|
|
402
|
+
filteredClinics = filteredClinics.filter((clinic) => {
|
|
403
|
+
const proceduresInfo = clinic.proceduresInfo || [];
|
|
404
|
+
return proceduresInfo.some((proc) => proc.technologyName === filters.procedureTechnology);
|
|
405
|
+
});
|
|
406
|
+
console.log(
|
|
407
|
+
`[CLINIC_SERVICE] Applied procedure technology filter: ${initialCount} → ${filteredClinics.length}`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ✅ Geo-radius filter (postojeći kod)
|
|
412
|
+
if (filters.center && filters.radiusInKm) {
|
|
413
|
+
const initialCount = filteredClinics.length;
|
|
414
|
+
filteredClinics = filteredClinics.filter((clinic) => {
|
|
415
|
+
if (!clinic.location?.latitude || !clinic.location?.longitude) {
|
|
416
|
+
console.log(`[CLINIC_SERVICE] Clinic ${clinic.id} missing location data`);
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const distance =
|
|
421
|
+
distanceBetween(
|
|
422
|
+
[filters.center.latitude, filters.center.longitude],
|
|
423
|
+
[clinic.location.latitude, clinic.location.longitude],
|
|
424
|
+
) / 1000; // Convert to km
|
|
425
|
+
|
|
426
|
+
console.log(
|
|
427
|
+
`[CLINIC_SERVICE] Clinic ${clinic.name}: distance ${distance.toFixed(2)}km (limit: ${
|
|
428
|
+
filters.radiusInKm
|
|
429
|
+
}km)`,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
// Attach distance for frontend sorting/display
|
|
433
|
+
(clinic as any).distance = distance;
|
|
434
|
+
return distance <= filters.radiusInKm;
|
|
435
|
+
});
|
|
436
|
+
console.log(
|
|
437
|
+
`[CLINIC_SERVICE] Applied geo filter (${filters.radiusInKm}km): ${initialCount} → ${filteredClinics.length}`,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
// Sort by distance when geo filtering is applied
|
|
441
|
+
filteredClinics.sort((a, b) => ((a as any).distance || 0) - ((b as any).distance || 0));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
console.log(`[CLINIC_SERVICE] Final filtered result: ${filteredClinics.length} clinics`);
|
|
445
|
+
return filteredClinics as (Clinic & { distance?: number })[];
|
|
446
|
+
}
|