@blackcode_sa/metaestetics-api 1.15.16 → 1.15.17-staging.0
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 +377 -222
- package/dist/admin/index.d.ts +377 -222
- package/dist/admin/index.js +625 -206
- package/dist/admin/index.mjs +624 -206
- package/dist/backoffice/index.d.mts +24 -0
- package/dist/backoffice/index.d.ts +24 -0
- package/dist/index.d.mts +292 -4
- package/dist/index.d.ts +292 -4
- package/dist/index.js +1142 -630
- package/dist/index.mjs +1137 -617
- package/package.json +2 -1
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +151 -129
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +2137 -2091
- 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 +966 -966
- 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 +184 -125
- package/src/admin/booking/booking.admin.ts +1330 -1073
- package/src/admin/booking/booking.calculator.ts +850 -712
- package/src/admin/booking/booking.types.ts +76 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +62 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +2 -1
- package/src/admin/calendar/resource-calendar.admin.ts +198 -0
- 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 +83 -83
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +139 -139
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +1253 -1253
- 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/clinicWelcome/clinicWelcome.mailing.ts +292 -292
- package/src/admin/mailing/clinicWelcome/index.ts +1 -1
- package/src/admin/mailing/clinicWelcome/templates/welcome.template.ts +225 -225
- package/src/admin/mailing/index.ts +5 -5
- package/src/admin/mailing/patientInvite/index.ts +2 -2
- package/src/admin/mailing/patientInvite/patientInvite.mailing.ts +415 -415
- package/src/admin/mailing/patientInvite/templates/invitation.template.ts +105 -105
- 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 +818 -818
- 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 +260 -260
- 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 +557 -557
- 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 +1153 -1153
- 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 +239 -239
- 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 +17 -17
- package/src/config/tiers.config.ts +255 -229
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +211 -211
- 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 +298 -298
- package/src/services/__tests__/auth.service.test.ts +310 -310
- package/src/services/__tests__/base.service.test.ts +36 -36
- package/src/services/__tests__/user.service.test.ts +530 -530
- 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 +2148 -2148
- 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 +2943 -2941
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +620 -620
- package/src/services/appointment/utils/extended-procedure.utils.ts +354 -354
- package/src/services/appointment/utils/form-initialization.utils.ts +516 -516
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +468 -468
- package/src/services/appointment/utils/zone-photo.utils.ts +302 -302
- package/src/services/auth/auth.service.ts +1435 -1435
- 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 +1693 -1693
- 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 +676 -676
- 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 +265 -265
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +222 -222
- package/src/services/clinic/__tests__/clinic.service.test.ts +302 -302
- 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 +720 -720
- 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 +1023 -1023
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +462 -462
- package/src/services/clinic/utils/index.ts +10 -10
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +83 -83
- 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 +597 -597
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +16 -15
- 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 +286 -286
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +1021 -1021
- package/src/services/patient/patientRequirements.service.ts +309 -309
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/body-assessment.utils.ts +159 -159
- package/src/services/patient/utils/clinic.utils.ts +159 -159
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/hair-scalp-assessment.utils.ts +158 -158
- 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/pre-surgical-assessment.utils.ts +161 -161
- 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/skin-quality-assessment.utils.ts +160 -160
- 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 +2355 -2354
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +2521 -2521
- package/src/services/resource/README.md +119 -0
- package/src/services/resource/index.ts +1 -0
- package/src/services/resource/resource.service.ts +555 -0
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +745 -745
- package/src/services/tier-enforcement.ts +240 -240
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +533 -533
- package/src/services/user/user.v2.service.ts +467 -467
- 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 +524 -517
- package/src/types/calendar/index.ts +261 -260
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +530 -529
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/rbac.types.ts +64 -63
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +50 -47
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +300 -300
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/body-assessment.types.ts +93 -93
- package/src/types/patient/hair-scalp-assessment.types.ts +98 -98
- package/src/types/patient/index.ts +279 -279
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/pre-surgical-assessment.types.ts +95 -95
- package/src/types/patient/skin-quality-assessment.types.ts +105 -105
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +208 -208
- package/src/types/procedure/index.ts +189 -183
- package/src/types/profile/index.ts +39 -39
- package/src/types/resource/README.md +153 -0
- package/src/types/resource/index.ts +199 -0
- package/src/types/reviews/index.ts +132 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +60 -60
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/README.md +94 -0
- package/src/validations/appointment.schema.ts +589 -589
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +494 -494
- 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 +21 -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/body-assessment.schema.ts +82 -82
- package/src/validations/patient/hair-scalp-assessment.schema.ts +70 -70
- package/src/validations/patient/medical-info.schema.ts +177 -177
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/pre-surgical-assessment.schema.ts +78 -78
- package/src/validations/patient/skin-quality-assessment.schema.ts +70 -70
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -217
- package/src/validations/practitioner.schema.ts +224 -224
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +136 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/resource.schema.ts +57 -0
- package/src/validations/reviews.schema.ts +195 -195
- package/src/validations/schemas.ts +109 -109
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,2354 +1,2355 @@
|
|
|
1
|
-
import {
|
|
2
|
-
collection,
|
|
3
|
-
doc,
|
|
4
|
-
getDoc,
|
|
5
|
-
getDocs,
|
|
6
|
-
query,
|
|
7
|
-
where,
|
|
8
|
-
updateDoc,
|
|
9
|
-
setDoc,
|
|
10
|
-
deleteDoc,
|
|
11
|
-
Timestamp,
|
|
12
|
-
serverTimestamp,
|
|
13
|
-
limit,
|
|
14
|
-
startAfter,
|
|
15
|
-
orderBy,
|
|
16
|
-
writeBatch,
|
|
17
|
-
arrayUnion,
|
|
18
|
-
arrayRemove,
|
|
19
|
-
FieldValue,
|
|
20
|
-
} from "firebase/firestore";
|
|
21
|
-
import { BaseService } from "../base.service";
|
|
22
|
-
import { enforceProviderLimit } from "../tier-enforcement";
|
|
23
|
-
import {
|
|
24
|
-
Practitioner,
|
|
25
|
-
CreatePractitionerData,
|
|
26
|
-
UpdatePractitionerData,
|
|
27
|
-
PRACTITIONERS_COLLECTION,
|
|
28
|
-
REGISTER_TOKENS_COLLECTION,
|
|
29
|
-
PractitionerStatus,
|
|
30
|
-
CreateDraftPractitionerData,
|
|
31
|
-
PractitionerToken,
|
|
32
|
-
CreatePractitionerTokenData,
|
|
33
|
-
PractitionerTokenStatus,
|
|
34
|
-
PractitionerBasicInfo,
|
|
35
|
-
} from "../../types/practitioner";
|
|
36
|
-
import { ProcedureSummaryInfo } from "../../types/procedure";
|
|
37
|
-
import { ClinicService } from "../clinic/clinic.service";
|
|
38
|
-
import {
|
|
39
|
-
MediaService,
|
|
40
|
-
MediaAccessLevel,
|
|
41
|
-
MediaResource,
|
|
42
|
-
} from "../media/media.service";
|
|
43
|
-
import {
|
|
44
|
-
practitionerSchema,
|
|
45
|
-
createPractitionerSchema,
|
|
46
|
-
createDraftPractitionerSchema,
|
|
47
|
-
practitionerTokenSchema,
|
|
48
|
-
createPractitionerTokenSchema,
|
|
49
|
-
} from "../../validations/practitioner.schema";
|
|
50
|
-
import { z } from "zod";
|
|
51
|
-
import { Auth } from "firebase/auth";
|
|
52
|
-
import { Firestore } from "firebase/firestore";
|
|
53
|
-
import { FirebaseApp } from "firebase/app";
|
|
54
|
-
import { PractitionerReviewInfo } from "../../types/reviews";
|
|
55
|
-
import { distanceBetween } from "geofire-common";
|
|
56
|
-
import { CertificationSpecialty } from "../../backoffice/types/static/certification.types";
|
|
57
|
-
import { Clinic, DoctorInfo, CLINICS_COLLECTION } from "../../types/clinic";
|
|
58
|
-
import { ClinicInfo } from "../../types/profile";
|
|
59
|
-
import { ProcedureService } from "../procedure/procedure.service";
|
|
60
|
-
import { ProcedureFamily } from "../../backoffice/types/static/procedure-family.types";
|
|
61
|
-
import {
|
|
62
|
-
Currency,
|
|
63
|
-
PricingMeasure,
|
|
64
|
-
} from "../../backoffice/types/static/pricing.types";
|
|
65
|
-
import { CreateProcedureData } from "../../types/procedure";
|
|
66
|
-
|
|
67
|
-
export class PractitionerService extends BaseService {
|
|
68
|
-
private clinicService?: ClinicService;
|
|
69
|
-
private mediaService: MediaService;
|
|
70
|
-
private procedureService?: ProcedureService;
|
|
71
|
-
|
|
72
|
-
constructor(
|
|
73
|
-
db: Firestore,
|
|
74
|
-
auth: Auth,
|
|
75
|
-
app: FirebaseApp,
|
|
76
|
-
clinicService?: ClinicService,
|
|
77
|
-
procedureService?: ProcedureService
|
|
78
|
-
) {
|
|
79
|
-
super(db, auth, app);
|
|
80
|
-
this.clinicService = clinicService;
|
|
81
|
-
this.procedureService = procedureService;
|
|
82
|
-
this.mediaService = new MediaService(db, auth, app);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
public getClinicService(): ClinicService {
|
|
86
|
-
if (!this.clinicService) {
|
|
87
|
-
throw new Error("Clinic service not initialized!");
|
|
88
|
-
}
|
|
89
|
-
return this.clinicService;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
private getProcedureService(): ProcedureService {
|
|
93
|
-
if (!this.procedureService) {
|
|
94
|
-
throw new Error("Procedure service not initialized!");
|
|
95
|
-
}
|
|
96
|
-
return this.procedureService;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
setClinicService(clinicService: ClinicService): void {
|
|
100
|
-
this.clinicService = clinicService;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
setProcedureService(procedureService: ProcedureService): void {
|
|
104
|
-
this.procedureService = procedureService;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Handles profile photo upload for practitioners
|
|
109
|
-
* @param profilePhoto - MediaResource (File, Blob, or URL string)
|
|
110
|
-
* @param practitionerId - ID of the practitioner
|
|
111
|
-
* @returns URL string of the uploaded or existing photo
|
|
112
|
-
*/
|
|
113
|
-
private async handleProfilePhotoUpload(
|
|
114
|
-
profilePhoto: MediaResource | undefined | null,
|
|
115
|
-
practitionerId: string
|
|
116
|
-
): Promise<string | undefined> {
|
|
117
|
-
if (!profilePhoto) {
|
|
118
|
-
return undefined;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// If it's already a URL string, return it as is
|
|
122
|
-
if (typeof profilePhoto === "string") {
|
|
123
|
-
return profilePhoto;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// If it's a File or Blob, upload it
|
|
127
|
-
if (profilePhoto instanceof File || profilePhoto instanceof Blob) {
|
|
128
|
-
console.log(
|
|
129
|
-
`[PractitionerService] Uploading profile photo for practitioner ${practitionerId}`
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
const mediaMetadata = await this.mediaService.uploadMedia(
|
|
133
|
-
profilePhoto,
|
|
134
|
-
practitionerId, // Using practitionerId as ownerId
|
|
135
|
-
MediaAccessLevel.PUBLIC, // Profile photos should be public
|
|
136
|
-
"practitioner_profile_photos",
|
|
137
|
-
profilePhoto instanceof File
|
|
138
|
-
? profilePhoto.name
|
|
139
|
-
: `profile_photo_${practitionerId}`
|
|
140
|
-
);
|
|
141
|
-
|
|
142
|
-
return mediaMetadata.url;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return undefined;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Processes BasicPractitionerInfo to handle profile photo uploads
|
|
150
|
-
* @param basicInfo - The basic info containing potential MediaResource profile photo
|
|
151
|
-
* @param practitionerId - ID of the practitioner
|
|
152
|
-
* @returns Processed basic info with URL string for profileImageUrl
|
|
153
|
-
*/
|
|
154
|
-
private async processBasicInfo(
|
|
155
|
-
basicInfo: PractitionerBasicInfo & {
|
|
156
|
-
profileImageUrl?: MediaResource | null;
|
|
157
|
-
},
|
|
158
|
-
practitionerId: string
|
|
159
|
-
): Promise<PractitionerBasicInfo> {
|
|
160
|
-
const processedBasicInfo = { ...basicInfo };
|
|
161
|
-
|
|
162
|
-
// Normalize email to lowercase to ensure consistent matching
|
|
163
|
-
if (processedBasicInfo.email) {
|
|
164
|
-
processedBasicInfo.email = processedBasicInfo.email.toLowerCase().trim();
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Handle profile photo upload if needed
|
|
168
|
-
if (basicInfo.profileImageUrl) {
|
|
169
|
-
const uploadedUrl = await this.handleProfilePhotoUpload(
|
|
170
|
-
basicInfo.profileImageUrl,
|
|
171
|
-
practitionerId
|
|
172
|
-
);
|
|
173
|
-
processedBasicInfo.profileImageUrl = uploadedUrl;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return processedBasicInfo;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Creates a new practitioner
|
|
181
|
-
*/
|
|
182
|
-
async createPractitioner(
|
|
183
|
-
data: CreatePractitionerData
|
|
184
|
-
): Promise<Practitioner> {
|
|
185
|
-
try {
|
|
186
|
-
const validData = createPractitionerSchema.parse(data);
|
|
187
|
-
|
|
188
|
-
// Enforce tier limit: resolve clinicGroupId from the first assigned clinic
|
|
189
|
-
if (validData.clinics && validData.clinics.length > 0) {
|
|
190
|
-
const clinicRef = doc(this.db, CLINICS_COLLECTION, validData.clinics[0]);
|
|
191
|
-
const clinicSnap = await getDoc(clinicRef);
|
|
192
|
-
if (clinicSnap.exists()) {
|
|
193
|
-
const clinicGroupId = (clinicSnap.data() as any).clinicGroupId;
|
|
194
|
-
if (clinicGroupId) {
|
|
195
|
-
await enforceProviderLimit(this.db, clinicGroupId, validData.clinics[0]);
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const practitionerId = this.generateId();
|
|
201
|
-
|
|
202
|
-
// Default review info
|
|
203
|
-
const reviewInfo: PractitionerReviewInfo = {
|
|
204
|
-
totalReviews: 0,
|
|
205
|
-
averageRating: 0,
|
|
206
|
-
knowledgeAndExpertise: 0,
|
|
207
|
-
communicationSkills: 0,
|
|
208
|
-
bedSideManner: 0,
|
|
209
|
-
thoroughness: 0,
|
|
210
|
-
trustworthiness: 0,
|
|
211
|
-
recommendationPercentage: 0,
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
// Create practitioner object
|
|
215
|
-
const fullNameLower =
|
|
216
|
-
`${validData.basicInfo.firstName} ${validData.basicInfo.lastName}`.toLowerCase();
|
|
217
|
-
const practitioner: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
218
|
-
createdAt: FieldValue;
|
|
219
|
-
updatedAt: FieldValue;
|
|
220
|
-
} = {
|
|
221
|
-
id: practitionerId,
|
|
222
|
-
userRef: validData.userRef,
|
|
223
|
-
basicInfo: await this.processBasicInfo(
|
|
224
|
-
validData.basicInfo,
|
|
225
|
-
practitionerId
|
|
226
|
-
),
|
|
227
|
-
fullNameLower: fullNameLower, // Ensure this is present
|
|
228
|
-
certification: validData.certification,
|
|
229
|
-
clinics: validData.clinics || [],
|
|
230
|
-
clinicWorkingHours: validData.clinicWorkingHours || [],
|
|
231
|
-
clinicsInfo: [],
|
|
232
|
-
procedures: [],
|
|
233
|
-
proceduresInfo: [],
|
|
234
|
-
reviewInfo,
|
|
235
|
-
isActive: validData.isActive !== undefined ? validData.isActive : true,
|
|
236
|
-
isVerified:
|
|
237
|
-
validData.isVerified !== undefined ? validData.isVerified : false,
|
|
238
|
-
status: validData.status || PractitionerStatus.ACTIVE,
|
|
239
|
-
createdAt: serverTimestamp(),
|
|
240
|
-
updatedAt: serverTimestamp(),
|
|
241
|
-
};
|
|
242
|
-
|
|
243
|
-
// Validate the entire object
|
|
244
|
-
practitionerSchema.parse({
|
|
245
|
-
...practitioner,
|
|
246
|
-
createdAt: Timestamp.now(),
|
|
247
|
-
updatedAt: Timestamp.now(),
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// Create practitioner document
|
|
251
|
-
const practitionerRef = doc(
|
|
252
|
-
this.db,
|
|
253
|
-
PRACTITIONERS_COLLECTION,
|
|
254
|
-
practitionerId
|
|
255
|
-
);
|
|
256
|
-
|
|
257
|
-
await setDoc(practitionerRef, practitioner);
|
|
258
|
-
|
|
259
|
-
// Return the created practitioner
|
|
260
|
-
const createdPractitioner = await this.getPractitioner(practitionerId);
|
|
261
|
-
if (!createdPractitioner) {
|
|
262
|
-
throw new Error(
|
|
263
|
-
`Failed to retrieve created practitioner ${practitionerId}`
|
|
264
|
-
);
|
|
265
|
-
}
|
|
266
|
-
return createdPractitioner;
|
|
267
|
-
} catch (error) {
|
|
268
|
-
if (error instanceof z.ZodError) {
|
|
269
|
-
throw new Error(`Invalid practitioner data: ${error.message}`);
|
|
270
|
-
}
|
|
271
|
-
console.error("Error creating practitioner:", error);
|
|
272
|
-
throw error;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/**
|
|
277
|
-
* Kreira novi draft profil zdravstvenog radnika bez povezanog korisnika
|
|
278
|
-
* Koristi se od strane administratora klinike za kreiranje profila i kasnije pozivanje
|
|
279
|
-
* @param data Podaci za kreiranje draft profila
|
|
280
|
-
* @param createdBy ID administratora koji kreira profil
|
|
281
|
-
* @param clinicId ID klinike za koju se kreira profil
|
|
282
|
-
* @returns Objekt koji sadrži kreirani draft profil i token za registraciju
|
|
283
|
-
*/
|
|
284
|
-
async createDraftPractitioner(
|
|
285
|
-
data: CreateDraftPractitionerData,
|
|
286
|
-
createdBy: string,
|
|
287
|
-
clinicId: string
|
|
288
|
-
): Promise<{ practitioner: Practitioner; token: PractitionerToken }> {
|
|
289
|
-
try {
|
|
290
|
-
// Validacija ulaznih podataka
|
|
291
|
-
const validatedData = createDraftPractitionerSchema.parse(data);
|
|
292
|
-
|
|
293
|
-
// Provera da li klinika postoji
|
|
294
|
-
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
295
|
-
if (!clinic) {
|
|
296
|
-
throw new Error(`Clinic ${clinicId} not found`);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Enforce tier limit before creating draft practitioner (per-branch)
|
|
300
|
-
if (clinic.clinicGroupId) {
|
|
301
|
-
await enforceProviderLimit(this.db, clinic.clinicGroupId, clinicId);
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Make sure the primary clinic (clinicId) is always included
|
|
305
|
-
// Merge the clinics array with the primary clinicId, avoiding duplicates
|
|
306
|
-
const clinicsToAdd = new Set<string>([clinicId]);
|
|
307
|
-
|
|
308
|
-
// Add additional clinics if provided
|
|
309
|
-
if (data.clinics && data.clinics.length > 0) {
|
|
310
|
-
for (const cId of data.clinics) {
|
|
311
|
-
// Verify each additional clinic exists
|
|
312
|
-
if (cId !== clinicId) {
|
|
313
|
-
// Skip checking the primary clinic again
|
|
314
|
-
const otherClinic = await this.getClinicService().getClinic(cId);
|
|
315
|
-
if (!otherClinic) {
|
|
316
|
-
throw new Error(`Clinic ${cId} not found`);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
clinicsToAdd.add(cId);
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Convert Set to Array
|
|
324
|
-
const clinics = Array.from(clinicsToAdd);
|
|
325
|
-
|
|
326
|
-
// Initialize default review info for new practitioners
|
|
327
|
-
const defaultReviewInfo: PractitionerReviewInfo = {
|
|
328
|
-
totalReviews: 0,
|
|
329
|
-
averageRating: 0,
|
|
330
|
-
knowledgeAndExpertise: 0,
|
|
331
|
-
communicationSkills: 0,
|
|
332
|
-
bedSideManner: 0,
|
|
333
|
-
thoroughness: 0,
|
|
334
|
-
trustworthiness: 0,
|
|
335
|
-
recommendationPercentage: 0,
|
|
336
|
-
};
|
|
337
|
-
|
|
338
|
-
// Generate ID for the new practitioner
|
|
339
|
-
const practitionerId = this.generateId();
|
|
340
|
-
|
|
341
|
-
// Create clinicsInfo from the merged clinics array
|
|
342
|
-
const clinicsInfo: ClinicInfo[] = [];
|
|
343
|
-
|
|
344
|
-
// Populate clinicsInfo for each clinic
|
|
345
|
-
for (const cId of clinics) {
|
|
346
|
-
const clinicData = await this.getClinicService().getClinic(cId);
|
|
347
|
-
if (clinicData) {
|
|
348
|
-
// Ensure we're creating a ClinicInfo object that matches the interface structure
|
|
349
|
-
clinicsInfo.push({
|
|
350
|
-
id: clinicData.id,
|
|
351
|
-
name: clinicData.name,
|
|
352
|
-
location: clinicData.location,
|
|
353
|
-
contactInfo: clinicData.contactInfo,
|
|
354
|
-
// Make sure we're using the right property for featuredPhoto
|
|
355
|
-
featuredPhoto:
|
|
356
|
-
clinicData.featuredPhotos && clinicData.featuredPhotos.length > 0
|
|
357
|
-
? typeof clinicData.featuredPhotos[0] === "string"
|
|
358
|
-
? clinicData.featuredPhotos[0]
|
|
359
|
-
: ""
|
|
360
|
-
: (typeof clinicData.coverPhoto === "string"
|
|
361
|
-
? clinicData.coverPhoto
|
|
362
|
-
: "") || "",
|
|
363
|
-
description: clinicData.description || null,
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Use provided clinicsInfo if available, otherwise use the ones we just created
|
|
369
|
-
const finalClinicsInfo =
|
|
370
|
-
validatedData.clinicsInfo && validatedData.clinicsInfo.length > 0
|
|
371
|
-
? validatedData.clinicsInfo
|
|
372
|
-
: clinicsInfo;
|
|
373
|
-
|
|
374
|
-
const proceduresInfo: ProcedureSummaryInfo[] = [];
|
|
375
|
-
|
|
376
|
-
// Add fullNameLower for draft
|
|
377
|
-
const fullNameLowerDraft =
|
|
378
|
-
`${validatedData.basicInfo.firstName} ${validatedData.basicInfo.lastName}`.toLowerCase();
|
|
379
|
-
const practitionerData: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
380
|
-
createdAt: ReturnType<typeof serverTimestamp>;
|
|
381
|
-
updatedAt: ReturnType<typeof serverTimestamp>;
|
|
382
|
-
} = {
|
|
383
|
-
id: practitionerId,
|
|
384
|
-
userRef: "", // Prazno - biće popunjeno kada korisnik kreira nalog
|
|
385
|
-
basicInfo: await this.processBasicInfo(
|
|
386
|
-
validatedData.basicInfo,
|
|
387
|
-
practitionerId
|
|
388
|
-
),
|
|
389
|
-
fullNameLower: fullNameLowerDraft, // Ensure this is present
|
|
390
|
-
certification: validatedData.certification,
|
|
391
|
-
clinics: clinics,
|
|
392
|
-
clinicWorkingHours: validatedData.clinicWorkingHours || [],
|
|
393
|
-
clinicsInfo: finalClinicsInfo,
|
|
394
|
-
procedures: [],
|
|
395
|
-
proceduresInfo: proceduresInfo,
|
|
396
|
-
reviewInfo: defaultReviewInfo,
|
|
397
|
-
isActive:
|
|
398
|
-
validatedData.isActive !== undefined ? validatedData.isActive : false,
|
|
399
|
-
isVerified:
|
|
400
|
-
validatedData.isVerified !== undefined
|
|
401
|
-
? validatedData.isVerified
|
|
402
|
-
: false,
|
|
403
|
-
status: PractitionerStatus.DRAFT,
|
|
404
|
-
createdAt: serverTimestamp(),
|
|
405
|
-
updatedAt: serverTimestamp(),
|
|
406
|
-
};
|
|
407
|
-
|
|
408
|
-
// Validacija kompletnog objekta
|
|
409
|
-
// Koristimo privremeni userRef za validaciju, biće prazan u bazi
|
|
410
|
-
practitionerSchema.parse({
|
|
411
|
-
...practitionerData,
|
|
412
|
-
userRef: "temp-for-validation",
|
|
413
|
-
createdAt: Timestamp.now(),
|
|
414
|
-
updatedAt: Timestamp.now(),
|
|
415
|
-
});
|
|
416
|
-
|
|
417
|
-
// Čuvamo u Firestore
|
|
418
|
-
await setDoc(
|
|
419
|
-
doc(this.db, PRACTITIONERS_COLLECTION, practitionerData.id),
|
|
420
|
-
practitionerData
|
|
421
|
-
);
|
|
422
|
-
|
|
423
|
-
const savedPractitioner = await this.getPractitioner(practitionerData.id);
|
|
424
|
-
if (!savedPractitioner) {
|
|
425
|
-
throw new Error("Failed to create draft practitioner profile");
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
// Automatski kreiramo token za registraciju
|
|
429
|
-
const tokenString = this.generateId().slice(0, 6).toUpperCase();
|
|
430
|
-
|
|
431
|
-
// Default expiration is 7 days from now
|
|
432
|
-
const expiration = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
433
|
-
|
|
434
|
-
const token: PractitionerToken = {
|
|
435
|
-
id: this.generateId(),
|
|
436
|
-
token: tokenString,
|
|
437
|
-
practitionerId: practitionerId,
|
|
438
|
-
email: practitionerData.basicInfo.email,
|
|
439
|
-
clinicId: clinicId,
|
|
440
|
-
status: PractitionerTokenStatus.ACTIVE,
|
|
441
|
-
createdBy: createdBy,
|
|
442
|
-
createdAt: Timestamp.now(),
|
|
443
|
-
expiresAt: Timestamp.fromDate(expiration),
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
// Validate token object
|
|
447
|
-
practitionerTokenSchema.parse(token);
|
|
448
|
-
|
|
449
|
-
// Store the token in the practitioner document's register_tokens subcollection
|
|
450
|
-
const tokenPath = `${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
|
|
451
|
-
await setDoc(doc(this.db, tokenPath), token);
|
|
452
|
-
|
|
453
|
-
// Ovde bi bilo slanje emaila sa tokenom, ali to ćemo implementirati kasnije
|
|
454
|
-
// TODO: Implement email sending with Cloud Functions
|
|
455
|
-
|
|
456
|
-
return { practitioner: savedPractitioner, token };
|
|
457
|
-
} catch (error) {
|
|
458
|
-
if (error instanceof z.ZodError) {
|
|
459
|
-
throw new Error("Invalid practitioner data: " + error.message);
|
|
460
|
-
}
|
|
461
|
-
throw error;
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
/**
|
|
466
|
-
* Creates a token for inviting practitioner to claim their profile
|
|
467
|
-
* @param data Data for creating token
|
|
468
|
-
* @param createdBy ID of the user creating the token
|
|
469
|
-
* @returns Created token
|
|
470
|
-
*/
|
|
471
|
-
async createPractitionerToken(
|
|
472
|
-
data: CreatePractitionerTokenData,
|
|
473
|
-
createdBy: string
|
|
474
|
-
): Promise<PractitionerToken> {
|
|
475
|
-
try {
|
|
476
|
-
// Validate data
|
|
477
|
-
const validatedData = createPractitionerTokenSchema.parse(data);
|
|
478
|
-
|
|
479
|
-
// Check if practitioner exists and is in DRAFT status
|
|
480
|
-
const practitioner = await this.getPractitioner(
|
|
481
|
-
validatedData.practitionerId
|
|
482
|
-
);
|
|
483
|
-
if (!practitioner) {
|
|
484
|
-
throw new Error("Practitioner not found");
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
488
|
-
throw new Error(
|
|
489
|
-
"Can only create tokens for practitioners in DRAFT status"
|
|
490
|
-
);
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
// Check if clinic exists and practitioner belongs to it
|
|
494
|
-
const clinic = await this.getClinicService().getClinic(
|
|
495
|
-
validatedData.clinicId
|
|
496
|
-
);
|
|
497
|
-
if (!clinic) {
|
|
498
|
-
throw new Error(`Clinic ${validatedData.clinicId} not found`);
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
if (!practitioner.clinics.includes(validatedData.clinicId)) {
|
|
502
|
-
throw new Error("Practitioner is not associated with this clinic");
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Security check: Verify that the clinic belongs to the clinic group of the user creating the token
|
|
506
|
-
// createdBy can be either clinicGroupId or clinicId
|
|
507
|
-
let expectedClinicGroupId: string | null = null;
|
|
508
|
-
|
|
509
|
-
// First, check if createdBy matches the clinic's clinicGroupId directly
|
|
510
|
-
if (clinic.clinicGroupId === createdBy) {
|
|
511
|
-
// createdBy is the clinicGroupId, which matches - this is valid
|
|
512
|
-
expectedClinicGroupId = createdBy;
|
|
513
|
-
} else {
|
|
514
|
-
// createdBy might be a clinicId, check if that clinic belongs to the same group
|
|
515
|
-
try {
|
|
516
|
-
const creatorClinic = await this.getClinicService().getClinic(createdBy);
|
|
517
|
-
if (creatorClinic && creatorClinic.clinicGroupId === clinic.clinicGroupId) {
|
|
518
|
-
// Both clinics belong to the same group - valid
|
|
519
|
-
expectedClinicGroupId = clinic.clinicGroupId;
|
|
520
|
-
} else {
|
|
521
|
-
throw new Error("Clinic does not belong to your clinic group");
|
|
522
|
-
}
|
|
523
|
-
} catch (error: any) {
|
|
524
|
-
// If createdBy is not a valid clinicId, or clinics don't match, reject
|
|
525
|
-
if (error.message === "Clinic does not belong to your clinic group") {
|
|
526
|
-
throw error;
|
|
527
|
-
}
|
|
528
|
-
// If getClinic fails, createdBy might be a clinicGroupId that doesn't match
|
|
529
|
-
throw new Error("Clinic does not belong to your clinic group");
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
// Default expiration is 7 days from now if not specified
|
|
534
|
-
const expiration =
|
|
535
|
-
validatedData.expiresAt ||
|
|
536
|
-
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
537
|
-
|
|
538
|
-
// Generate a token (6 characters) using generateId from BaseService
|
|
539
|
-
const tokenString = this.generateId().slice(0, 6).toUpperCase();
|
|
540
|
-
|
|
541
|
-
const token: PractitionerToken = {
|
|
542
|
-
id: this.generateId(),
|
|
543
|
-
token: tokenString,
|
|
544
|
-
practitionerId: validatedData.practitionerId,
|
|
545
|
-
email: validatedData.email,
|
|
546
|
-
clinicId: validatedData.clinicId,
|
|
547
|
-
status: PractitionerTokenStatus.ACTIVE,
|
|
548
|
-
createdBy: createdBy,
|
|
549
|
-
createdAt: Timestamp.now(),
|
|
550
|
-
expiresAt: Timestamp.fromDate(expiration),
|
|
551
|
-
};
|
|
552
|
-
|
|
553
|
-
// Validate token object
|
|
554
|
-
practitionerTokenSchema.parse(token);
|
|
555
|
-
|
|
556
|
-
// Store the token in the practitioner document's register_tokens subcollection
|
|
557
|
-
const tokenPath = `${PRACTITIONERS_COLLECTION}/${validatedData.practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
|
|
558
|
-
await setDoc(doc(this.db, tokenPath), token);
|
|
559
|
-
|
|
560
|
-
return token;
|
|
561
|
-
} catch (error) {
|
|
562
|
-
if (error instanceof z.ZodError) {
|
|
563
|
-
throw new Error("Invalid token data: " + error.message);
|
|
564
|
-
}
|
|
565
|
-
throw error;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Gets active tokens for a practitioner
|
|
571
|
-
* @param practitionerId ID of the practitioner
|
|
572
|
-
* @param clinicId Optional clinic ID to filter tokens by. If provided, only returns tokens for this clinic.
|
|
573
|
-
* @returns Array of active tokens
|
|
574
|
-
*/
|
|
575
|
-
async getPractitionerActiveTokens(
|
|
576
|
-
practitionerId: string,
|
|
577
|
-
clinicId?: string
|
|
578
|
-
): Promise<PractitionerToken[]> {
|
|
579
|
-
const tokensRef = collection(
|
|
580
|
-
this.db,
|
|
581
|
-
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
|
|
582
|
-
);
|
|
583
|
-
|
|
584
|
-
const conditions = [
|
|
585
|
-
where("status", "==", PractitionerTokenStatus.ACTIVE),
|
|
586
|
-
where("expiresAt", ">", Timestamp.now())
|
|
587
|
-
];
|
|
588
|
-
|
|
589
|
-
// Filter by clinic if provided
|
|
590
|
-
if (clinicId) {
|
|
591
|
-
conditions.push(where("clinicId", "==", clinicId));
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
const q = query(tokensRef, ...conditions);
|
|
595
|
-
|
|
596
|
-
const querySnapshot = await getDocs(q);
|
|
597
|
-
return querySnapshot.docs.map((doc) => doc.data() as PractitionerToken);
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/**
|
|
601
|
-
* Gets a token by its string value and validates it
|
|
602
|
-
* @param tokenString The token string to find
|
|
603
|
-
* @returns The token if found and valid, null otherwise
|
|
604
|
-
*/
|
|
605
|
-
async validateToken(tokenString: string): Promise<PractitionerToken | null> {
|
|
606
|
-
// We need to search through all practitioners' register_tokens subcollections
|
|
607
|
-
const practitionersRef = collection(this.db, PRACTITIONERS_COLLECTION);
|
|
608
|
-
const practitionersSnapshot = await getDocs(practitionersRef);
|
|
609
|
-
|
|
610
|
-
for (const practitionerDoc of practitionersSnapshot.docs) {
|
|
611
|
-
const practitionerId = practitionerDoc.id;
|
|
612
|
-
const tokensRef = collection(
|
|
613
|
-
this.db,
|
|
614
|
-
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
|
|
615
|
-
);
|
|
616
|
-
|
|
617
|
-
console.log(
|
|
618
|
-
`[PRACTITIONER] Validating token for practitioner ${practitionerId}`,
|
|
619
|
-
{
|
|
620
|
-
tokenString,
|
|
621
|
-
timestamp: Timestamp.now().toDate(),
|
|
622
|
-
}
|
|
623
|
-
);
|
|
624
|
-
|
|
625
|
-
const q = query(
|
|
626
|
-
tokensRef,
|
|
627
|
-
where("token", "==", tokenString),
|
|
628
|
-
where("status", "==", PractitionerTokenStatus.ACTIVE),
|
|
629
|
-
where("expiresAt", ">", Timestamp.now())
|
|
630
|
-
);
|
|
631
|
-
|
|
632
|
-
try {
|
|
633
|
-
const tokenSnapshot = await getDocs(q);
|
|
634
|
-
console.log(
|
|
635
|
-
`[PRACTITIONER] Token query results for practitioner ${practitionerId}`,
|
|
636
|
-
{
|
|
637
|
-
found: !tokenSnapshot.empty,
|
|
638
|
-
count: tokenSnapshot.size,
|
|
639
|
-
}
|
|
640
|
-
);
|
|
641
|
-
|
|
642
|
-
if (!tokenSnapshot.empty) {
|
|
643
|
-
const tokenData = tokenSnapshot.docs[0].data() as PractitionerToken;
|
|
644
|
-
console.log(`[PRACTITIONER] Valid token found`, {
|
|
645
|
-
tokenId: tokenData.id,
|
|
646
|
-
expiresAt: tokenData.expiresAt.toDate(),
|
|
647
|
-
});
|
|
648
|
-
return tokenData;
|
|
649
|
-
}
|
|
650
|
-
} catch (error) {
|
|
651
|
-
console.error(
|
|
652
|
-
`[PRACTITIONER] Error validating token for practitioner ${practitionerId}:`,
|
|
653
|
-
error
|
|
654
|
-
);
|
|
655
|
-
// Re-throw the error to be handled by the caller
|
|
656
|
-
throw error;
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
return null;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
/**
|
|
664
|
-
* Marks a token as used
|
|
665
|
-
* @param tokenId ID of the token
|
|
666
|
-
* @param practitionerId ID of the practitioner
|
|
667
|
-
* @param userId ID of the user using the token
|
|
668
|
-
*/
|
|
669
|
-
async markTokenAsUsed(
|
|
670
|
-
tokenId: string,
|
|
671
|
-
practitionerId: string,
|
|
672
|
-
userId: string
|
|
673
|
-
): Promise<void> {
|
|
674
|
-
const tokenRef = doc(
|
|
675
|
-
this.db,
|
|
676
|
-
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${tokenId}`
|
|
677
|
-
);
|
|
678
|
-
|
|
679
|
-
await updateDoc(tokenRef, {
|
|
680
|
-
status: PractitionerTokenStatus.USED,
|
|
681
|
-
usedBy: userId,
|
|
682
|
-
usedAt: Timestamp.now(),
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
/**
|
|
687
|
-
* Revokes a token by setting its status to REVOKED
|
|
688
|
-
* @param tokenId ID of the token
|
|
689
|
-
* @param practitionerId ID of the practitioner
|
|
690
|
-
* @param clinicId ID of the clinic that owns the token. Used to verify ownership before revoking.
|
|
691
|
-
* @throws Error if token doesn't exist or doesn't belong to the specified clinic
|
|
692
|
-
*/
|
|
693
|
-
async revokeToken(
|
|
694
|
-
tokenId: string,
|
|
695
|
-
practitionerId: string,
|
|
696
|
-
clinicId: string
|
|
697
|
-
): Promise<void> {
|
|
698
|
-
const tokenRef = doc(
|
|
699
|
-
this.db,
|
|
700
|
-
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${tokenId}`
|
|
701
|
-
);
|
|
702
|
-
|
|
703
|
-
// First, verify the token exists and belongs to the clinic
|
|
704
|
-
const tokenDoc = await getDoc(tokenRef);
|
|
705
|
-
if (!tokenDoc.exists()) {
|
|
706
|
-
throw new Error("Token not found");
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
const tokenData = tokenDoc.data() as PractitionerToken;
|
|
710
|
-
if (tokenData.clinicId !== clinicId) {
|
|
711
|
-
throw new Error("Token does not belong to the specified clinic");
|
|
712
|
-
}
|
|
713
|
-
|
|
714
|
-
// Only revoke if token is still active
|
|
715
|
-
if (tokenData.status !== PractitionerTokenStatus.ACTIVE) {
|
|
716
|
-
throw new Error("Token is not active and cannot be revoked");
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
await updateDoc(tokenRef, {
|
|
720
|
-
status: PractitionerTokenStatus.REVOKED,
|
|
721
|
-
updatedAt: serverTimestamp(),
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
/**
|
|
726
|
-
* Dohvata zdravstvenog radnika po ID-u
|
|
727
|
-
*/
|
|
728
|
-
async getPractitioner(practitionerId: string): Promise<Practitioner | null> {
|
|
729
|
-
const practitionerDoc = await getDoc(
|
|
730
|
-
doc(this.db, PRACTITIONERS_COLLECTION, practitionerId)
|
|
731
|
-
);
|
|
732
|
-
|
|
733
|
-
if (!practitionerDoc.exists()) {
|
|
734
|
-
return null;
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
return practitionerDoc.data() as Practitioner;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
/**
|
|
741
|
-
* Dohvata zdravstvenog radnika po User ID-u
|
|
742
|
-
*/
|
|
743
|
-
async getPractitionerByUserRef(
|
|
744
|
-
userRef: string
|
|
745
|
-
): Promise<Practitioner | null> {
|
|
746
|
-
const q = query(
|
|
747
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
748
|
-
where("userRef", "==", userRef)
|
|
749
|
-
);
|
|
750
|
-
|
|
751
|
-
const querySnapshot = await getDocs(q);
|
|
752
|
-
if (querySnapshot.empty) {
|
|
753
|
-
return null;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
return querySnapshot.docs[0].data() as Practitioner;
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
/**
|
|
760
|
-
* Finds a draft practitioner profile by email address
|
|
761
|
-
* Used to detect if a draft profile exists when a doctor registers without a token
|
|
762
|
-
*
|
|
763
|
-
* @param email - Email address to search for
|
|
764
|
-
* @returns Draft practitioner profile if found, null otherwise
|
|
765
|
-
*
|
|
766
|
-
* @remarks
|
|
767
|
-
* Requires Firestore composite index on:
|
|
768
|
-
* - Collection: practitioners
|
|
769
|
-
* - Fields: basicInfo.email (Ascending), status (Ascending), userRef (Ascending)
|
|
770
|
-
*/
|
|
771
|
-
async findDraftPractitionerByEmail(
|
|
772
|
-
email: string
|
|
773
|
-
): Promise<Practitioner | null> {
|
|
774
|
-
try {
|
|
775
|
-
const normalizedEmail = email.toLowerCase().trim();
|
|
776
|
-
|
|
777
|
-
console.log("[PRACTITIONER] Searching for draft practitioner by email", {
|
|
778
|
-
email: normalizedEmail,
|
|
779
|
-
});
|
|
780
|
-
|
|
781
|
-
const q = query(
|
|
782
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
783
|
-
where("basicInfo.email", "==", normalizedEmail),
|
|
784
|
-
where("status", "==", PractitionerStatus.DRAFT),
|
|
785
|
-
where("userRef", "==", ""),
|
|
786
|
-
limit(1)
|
|
787
|
-
);
|
|
788
|
-
|
|
789
|
-
const querySnapshot = await getDocs(q);
|
|
790
|
-
|
|
791
|
-
if (querySnapshot.empty) {
|
|
792
|
-
console.log("[PRACTITIONER] No draft practitioner found for email", {
|
|
793
|
-
email: normalizedEmail,
|
|
794
|
-
});
|
|
795
|
-
return null;
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
const draftPractitioner = querySnapshot.docs[0].data() as Practitioner;
|
|
799
|
-
console.log("[PRACTITIONER] Draft practitioner found", {
|
|
800
|
-
email: normalizedEmail,
|
|
801
|
-
practitionerId: draftPractitioner.id,
|
|
802
|
-
});
|
|
803
|
-
|
|
804
|
-
return draftPractitioner;
|
|
805
|
-
} catch (error) {
|
|
806
|
-
console.error(
|
|
807
|
-
"[PRACTITIONER] Error finding draft practitioner by email:",
|
|
808
|
-
error
|
|
809
|
-
);
|
|
810
|
-
// If query fails (e.g., index not created), return null to allow registration
|
|
811
|
-
// This prevents blocking registration if index is missing
|
|
812
|
-
return null;
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
/**
|
|
817
|
-
* Finds all draft practitioner profiles by email address
|
|
818
|
-
* Used when a doctor signs in with Google to show all clinic invitations
|
|
819
|
-
*
|
|
820
|
-
* @param email - Email address to search for
|
|
821
|
-
* @returns Array of draft practitioner profiles with clinic information
|
|
822
|
-
*
|
|
823
|
-
* @remarks
|
|
824
|
-
* Requires Firestore composite index on:
|
|
825
|
-
* - Collection: practitioners
|
|
826
|
-
* - Fields: basicInfo.email (Ascending), status (Ascending), userRef (Ascending)
|
|
827
|
-
*/
|
|
828
|
-
async getDraftProfilesByEmail(
|
|
829
|
-
email: string
|
|
830
|
-
): Promise<Practitioner[]> {
|
|
831
|
-
try {
|
|
832
|
-
const normalizedEmail = email.toLowerCase().trim();
|
|
833
|
-
|
|
834
|
-
console.log("[PRACTITIONER] Searching for all draft practitioners by email", {
|
|
835
|
-
email: normalizedEmail,
|
|
836
|
-
originalEmail: email,
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
const q = query(
|
|
840
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
841
|
-
where("basicInfo.email", "==", normalizedEmail),
|
|
842
|
-
where("status", "==", PractitionerStatus.DRAFT),
|
|
843
|
-
where("userRef", "==", "")
|
|
844
|
-
);
|
|
845
|
-
|
|
846
|
-
const querySnapshot = await getDocs(q);
|
|
847
|
-
|
|
848
|
-
if (querySnapshot.empty) {
|
|
849
|
-
console.log("[PRACTITIONER] No draft practitioners found for email", {
|
|
850
|
-
email: normalizedEmail,
|
|
851
|
-
originalEmail: email,
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
// Debug: Try to find ANY practitioners with this email (regardless of status)
|
|
855
|
-
const debugQ = query(
|
|
856
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
857
|
-
where("basicInfo.email", "==", normalizedEmail),
|
|
858
|
-
limit(5)
|
|
859
|
-
);
|
|
860
|
-
const debugSnapshot = await getDocs(debugQ);
|
|
861
|
-
console.log("[PRACTITIONER] Debug: Found practitioners with this email (any status):", {
|
|
862
|
-
count: debugSnapshot.size,
|
|
863
|
-
practitioners: debugSnapshot.docs.map(doc => ({
|
|
864
|
-
id: doc.id,
|
|
865
|
-
email: doc.data().basicInfo?.email,
|
|
866
|
-
status: doc.data().status,
|
|
867
|
-
userRef: doc.data().userRef,
|
|
868
|
-
})),
|
|
869
|
-
});
|
|
870
|
-
|
|
871
|
-
return [];
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
const draftPractitioners = querySnapshot.docs.map(
|
|
875
|
-
(doc) => doc.data() as Practitioner
|
|
876
|
-
);
|
|
877
|
-
|
|
878
|
-
console.log("[PRACTITIONER] Found draft practitioners", {
|
|
879
|
-
email: normalizedEmail,
|
|
880
|
-
count: draftPractitioners.length,
|
|
881
|
-
practitionerIds: draftPractitioners.map((p) => p.id),
|
|
882
|
-
});
|
|
883
|
-
|
|
884
|
-
return draftPractitioners;
|
|
885
|
-
} catch (error) {
|
|
886
|
-
console.error(
|
|
887
|
-
"[PRACTITIONER] Error finding draft practitioners by email:",
|
|
888
|
-
error
|
|
889
|
-
);
|
|
890
|
-
// If query fails (e.g., index not created), return empty array
|
|
891
|
-
return [];
|
|
892
|
-
}
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* Claims a draft practitioner profile and links it to a user account
|
|
897
|
-
* Used when a doctor selects which clinic(s) to join after Google Sign-In
|
|
898
|
-
*
|
|
899
|
-
* @param practitionerId - ID of the draft practitioner profile to claim
|
|
900
|
-
* @param userId - ID of the user account to link the profile to
|
|
901
|
-
* @returns The claimed practitioner profile
|
|
902
|
-
*/
|
|
903
|
-
async claimDraftProfileWithGoogle(
|
|
904
|
-
practitionerId: string,
|
|
905
|
-
userId: string
|
|
906
|
-
): Promise<Practitioner> {
|
|
907
|
-
try {
|
|
908
|
-
console.log("[PRACTITIONER] Claiming draft profile with Google", {
|
|
909
|
-
practitionerId,
|
|
910
|
-
userId,
|
|
911
|
-
});
|
|
912
|
-
|
|
913
|
-
// Get the draft practitioner profile
|
|
914
|
-
const practitioner = await this.getPractitioner(practitionerId);
|
|
915
|
-
if (!practitioner) {
|
|
916
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
// Ensure practitioner is in DRAFT status
|
|
920
|
-
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
921
|
-
throw new Error("This practitioner profile has already been claimed");
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
// Check if user already has a practitioner profile
|
|
925
|
-
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
926
|
-
if (existingPractitioner) {
|
|
927
|
-
// User already has a profile - merge clinics from draft profile into existing profile
|
|
928
|
-
console.log("[PRACTITIONER] User already has profile, merging clinics");
|
|
929
|
-
|
|
930
|
-
// Merge clinics (avoid duplicates)
|
|
931
|
-
const mergedClinics = Array.from(new Set([
|
|
932
|
-
...existingPractitioner.clinics,
|
|
933
|
-
...practitioner.clinics,
|
|
934
|
-
]));
|
|
935
|
-
|
|
936
|
-
// Merge clinic working hours
|
|
937
|
-
const mergedWorkingHours = [...existingPractitioner.clinicWorkingHours];
|
|
938
|
-
for (const workingHours of practitioner.clinicWorkingHours) {
|
|
939
|
-
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
940
|
-
mergedWorkingHours.push(workingHours);
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
// Merge clinics info (avoid duplicates)
|
|
945
|
-
const mergedClinicsInfo = [...existingPractitioner.clinicsInfo];
|
|
946
|
-
for (const clinicInfo of practitioner.clinicsInfo) {
|
|
947
|
-
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
948
|
-
mergedClinicsInfo.push(clinicInfo);
|
|
949
|
-
}
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
// Update existing practitioner with merged data
|
|
953
|
-
const updatedPractitioner = await this.updatePractitioner(existingPractitioner.id, {
|
|
954
|
-
clinics: mergedClinics,
|
|
955
|
-
clinicWorkingHours: mergedWorkingHours,
|
|
956
|
-
clinicsInfo: mergedClinicsInfo,
|
|
957
|
-
});
|
|
958
|
-
|
|
959
|
-
// Delete the draft profile since we've merged it
|
|
960
|
-
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId));
|
|
961
|
-
|
|
962
|
-
// Mark all active tokens for the draft practitioner as used
|
|
963
|
-
const activeTokens = await this.getPractitionerActiveTokens(practitionerId);
|
|
964
|
-
for (const token of activeTokens) {
|
|
965
|
-
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
966
|
-
}
|
|
967
|
-
|
|
968
|
-
return updatedPractitioner;
|
|
969
|
-
}
|
|
970
|
-
|
|
971
|
-
// Claim the profile by linking it to the user
|
|
972
|
-
const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
|
|
973
|
-
userRef: userId,
|
|
974
|
-
status: PractitionerStatus.ACTIVE,
|
|
975
|
-
});
|
|
976
|
-
|
|
977
|
-
// Mark all active tokens for this practitioner as used
|
|
978
|
-
const activeTokens = await this.getPractitionerActiveTokens(practitionerId);
|
|
979
|
-
for (const token of activeTokens) {
|
|
980
|
-
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
981
|
-
}
|
|
982
|
-
|
|
983
|
-
console.log("[PRACTITIONER] Draft profile claimed successfully", {
|
|
984
|
-
practitionerId: updatedPractitioner.id,
|
|
985
|
-
userId,
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
return updatedPractitioner;
|
|
989
|
-
} catch (error) {
|
|
990
|
-
console.error(
|
|
991
|
-
"[PRACTITIONER] Error claiming draft profile with Google:",
|
|
992
|
-
error
|
|
993
|
-
);
|
|
994
|
-
throw error;
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* Claims multiple draft practitioner profiles and merges them into one profile
|
|
1000
|
-
* Used when a doctor selects multiple clinics to join after Google Sign-In
|
|
1001
|
-
*
|
|
1002
|
-
* @param practitionerIds - Array of draft practitioner profile IDs to claim
|
|
1003
|
-
* @param userId - ID of the user account to link the profiles to
|
|
1004
|
-
* @returns The claimed practitioner profile (first one becomes main, others merged)
|
|
1005
|
-
*/
|
|
1006
|
-
async claimMultipleDraftProfilesWithGoogle(
|
|
1007
|
-
practitionerIds: string[],
|
|
1008
|
-
userId: string
|
|
1009
|
-
): Promise<Practitioner> {
|
|
1010
|
-
try {
|
|
1011
|
-
if (practitionerIds.length === 0) {
|
|
1012
|
-
throw new Error("No practitioner IDs provided");
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
console.log("[PRACTITIONER] Claiming multiple draft profiles with Google", {
|
|
1016
|
-
practitionerIds,
|
|
1017
|
-
userId,
|
|
1018
|
-
count: practitionerIds.length,
|
|
1019
|
-
});
|
|
1020
|
-
|
|
1021
|
-
// Get all draft profiles
|
|
1022
|
-
const draftProfiles = await Promise.all(
|
|
1023
|
-
practitionerIds.map(id => this.getPractitioner(id))
|
|
1024
|
-
);
|
|
1025
|
-
|
|
1026
|
-
// Filter out nulls and ensure all are drafts
|
|
1027
|
-
const validDrafts = draftProfiles.filter((p): p is Practitioner => {
|
|
1028
|
-
if (!p) return false;
|
|
1029
|
-
if (p.status !== PractitionerStatus.DRAFT) {
|
|
1030
|
-
throw new Error(`Practitioner ${p.id} has already been claimed`);
|
|
1031
|
-
}
|
|
1032
|
-
return true;
|
|
1033
|
-
});
|
|
1034
|
-
|
|
1035
|
-
if (validDrafts.length === 0) {
|
|
1036
|
-
throw new Error("No valid draft profiles found");
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
|
-
// Check if user already has a practitioner profile
|
|
1040
|
-
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
1041
|
-
|
|
1042
|
-
if (existingPractitioner) {
|
|
1043
|
-
// Merge all draft profiles into existing profile
|
|
1044
|
-
let mergedClinics = new Set(existingPractitioner.clinics);
|
|
1045
|
-
let mergedWorkingHours = [...existingPractitioner.clinicWorkingHours];
|
|
1046
|
-
let mergedClinicsInfo = [...existingPractitioner.clinicsInfo];
|
|
1047
|
-
|
|
1048
|
-
for (const draft of validDrafts) {
|
|
1049
|
-
// Merge clinics
|
|
1050
|
-
draft.clinics.forEach(clinicId => mergedClinics.add(clinicId));
|
|
1051
|
-
|
|
1052
|
-
// Merge working hours
|
|
1053
|
-
for (const workingHours of draft.clinicWorkingHours) {
|
|
1054
|
-
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
1055
|
-
mergedWorkingHours.push(workingHours);
|
|
1056
|
-
}
|
|
1057
|
-
}
|
|
1058
|
-
|
|
1059
|
-
// Merge clinics info
|
|
1060
|
-
for (const clinicInfo of draft.clinicsInfo) {
|
|
1061
|
-
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
1062
|
-
mergedClinicsInfo.push(clinicInfo);
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
// Update existing practitioner
|
|
1068
|
-
const updatedPractitioner = await this.updatePractitioner(existingPractitioner.id, {
|
|
1069
|
-
clinics: Array.from(mergedClinics),
|
|
1070
|
-
clinicWorkingHours: mergedWorkingHours,
|
|
1071
|
-
clinicsInfo: mergedClinicsInfo,
|
|
1072
|
-
});
|
|
1073
|
-
|
|
1074
|
-
// Delete all draft profiles
|
|
1075
|
-
for (const draft of validDrafts) {
|
|
1076
|
-
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
1077
|
-
|
|
1078
|
-
// Mark all active tokens as used
|
|
1079
|
-
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
1080
|
-
for (const token of activeTokens) {
|
|
1081
|
-
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
return updatedPractitioner;
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
// Use first draft as the main profile, merge others into it
|
|
1089
|
-
const mainDraft = validDrafts[0];
|
|
1090
|
-
const otherDrafts = validDrafts.slice(1);
|
|
1091
|
-
|
|
1092
|
-
// Merge clinics from other drafts
|
|
1093
|
-
let mergedClinics = new Set(mainDraft.clinics);
|
|
1094
|
-
let mergedWorkingHours = [...mainDraft.clinicWorkingHours];
|
|
1095
|
-
let mergedClinicsInfo = [...mainDraft.clinicsInfo];
|
|
1096
|
-
|
|
1097
|
-
for (const draft of otherDrafts) {
|
|
1098
|
-
draft.clinics.forEach(clinicId => mergedClinics.add(clinicId));
|
|
1099
|
-
|
|
1100
|
-
for (const workingHours of draft.clinicWorkingHours) {
|
|
1101
|
-
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
1102
|
-
mergedWorkingHours.push(workingHours);
|
|
1103
|
-
}
|
|
1104
|
-
}
|
|
1105
|
-
|
|
1106
|
-
for (const clinicInfo of draft.clinicsInfo) {
|
|
1107
|
-
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
1108
|
-
mergedClinicsInfo.push(clinicInfo);
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// Claim the main profile
|
|
1114
|
-
const updatedPractitioner = await this.updatePractitioner(mainDraft.id, {
|
|
1115
|
-
userRef: userId,
|
|
1116
|
-
status: PractitionerStatus.ACTIVE,
|
|
1117
|
-
clinics: Array.from(mergedClinics),
|
|
1118
|
-
clinicWorkingHours: mergedWorkingHours,
|
|
1119
|
-
clinicsInfo: mergedClinicsInfo,
|
|
1120
|
-
});
|
|
1121
|
-
|
|
1122
|
-
// Mark all active tokens for main profile as used
|
|
1123
|
-
const mainActiveTokens = await this.getPractitionerActiveTokens(mainDraft.id);
|
|
1124
|
-
for (const token of mainActiveTokens) {
|
|
1125
|
-
await this.markTokenAsUsed(token.id, mainDraft.id, userId);
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
// Delete other draft profiles
|
|
1129
|
-
for (const draft of otherDrafts) {
|
|
1130
|
-
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
1131
|
-
|
|
1132
|
-
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
1133
|
-
for (const token of activeTokens) {
|
|
1134
|
-
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
console.log("[PRACTITIONER] Multiple draft profiles claimed successfully", {
|
|
1139
|
-
practitionerId: updatedPractitioner.id,
|
|
1140
|
-
userId,
|
|
1141
|
-
mergedCount: validDrafts.length,
|
|
1142
|
-
});
|
|
1143
|
-
|
|
1144
|
-
return updatedPractitioner;
|
|
1145
|
-
} catch (error) {
|
|
1146
|
-
console.error(
|
|
1147
|
-
"[PRACTITIONER] Error claiming multiple draft profiles with Google:",
|
|
1148
|
-
error
|
|
1149
|
-
);
|
|
1150
|
-
throw error;
|
|
1151
|
-
}
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* Dohvata sve zdravstvene radnike za određenu kliniku sa statusom ACTIVE
|
|
1156
|
-
*/
|
|
1157
|
-
async getPractitionersByClinic(clinicId: string): Promise<Practitioner[]> {
|
|
1158
|
-
const q = query(
|
|
1159
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1160
|
-
where("clinics", "array-contains", clinicId),
|
|
1161
|
-
where("isActive", "==", true),
|
|
1162
|
-
where("status", "==", PractitionerStatus.ACTIVE)
|
|
1163
|
-
);
|
|
1164
|
-
|
|
1165
|
-
const querySnapshot = await getDocs(q);
|
|
1166
|
-
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
1167
|
-
}
|
|
1168
|
-
|
|
1169
|
-
/**
|
|
1170
|
-
* Dohvata sve zdravstvene radnike za određenu kliniku
|
|
1171
|
-
*/
|
|
1172
|
-
async getAllPractitionersByClinic(clinicId: string): Promise<Practitioner[]> {
|
|
1173
|
-
const q = query(
|
|
1174
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1175
|
-
where("clinics", "array-contains", clinicId),
|
|
1176
|
-
where("isActive", "==", true)
|
|
1177
|
-
);
|
|
1178
|
-
|
|
1179
|
-
const querySnapshot = await getDocs(q);
|
|
1180
|
-
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
1181
|
-
}
|
|
1182
|
-
|
|
1183
|
-
/**
|
|
1184
|
-
* Dohvata sve draft zdravstvene radnike za određenu kliniku sa statusom DRAFT
|
|
1185
|
-
*/
|
|
1186
|
-
async getDraftPractitionersByClinic(
|
|
1187
|
-
clinicId: string
|
|
1188
|
-
): Promise<Practitioner[]> {
|
|
1189
|
-
const q = query(
|
|
1190
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1191
|
-
where("clinics", "array-contains", clinicId),
|
|
1192
|
-
where("status", "==", PractitionerStatus.DRAFT)
|
|
1193
|
-
);
|
|
1194
|
-
|
|
1195
|
-
const querySnapshot = await getDocs(q);
|
|
1196
|
-
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
/**
|
|
1200
|
-
* Updates a practitioner
|
|
1201
|
-
*/
|
|
1202
|
-
async updatePractitioner(
|
|
1203
|
-
practitionerId: string,
|
|
1204
|
-
data: UpdatePractitionerData
|
|
1205
|
-
): Promise<Practitioner> {
|
|
1206
|
-
try {
|
|
1207
|
-
// Validate update data
|
|
1208
|
-
const validData = data; // Using the passed data directly as it's already validated by the schema type
|
|
1209
|
-
|
|
1210
|
-
// Get current practitioner data
|
|
1211
|
-
const practitionerRef = doc(
|
|
1212
|
-
this.db,
|
|
1213
|
-
PRACTITIONERS_COLLECTION,
|
|
1214
|
-
practitionerId
|
|
1215
|
-
);
|
|
1216
|
-
const practitionerDoc = await getDoc(practitionerRef);
|
|
1217
|
-
|
|
1218
|
-
if (!practitionerDoc.exists()) {
|
|
1219
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1220
|
-
}
|
|
1221
|
-
|
|
1222
|
-
const currentPractitioner = practitionerDoc.data() as Practitioner;
|
|
1223
|
-
|
|
1224
|
-
// Process basicInfo if it's being updated to handle profile photo uploads
|
|
1225
|
-
let processedData: UpdatePractitionerData & { fullNameLower?: string } = {
|
|
1226
|
-
...validData,
|
|
1227
|
-
};
|
|
1228
|
-
if (validData.basicInfo) {
|
|
1229
|
-
processedData.basicInfo = await this.processBasicInfo(
|
|
1230
|
-
validData.basicInfo as PractitionerBasicInfo & {
|
|
1231
|
-
profileImageUrl?: MediaResource | null;
|
|
1232
|
-
},
|
|
1233
|
-
practitionerId
|
|
1234
|
-
);
|
|
1235
|
-
// Always update fullNameLower when basicInfo changes
|
|
1236
|
-
processedData.fullNameLower =
|
|
1237
|
-
`${processedData.basicInfo.firstName} ${processedData.basicInfo.lastName}`.toLowerCase();
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
// Prepare update data
|
|
1241
|
-
const updateData: any = {
|
|
1242
|
-
...processedData,
|
|
1243
|
-
updatedAt: serverTimestamp(),
|
|
1244
|
-
};
|
|
1245
|
-
|
|
1246
|
-
// Update practitioner
|
|
1247
|
-
await updateDoc(practitionerRef, updateData);
|
|
1248
|
-
|
|
1249
|
-
// Return updated practitioner
|
|
1250
|
-
const updatedPractitioner = await this.getPractitioner(practitionerId);
|
|
1251
|
-
if (!updatedPractitioner) {
|
|
1252
|
-
throw new Error(
|
|
1253
|
-
`Failed to retrieve updated practitioner ${practitionerId}`
|
|
1254
|
-
);
|
|
1255
|
-
}
|
|
1256
|
-
return updatedPractitioner;
|
|
1257
|
-
} catch (error) {
|
|
1258
|
-
if (error instanceof z.ZodError) {
|
|
1259
|
-
throw new Error(`Invalid practitioner update data: ${error.message}`);
|
|
1260
|
-
}
|
|
1261
|
-
console.error(`Error updating practitioner ${practitionerId}:`, error);
|
|
1262
|
-
throw error;
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
/**
|
|
1267
|
-
* Adds a clinic to a practitioner
|
|
1268
|
-
*/
|
|
1269
|
-
async addClinic(practitionerId: string, clinicId: string): Promise<void> {
|
|
1270
|
-
try {
|
|
1271
|
-
// Get practitioner
|
|
1272
|
-
const practitionerRef = doc(
|
|
1273
|
-
this.db,
|
|
1274
|
-
PRACTITIONERS_COLLECTION,
|
|
1275
|
-
practitionerId
|
|
1276
|
-
);
|
|
1277
|
-
const practitionerDoc = await getDoc(practitionerRef);
|
|
1278
|
-
|
|
1279
|
-
if (!practitionerDoc.exists()) {
|
|
1280
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
const practitioner = practitionerDoc.data() as Practitioner;
|
|
1284
|
-
|
|
1285
|
-
// Check if clinic already added
|
|
1286
|
-
if (practitioner.clinics?.includes(clinicId)) {
|
|
1287
|
-
console.log(
|
|
1288
|
-
`Clinic ${clinicId} already added to practitioner ${practitionerId}`
|
|
1289
|
-
);
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
// Add clinic to clinics array
|
|
1294
|
-
await updateDoc(practitionerRef, {
|
|
1295
|
-
clinics: arrayUnion(clinicId),
|
|
1296
|
-
updatedAt: serverTimestamp(),
|
|
1297
|
-
});
|
|
1298
|
-
} catch (error) {
|
|
1299
|
-
console.error(
|
|
1300
|
-
`Error adding clinic ${clinicId} to practitioner ${practitionerId}:`,
|
|
1301
|
-
error
|
|
1302
|
-
);
|
|
1303
|
-
throw error;
|
|
1304
|
-
}
|
|
1305
|
-
}
|
|
1306
|
-
|
|
1307
|
-
/**
|
|
1308
|
-
* Removes a clinic from a practitioner
|
|
1309
|
-
*/
|
|
1310
|
-
async removeClinic(practitionerId: string, clinicId: string): Promise<void> {
|
|
1311
|
-
try {
|
|
1312
|
-
// Get practitioner
|
|
1313
|
-
const practitionerRef = doc(
|
|
1314
|
-
this.db,
|
|
1315
|
-
PRACTITIONERS_COLLECTION,
|
|
1316
|
-
practitionerId
|
|
1317
|
-
);
|
|
1318
|
-
const practitionerDoc = await getDoc(practitionerRef);
|
|
1319
|
-
|
|
1320
|
-
if (!practitionerDoc.exists()) {
|
|
1321
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
// Remove clinic from clinics array
|
|
1325
|
-
await updateDoc(practitionerRef, {
|
|
1326
|
-
clinics: arrayRemove(clinicId),
|
|
1327
|
-
updatedAt: serverTimestamp(),
|
|
1328
|
-
});
|
|
1329
|
-
} catch (error) {
|
|
1330
|
-
console.error(
|
|
1331
|
-
`Error removing clinic ${clinicId} from practitioner ${practitionerId}:`,
|
|
1332
|
-
error
|
|
1333
|
-
);
|
|
1334
|
-
throw error;
|
|
1335
|
-
}
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
/**
|
|
1339
|
-
* Deaktivira profil zdravstvenog radnika
|
|
1340
|
-
*/
|
|
1341
|
-
async deactivatePractitioner(practitionerId: string): Promise<void> {
|
|
1342
|
-
await this.updatePractitioner(practitionerId, {
|
|
1343
|
-
isActive: false,
|
|
1344
|
-
});
|
|
1345
|
-
}
|
|
1346
|
-
|
|
1347
|
-
/**
|
|
1348
|
-
* Aktivira profil zdravstvenog radnika
|
|
1349
|
-
*/
|
|
1350
|
-
async activatePractitioner(practitionerId: string): Promise<void> {
|
|
1351
|
-
await this.updatePractitioner(practitionerId, {
|
|
1352
|
-
isActive: true,
|
|
1353
|
-
});
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
/**
|
|
1357
|
-
* Briše profil zdravstvenog radnika
|
|
1358
|
-
*/
|
|
1359
|
-
async deletePractitioner(practitionerId: string): Promise<void> {
|
|
1360
|
-
const practitioner = await this.getPractitioner(practitionerId);
|
|
1361
|
-
if (!practitioner) {
|
|
1362
|
-
throw new Error("Practitioner not found");
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
// TODO: Kada implementiramo subkolekcije, ovde ćemo dodati brisanje povezanih podataka
|
|
1366
|
-
|
|
1367
|
-
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId));
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
/**
|
|
1371
|
-
* Validates a registration token and claims the associated draft practitioner profile
|
|
1372
|
-
* @param tokenString The token provided by the practitioner
|
|
1373
|
-
* @param userId The ID of the user claiming the profile
|
|
1374
|
-
* @returns The claimed practitioner profile or null if token is invalid
|
|
1375
|
-
*/
|
|
1376
|
-
async validateTokenAndClaimProfile(
|
|
1377
|
-
tokenString: string,
|
|
1378
|
-
userId: string
|
|
1379
|
-
): Promise<Practitioner | null> {
|
|
1380
|
-
// Find the token
|
|
1381
|
-
console.log("[PRACTITIONER] Validating token for claiming profile", {
|
|
1382
|
-
tokenString,
|
|
1383
|
-
userId,
|
|
1384
|
-
});
|
|
1385
|
-
|
|
1386
|
-
const token = await this.validateToken(tokenString);
|
|
1387
|
-
|
|
1388
|
-
if (!token) {
|
|
1389
|
-
console.log(
|
|
1390
|
-
"[PRACTITIONER] Token validation failed - token not found or not valid",
|
|
1391
|
-
{
|
|
1392
|
-
tokenString,
|
|
1393
|
-
}
|
|
1394
|
-
);
|
|
1395
|
-
return null; // Token not found or not valid
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
console.log("[PRACTITIONER] Token successfully validated", {
|
|
1399
|
-
tokenId: token.id,
|
|
1400
|
-
practitionerId: token.practitionerId,
|
|
1401
|
-
});
|
|
1402
|
-
|
|
1403
|
-
// Get the practitioner profile
|
|
1404
|
-
const practitioner = await this.getPractitioner(token.practitionerId);
|
|
1405
|
-
if (!practitioner) {
|
|
1406
|
-
console.log("[PRACTITIONER] Practitioner not found", {
|
|
1407
|
-
practitionerId: token.practitionerId,
|
|
1408
|
-
});
|
|
1409
|
-
return null; // Practitioner not found
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
// Ensure practitioner is in DRAFT status
|
|
1413
|
-
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
1414
|
-
console.log("[PRACTITIONER] Practitioner status is not DRAFT", {
|
|
1415
|
-
practitionerId: practitioner.id,
|
|
1416
|
-
status: practitioner.status,
|
|
1417
|
-
});
|
|
1418
|
-
throw new Error("This practitioner profile has already been claimed");
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
// Check if user already has a practitioner profile
|
|
1422
|
-
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
1423
|
-
if (existingPractitioner) {
|
|
1424
|
-
throw new Error("User already has a practitioner profile");
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
// Claim the profile by linking it to the user
|
|
1428
|
-
const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
|
|
1429
|
-
userRef: userId,
|
|
1430
|
-
status: PractitionerStatus.ACTIVE,
|
|
1431
|
-
});
|
|
1432
|
-
|
|
1433
|
-
// Mark the token as used
|
|
1434
|
-
await this.markTokenAsUsed(token.id, token.practitionerId, userId);
|
|
1435
|
-
|
|
1436
|
-
console.log("[PRACTITIONER] Profile claimed successfully", {
|
|
1437
|
-
practitionerId: updatedPractitioner.id,
|
|
1438
|
-
userId,
|
|
1439
|
-
});
|
|
1440
|
-
|
|
1441
|
-
return updatedPractitioner;
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
/**
|
|
1445
|
-
* Retrieves all practitioners with optional pagination and draft inclusion
|
|
1446
|
-
*
|
|
1447
|
-
* @param options - Search options
|
|
1448
|
-
* @param options.pagination - Optional limit for number of results per page
|
|
1449
|
-
* @param options.lastDoc - Optional last document for pagination
|
|
1450
|
-
* @param options.includeDraftPractitioners - Whether to include draft practitioners
|
|
1451
|
-
* @returns Array of practitioners and the last document for pagination
|
|
1452
|
-
*/
|
|
1453
|
-
async getAllPractitioners(options?: {
|
|
1454
|
-
pagination?: number;
|
|
1455
|
-
lastDoc?: any;
|
|
1456
|
-
includeDraftPractitioners?: boolean;
|
|
1457
|
-
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
1458
|
-
try {
|
|
1459
|
-
const constraints = [];
|
|
1460
|
-
|
|
1461
|
-
// Filter by status if not including drafts
|
|
1462
|
-
if (!options?.includeDraftPractitioners) {
|
|
1463
|
-
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
// Add ordering for consistent pagination
|
|
1467
|
-
constraints.push(orderBy("basicInfo.lastName", "asc"));
|
|
1468
|
-
constraints.push(orderBy("basicInfo.firstName", "asc"));
|
|
1469
|
-
|
|
1470
|
-
// Add pagination if specified
|
|
1471
|
-
if (options?.pagination && options.pagination > 0) {
|
|
1472
|
-
if (options.lastDoc) {
|
|
1473
|
-
constraints.push(startAfter(options.lastDoc));
|
|
1474
|
-
}
|
|
1475
|
-
constraints.push(limit(options.pagination));
|
|
1476
|
-
}
|
|
1477
|
-
|
|
1478
|
-
const q = query(
|
|
1479
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1480
|
-
...constraints
|
|
1481
|
-
);
|
|
1482
|
-
|
|
1483
|
-
const querySnapshot = await getDocs(q);
|
|
1484
|
-
|
|
1485
|
-
const practitioners = querySnapshot.docs.map(
|
|
1486
|
-
(doc) => doc.data() as Practitioner
|
|
1487
|
-
);
|
|
1488
|
-
|
|
1489
|
-
// Get last document for pagination
|
|
1490
|
-
const lastDoc =
|
|
1491
|
-
querySnapshot.docs.length > 0
|
|
1492
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1493
|
-
: null;
|
|
1494
|
-
|
|
1495
|
-
return {
|
|
1496
|
-
practitioners,
|
|
1497
|
-
lastDoc,
|
|
1498
|
-
};
|
|
1499
|
-
} catch (error) {
|
|
1500
|
-
console.error(
|
|
1501
|
-
"[PRACTITIONER_SERVICE] Error getting all practitioners:",
|
|
1502
|
-
error
|
|
1503
|
-
);
|
|
1504
|
-
throw error;
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
|
|
1508
|
-
/**
|
|
1509
|
-
* Searches and filters practitioners based on multiple criteria
|
|
1510
|
-
*
|
|
1511
|
-
* @param filters - Various filters to apply
|
|
1512
|
-
* @param filters.nameSearch - Optional search text for first/last name
|
|
1513
|
-
* @param filters.certifications - Optional array of certifications to filter by
|
|
1514
|
-
* @param filters.specialties - Optional array of specialties to filter by
|
|
1515
|
-
* @param filters.procedureFamily - Optional procedure family practitioners provide
|
|
1516
|
-
* @param filters.procedureCategory - Optional procedure category practitioners provide
|
|
1517
|
-
* @param filters.procedureSubcategory - Optional procedure subcategory practitioners provide
|
|
1518
|
-
* @param filters.procedureTechnology - Optional procedure technology practitioners provide
|
|
1519
|
-
* @param filters.location - Optional location for distance-based search
|
|
1520
|
-
* @param filters.radiusInKm - Optional radius in kilometers (required if location is provided)
|
|
1521
|
-
* @param filters.minRating - Optional minimum rating (0-5)
|
|
1522
|
-
* @param filters.maxRating - Optional maximum rating (0-5)
|
|
1523
|
-
* @param filters.pagination - Optional number of results per page
|
|
1524
|
-
* @param filters.lastDoc - Optional last document for pagination
|
|
1525
|
-
* @param filters.includeDraftPractitioners - Whether to include draft practitioners
|
|
1526
|
-
* @returns Filtered practitioners and the last document for pagination
|
|
1527
|
-
*/
|
|
1528
|
-
async getPractitionersByFilters(filters: {
|
|
1529
|
-
nameSearch?: string;
|
|
1530
|
-
certifications?: string[];
|
|
1531
|
-
specialties?: CertificationSpecialty[];
|
|
1532
|
-
procedureFamily?: string;
|
|
1533
|
-
procedureCategory?: string;
|
|
1534
|
-
procedureSubcategory?: string;
|
|
1535
|
-
procedureTechnology?: string;
|
|
1536
|
-
location?: { latitude: number; longitude: number };
|
|
1537
|
-
radiusInKm?: number;
|
|
1538
|
-
minRating?: number;
|
|
1539
|
-
maxRating?: number;
|
|
1540
|
-
pagination?: number;
|
|
1541
|
-
lastDoc?: any;
|
|
1542
|
-
includeDraftPractitioners?: boolean;
|
|
1543
|
-
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
1544
|
-
try {
|
|
1545
|
-
console.log(
|
|
1546
|
-
"[PRACTITIONER_SERVICE] Starting practitioner filtering with fallback strategies"
|
|
1547
|
-
);
|
|
1548
|
-
|
|
1549
|
-
// Geo query debug i validacija
|
|
1550
|
-
if (filters.location && filters.radiusInKm) {
|
|
1551
|
-
console.log("[PRACTITIONER_SERVICE] Executing geo query:", {
|
|
1552
|
-
location: filters.location,
|
|
1553
|
-
radius: filters.radiusInKm,
|
|
1554
|
-
serviceName: "PractitionerService",
|
|
1555
|
-
});
|
|
1556
|
-
|
|
1557
|
-
// Validacija location podataka
|
|
1558
|
-
if (!filters.location.latitude || !filters.location.longitude) {
|
|
1559
|
-
console.warn(
|
|
1560
|
-
"[PRACTITIONER_SERVICE] Invalid location data:",
|
|
1561
|
-
filters.location
|
|
1562
|
-
);
|
|
1563
|
-
filters.location = undefined;
|
|
1564
|
-
filters.radiusInKm = undefined;
|
|
1565
|
-
}
|
|
1566
|
-
}
|
|
1567
|
-
|
|
1568
|
-
// Strategy 1: Try fullNameLower search if nameSearch exists
|
|
1569
|
-
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1570
|
-
try {
|
|
1571
|
-
console.log(
|
|
1572
|
-
"[PRACTITIONER_SERVICE] Strategy 1: Trying fullNameLower search"
|
|
1573
|
-
);
|
|
1574
|
-
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1575
|
-
const constraints: any[] = [];
|
|
1576
|
-
|
|
1577
|
-
if (!filters.includeDraftPractitioners) {
|
|
1578
|
-
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1579
|
-
}
|
|
1580
|
-
constraints.push(where("isActive", "==", true));
|
|
1581
|
-
constraints.push(where("fullNameLower", ">=", searchTerm));
|
|
1582
|
-
constraints.push(where("fullNameLower", "<=", searchTerm + "\uf8ff"));
|
|
1583
|
-
constraints.push(orderBy("fullNameLower"));
|
|
1584
|
-
|
|
1585
|
-
if (filters.location && filters.radiusInKm) {
|
|
1586
|
-
// Fetch more results when geo filtering will reduce count
|
|
1587
|
-
if (filters.lastDoc) {
|
|
1588
|
-
if (typeof filters.lastDoc.data === "function") {
|
|
1589
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1590
|
-
} else if (Array.isArray(filters.lastDoc)) {
|
|
1591
|
-
constraints.push(startAfter(...filters.lastDoc));
|
|
1592
|
-
} else {
|
|
1593
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1594
|
-
}
|
|
1595
|
-
}
|
|
1596
|
-
constraints.push(limit((filters.pagination || 10) * 2));
|
|
1597
|
-
} else {
|
|
1598
|
-
if (filters.lastDoc) {
|
|
1599
|
-
if (typeof filters.lastDoc.data === "function") {
|
|
1600
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1601
|
-
} else if (Array.isArray(filters.lastDoc)) {
|
|
1602
|
-
constraints.push(startAfter(...filters.lastDoc));
|
|
1603
|
-
} else {
|
|
1604
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
constraints.push(limit(filters.pagination || 10));
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
const q = query(
|
|
1611
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1612
|
-
...constraints
|
|
1613
|
-
);
|
|
1614
|
-
const querySnapshot = await getDocs(q);
|
|
1615
|
-
let practitioners = querySnapshot.docs.map(
|
|
1616
|
-
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1617
|
-
);
|
|
1618
|
-
const lastDoc =
|
|
1619
|
-
querySnapshot.docs.length > 0
|
|
1620
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1621
|
-
: null;
|
|
1622
|
-
|
|
1623
|
-
// Apply geo filter if location is provided (in-memory, same as Strategy 2)
|
|
1624
|
-
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1625
|
-
const location = filters.location;
|
|
1626
|
-
const radiusInKm = filters.radiusInKm;
|
|
1627
|
-
practitioners = practitioners.filter((practitioner) => {
|
|
1628
|
-
const clinics = practitioner.clinicsInfo || [];
|
|
1629
|
-
return clinics.some((clinic) => {
|
|
1630
|
-
const distanceInKm = distanceBetween(
|
|
1631
|
-
[location.latitude, location.longitude],
|
|
1632
|
-
[clinic.location.latitude, clinic.location.longitude]
|
|
1633
|
-
); // Already returns km
|
|
1634
|
-
return distanceInKm <= radiusInKm;
|
|
1635
|
-
});
|
|
1636
|
-
});
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
console.log(
|
|
1640
|
-
`[PRACTITIONER_SERVICE] Strategy 1 success: ${practitioners.length} practitioners`
|
|
1641
|
-
);
|
|
1642
|
-
|
|
1643
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1644
|
-
if (practitioners.length < (filters.pagination || 10)) {
|
|
1645
|
-
return { practitioners, lastDoc: null };
|
|
1646
|
-
}
|
|
1647
|
-
return { practitioners, lastDoc };
|
|
1648
|
-
} catch (error) {
|
|
1649
|
-
console.log("[PRACTITIONER_SERVICE] Strategy 1 failed:", error);
|
|
1650
|
-
}
|
|
1651
|
-
}
|
|
1652
|
-
|
|
1653
|
-
// Strategy 2: Basic query with createdAt ordering (no name search)
|
|
1654
|
-
try {
|
|
1655
|
-
console.log(
|
|
1656
|
-
"[PRACTITIONER_SERVICE] Strategy 2: Basic query with createdAt ordering"
|
|
1657
|
-
);
|
|
1658
|
-
const constraints: any[] = [];
|
|
1659
|
-
|
|
1660
|
-
if (!filters.includeDraftPractitioners) {
|
|
1661
|
-
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1662
|
-
}
|
|
1663
|
-
constraints.push(where("isActive", "==", true));
|
|
1664
|
-
|
|
1665
|
-
// Add other filters that work well with Firestore
|
|
1666
|
-
if (filters.certifications && filters.certifications.length > 0) {
|
|
1667
|
-
const certificationsToMatch =
|
|
1668
|
-
filters.certifications as CertificationSpecialty[];
|
|
1669
|
-
constraints.push(
|
|
1670
|
-
where(
|
|
1671
|
-
"certification.specialties",
|
|
1672
|
-
"array-contains-any",
|
|
1673
|
-
certificationsToMatch
|
|
1674
|
-
)
|
|
1675
|
-
);
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
if (filters.minRating !== undefined) {
|
|
1679
|
-
constraints.push(
|
|
1680
|
-
where("reviewInfo.averageRating", ">=", filters.minRating)
|
|
1681
|
-
);
|
|
1682
|
-
}
|
|
1683
|
-
if (filters.maxRating !== undefined) {
|
|
1684
|
-
constraints.push(
|
|
1685
|
-
where("reviewInfo.averageRating", "<=", filters.maxRating)
|
|
1686
|
-
);
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
constraints.push(orderBy("createdAt", "desc"));
|
|
1690
|
-
|
|
1691
|
-
// Pagination sa createdAt - poboljšano za geo queries
|
|
1692
|
-
if (filters.location && filters.radiusInKm) {
|
|
1693
|
-
// Ne koristiti lastDoc za geo queries, već preuzmi više rezultata
|
|
1694
|
-
constraints.push(limit((filters.pagination || 10) * 2)); // Dvostruko više za geo filter
|
|
1695
|
-
} else {
|
|
1696
|
-
if (filters.lastDoc) {
|
|
1697
|
-
if (typeof filters.lastDoc.data === "function") {
|
|
1698
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1699
|
-
} else if (Array.isArray(filters.lastDoc)) {
|
|
1700
|
-
constraints.push(startAfter(...filters.lastDoc));
|
|
1701
|
-
} else {
|
|
1702
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
constraints.push(limit(filters.pagination || 10));
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
const q = query(
|
|
1709
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1710
|
-
...constraints
|
|
1711
|
-
);
|
|
1712
|
-
const querySnapshot = await getDocs(q);
|
|
1713
|
-
let practitioners = querySnapshot.docs.map(
|
|
1714
|
-
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1715
|
-
);
|
|
1716
|
-
|
|
1717
|
-
// Apply geo filter if needed (this is the only in-memory filter we keep)
|
|
1718
|
-
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1719
|
-
const location = filters.location;
|
|
1720
|
-
const radiusInKm = filters.radiusInKm;
|
|
1721
|
-
practitioners = practitioners.filter((practitioner) => {
|
|
1722
|
-
const clinics = practitioner.clinicsInfo || [];
|
|
1723
|
-
return clinics.some((clinic) => {
|
|
1724
|
-
const distanceInKm = distanceBetween(
|
|
1725
|
-
[location.latitude, location.longitude],
|
|
1726
|
-
[clinic.location.latitude, clinic.location.longitude]
|
|
1727
|
-
); // Already returns km
|
|
1728
|
-
return distanceInKm <= radiusInKm;
|
|
1729
|
-
});
|
|
1730
|
-
});
|
|
1731
|
-
|
|
1732
|
-
// Ograniči na pagination broj nakon geo filtera
|
|
1733
|
-
practitioners = practitioners.slice(0, filters.pagination || 10);
|
|
1734
|
-
}
|
|
1735
|
-
|
|
1736
|
-
// Apply all remaining client-side filters using centralized function
|
|
1737
|
-
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1738
|
-
|
|
1739
|
-
const lastDoc =
|
|
1740
|
-
querySnapshot.docs.length > 0
|
|
1741
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1742
|
-
: null;
|
|
1743
|
-
console.log(
|
|
1744
|
-
`[PRACTITIONER_SERVICE] Strategy 2 success: ${practitioners.length} practitioners`
|
|
1745
|
-
);
|
|
1746
|
-
|
|
1747
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1748
|
-
if (practitioners.length < (filters.pagination || 10)) {
|
|
1749
|
-
return { practitioners, lastDoc: null };
|
|
1750
|
-
}
|
|
1751
|
-
return { practitioners, lastDoc };
|
|
1752
|
-
} catch (error) {
|
|
1753
|
-
console.log("[PRACTITIONER_SERVICE] Strategy 2 failed:", error);
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
// Strategy 3: Minimal query fallback
|
|
1757
|
-
try {
|
|
1758
|
-
console.log(
|
|
1759
|
-
"[PRACTITIONER_SERVICE] Strategy 3: Minimal query fallback"
|
|
1760
|
-
);
|
|
1761
|
-
const constraints: any[] = [
|
|
1762
|
-
where("isActive", "==", true),
|
|
1763
|
-
orderBy("createdAt", "desc"),
|
|
1764
|
-
limit(filters.pagination || 10),
|
|
1765
|
-
];
|
|
1766
|
-
|
|
1767
|
-
const q = query(
|
|
1768
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1769
|
-
...constraints
|
|
1770
|
-
);
|
|
1771
|
-
const querySnapshot = await getDocs(q);
|
|
1772
|
-
let practitioners = querySnapshot.docs.map(
|
|
1773
|
-
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1774
|
-
);
|
|
1775
|
-
|
|
1776
|
-
// Apply all client-side filters using centralized function
|
|
1777
|
-
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1778
|
-
|
|
1779
|
-
const lastDoc =
|
|
1780
|
-
querySnapshot.docs.length > 0
|
|
1781
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1782
|
-
: null;
|
|
1783
|
-
console.log(
|
|
1784
|
-
`[PRACTITIONER_SERVICE] Strategy 3 success: ${practitioners.length} practitioners`
|
|
1785
|
-
);
|
|
1786
|
-
|
|
1787
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1788
|
-
if (practitioners.length < (filters.pagination || 10)) {
|
|
1789
|
-
return { practitioners, lastDoc: null };
|
|
1790
|
-
}
|
|
1791
|
-
return { practitioners, lastDoc };
|
|
1792
|
-
} catch (error) {
|
|
1793
|
-
console.log("[PRACTITIONER_SERVICE] Strategy 3 failed:", error);
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
// Strategy 4: Client-side filtering fallback (kao u procedure/clinic services)
|
|
1797
|
-
try {
|
|
1798
|
-
console.log(
|
|
1799
|
-
"[PRACTITIONER_SERVICE] Strategy 4: Client-side filtering fallback"
|
|
1800
|
-
);
|
|
1801
|
-
|
|
1802
|
-
const constraints: any[] = [
|
|
1803
|
-
where("isActive", "==", true),
|
|
1804
|
-
where("status", "==", PractitionerStatus.ACTIVE),
|
|
1805
|
-
orderBy("createdAt", "desc"),
|
|
1806
|
-
limit(filters.pagination || 10),
|
|
1807
|
-
];
|
|
1808
|
-
|
|
1809
|
-
const q = query(
|
|
1810
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1811
|
-
...constraints
|
|
1812
|
-
);
|
|
1813
|
-
const querySnapshot = await getDocs(q);
|
|
1814
|
-
let practitioners = querySnapshot.docs.map(
|
|
1815
|
-
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1816
|
-
);
|
|
1817
|
-
|
|
1818
|
-
// Apply all client-side filters using centralized function
|
|
1819
|
-
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1820
|
-
|
|
1821
|
-
const lastDoc =
|
|
1822
|
-
querySnapshot.docs.length > 0
|
|
1823
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1824
|
-
: null;
|
|
1825
|
-
console.log(
|
|
1826
|
-
`[PRACTITIONER_SERVICE] Strategy 4 success: ${practitioners.length} practitioners`
|
|
1827
|
-
);
|
|
1828
|
-
|
|
1829
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1830
|
-
if (practitioners.length < (filters.pagination || 10)) {
|
|
1831
|
-
return { practitioners, lastDoc: null };
|
|
1832
|
-
}
|
|
1833
|
-
return { practitioners, lastDoc };
|
|
1834
|
-
} catch (error) {
|
|
1835
|
-
console.log("[PRACTITIONER_SERVICE] Strategy 4 failed:", error);
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
// All strategies failed
|
|
1839
|
-
console.log(
|
|
1840
|
-
"[PRACTITIONER_SERVICE] All strategies failed, returning empty result"
|
|
1841
|
-
);
|
|
1842
|
-
return { practitioners: [], lastDoc: null };
|
|
1843
|
-
} catch (error) {
|
|
1844
|
-
console.error(
|
|
1845
|
-
"[PRACTITIONER_SERVICE] Error filtering practitioners:",
|
|
1846
|
-
error
|
|
1847
|
-
);
|
|
1848
|
-
return { practitioners: [], lastDoc: null };
|
|
1849
|
-
}
|
|
1850
|
-
}
|
|
1851
|
-
|
|
1852
|
-
/**
|
|
1853
|
-
* Applies in-memory filters to practitioners array
|
|
1854
|
-
* Used when Firestore queries fail or for complex filtering
|
|
1855
|
-
*/
|
|
1856
|
-
private applyInMemoryFilters(
|
|
1857
|
-
practitioners: Practitioner[],
|
|
1858
|
-
filters: any
|
|
1859
|
-
): Practitioner[] {
|
|
1860
|
-
let filteredPractitioners = [...practitioners]; // Create copy to avoid mutating original
|
|
1861
|
-
|
|
1862
|
-
// Name search filter
|
|
1863
|
-
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1864
|
-
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1865
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1866
|
-
const firstName = (
|
|
1867
|
-
practitioner.basicInfo?.firstName || ""
|
|
1868
|
-
).toLowerCase();
|
|
1869
|
-
const lastName = (practitioner.basicInfo?.lastName || "").toLowerCase();
|
|
1870
|
-
const fullName = `${firstName} ${lastName}`.trim();
|
|
1871
|
-
const fullNameLower = practitioner.fullNameLower || "";
|
|
1872
|
-
|
|
1873
|
-
return (
|
|
1874
|
-
firstName.includes(searchTerm) ||
|
|
1875
|
-
lastName.includes(searchTerm) ||
|
|
1876
|
-
fullName.includes(searchTerm) ||
|
|
1877
|
-
fullNameLower.includes(searchTerm)
|
|
1878
|
-
);
|
|
1879
|
-
});
|
|
1880
|
-
console.log(
|
|
1881
|
-
`[PRACTITIONER_SERVICE] Applied name filter, results: ${filteredPractitioners.length}`
|
|
1882
|
-
);
|
|
1883
|
-
}
|
|
1884
|
-
|
|
1885
|
-
// Certifications filtering
|
|
1886
|
-
if (filters.certifications && filters.certifications.length > 0) {
|
|
1887
|
-
const certificationsToMatch = filters.certifications;
|
|
1888
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1889
|
-
const practitionerCerts = practitioner.certification?.specialties || [];
|
|
1890
|
-
return certificationsToMatch.some((cert: any) =>
|
|
1891
|
-
practitionerCerts.includes(cert as CertificationSpecialty)
|
|
1892
|
-
);
|
|
1893
|
-
});
|
|
1894
|
-
console.log(
|
|
1895
|
-
`[PRACTITIONER_SERVICE] Applied certifications filter, results: ${filteredPractitioners.length}`
|
|
1896
|
-
);
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
// Specialties filtering
|
|
1900
|
-
if (filters.specialties && filters.specialties.length > 0) {
|
|
1901
|
-
const specialtiesToMatch = filters.specialties;
|
|
1902
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1903
|
-
const practitionerSpecs = practitioner.certification?.specialties || [];
|
|
1904
|
-
return specialtiesToMatch.some((spec: any) =>
|
|
1905
|
-
practitionerSpecs.includes(spec)
|
|
1906
|
-
);
|
|
1907
|
-
});
|
|
1908
|
-
console.log(
|
|
1909
|
-
`[PRACTITIONER_SERVICE] Applied specialties filter, results: ${filteredPractitioners.length}`
|
|
1910
|
-
);
|
|
1911
|
-
}
|
|
1912
|
-
|
|
1913
|
-
// Rating filtering
|
|
1914
|
-
if (filters.minRating !== undefined || filters.maxRating !== undefined) {
|
|
1915
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1916
|
-
const rating = practitioner.reviewInfo?.averageRating || 0;
|
|
1917
|
-
if (filters.minRating !== undefined && rating < filters.minRating)
|
|
1918
|
-
return false;
|
|
1919
|
-
if (filters.maxRating !== undefined && rating > filters.maxRating)
|
|
1920
|
-
return false;
|
|
1921
|
-
return true;
|
|
1922
|
-
});
|
|
1923
|
-
console.log(
|
|
1924
|
-
`[PRACTITIONER_SERVICE] Applied rating filter, results: ${filteredPractitioners.length}`
|
|
1925
|
-
);
|
|
1926
|
-
}
|
|
1927
|
-
|
|
1928
|
-
// Procedure family filtering
|
|
1929
|
-
if (filters.procedureFamily) {
|
|
1930
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1931
|
-
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1932
|
-
return proceduresInfo.some(
|
|
1933
|
-
(proc) => proc.family === filters.procedureFamily
|
|
1934
|
-
);
|
|
1935
|
-
});
|
|
1936
|
-
console.log(
|
|
1937
|
-
`[PRACTITIONER_SERVICE] Applied procedure family filter, results: ${filteredPractitioners.length}`
|
|
1938
|
-
);
|
|
1939
|
-
}
|
|
1940
|
-
|
|
1941
|
-
// Procedure category filtering
|
|
1942
|
-
if (filters.procedureCategory) {
|
|
1943
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1944
|
-
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1945
|
-
return proceduresInfo.some(
|
|
1946
|
-
(proc) => proc.categoryName === filters.procedureCategory
|
|
1947
|
-
);
|
|
1948
|
-
});
|
|
1949
|
-
console.log(
|
|
1950
|
-
`[PRACTITIONER_SERVICE] Applied procedure category filter, results: ${filteredPractitioners.length}`
|
|
1951
|
-
);
|
|
1952
|
-
}
|
|
1953
|
-
|
|
1954
|
-
// Procedure subcategory filtering
|
|
1955
|
-
if (filters.procedureSubcategory) {
|
|
1956
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1957
|
-
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1958
|
-
return proceduresInfo.some(
|
|
1959
|
-
(proc) => proc.subcategoryName === filters.procedureSubcategory
|
|
1960
|
-
);
|
|
1961
|
-
});
|
|
1962
|
-
console.log(
|
|
1963
|
-
`[PRACTITIONER_SERVICE] Applied procedure subcategory filter, results: ${filteredPractitioners.length}`
|
|
1964
|
-
);
|
|
1965
|
-
}
|
|
1966
|
-
|
|
1967
|
-
// Procedure technology filtering
|
|
1968
|
-
if (filters.procedureTechnology) {
|
|
1969
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1970
|
-
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1971
|
-
return proceduresInfo.some(
|
|
1972
|
-
(proc) => proc.technologyName === filters.procedureTechnology
|
|
1973
|
-
);
|
|
1974
|
-
});
|
|
1975
|
-
console.log(
|
|
1976
|
-
`[PRACTITIONER_SERVICE] Applied procedure technology filter, results: ${filteredPractitioners.length}`
|
|
1977
|
-
);
|
|
1978
|
-
}
|
|
1979
|
-
|
|
1980
|
-
// Geo-radius filter
|
|
1981
|
-
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1982
|
-
const location = filters.location;
|
|
1983
|
-
const radiusInKm = filters.radiusInKm;
|
|
1984
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1985
|
-
const clinics = practitioner.clinicsInfo || [];
|
|
1986
|
-
return clinics.some((clinic) => {
|
|
1987
|
-
const distanceInKm = distanceBetween(
|
|
1988
|
-
[location.latitude, location.longitude],
|
|
1989
|
-
[clinic.location.latitude, clinic.location.longitude]
|
|
1990
|
-
); // Already returns km
|
|
1991
|
-
return distanceInKm <= radiusInKm;
|
|
1992
|
-
});
|
|
1993
|
-
});
|
|
1994
|
-
console.log(
|
|
1995
|
-
`[PRACTITIONER_SERVICE] Applied geo filter, results: ${filteredPractitioners.length}`
|
|
1996
|
-
);
|
|
1997
|
-
}
|
|
1998
|
-
|
|
1999
|
-
return filteredPractitioners;
|
|
2000
|
-
}
|
|
2001
|
-
|
|
2002
|
-
/**
|
|
2003
|
-
* Enables free consultation for a practitioner in a specific clinic
|
|
2004
|
-
* Creates a free consultation procedure with hardcoded parameters
|
|
2005
|
-
* @param practitionerId - ID of the practitioner
|
|
2006
|
-
* @param clinicId - ID of the clinic
|
|
2007
|
-
* @returns The created consultation procedure
|
|
2008
|
-
*/
|
|
2009
|
-
async EnableFreeConsultation(
|
|
2010
|
-
practitionerId: string,
|
|
2011
|
-
clinicId: string
|
|
2012
|
-
): Promise<void> {
|
|
2013
|
-
try {
|
|
2014
|
-
console.log(
|
|
2015
|
-
`[EnableFreeConsultation] Starting for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2016
|
-
);
|
|
2017
|
-
|
|
2018
|
-
// First, ensure the free consultation infrastructure exists
|
|
2019
|
-
await this.ensureFreeConsultationInfrastructure();
|
|
2020
|
-
|
|
2021
|
-
// Validate that practitioner exists and is active
|
|
2022
|
-
const practitioner = await this.getPractitioner(practitionerId);
|
|
2023
|
-
if (!practitioner) {
|
|
2024
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
// No need to check for is practitioner active
|
|
2028
|
-
// if (!practitioner.isActive) {
|
|
2029
|
-
// throw new Error(`Practitioner ${practitionerId} is not active`);
|
|
2030
|
-
// }
|
|
2031
|
-
|
|
2032
|
-
// Validate that clinic exists
|
|
2033
|
-
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
2034
|
-
if (!clinic) {
|
|
2035
|
-
throw new Error(`Clinic ${clinicId} not found`);
|
|
2036
|
-
}
|
|
2037
|
-
|
|
2038
|
-
// Check if practitioner is associated with this clinic
|
|
2039
|
-
if (!practitioner.clinics.includes(clinicId)) {
|
|
2040
|
-
throw new Error(
|
|
2041
|
-
`Practitioner ${practitionerId} is not associated with clinic ${clinicId}`
|
|
2042
|
-
);
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
// CRITICAL: Double-check for existing procedures to prevent race conditions
|
|
2046
|
-
// Fetch procedures again right before creation/update
|
|
2047
|
-
// IMPORTANT: Pass false for excludeDraftPractitioners to work with draft practitioners
|
|
2048
|
-
const [activeProcedures, inactiveProcedures] = await Promise.all([
|
|
2049
|
-
this.getProcedureService().getProceduresByPractitioner(
|
|
2050
|
-
practitionerId,
|
|
2051
|
-
undefined, // clinicBranchId
|
|
2052
|
-
false // excludeDraftPractitioners - allow draft practitioners
|
|
2053
|
-
),
|
|
2054
|
-
this.getProcedureService().getInactiveProceduresByPractitioner(
|
|
2055
|
-
practitionerId
|
|
2056
|
-
),
|
|
2057
|
-
]);
|
|
2058
|
-
|
|
2059
|
-
// Combine active and inactive procedures
|
|
2060
|
-
const allProcedures = [...activeProcedures, ...inactiveProcedures];
|
|
2061
|
-
|
|
2062
|
-
// Check if free consultation already exists (active or inactive)
|
|
2063
|
-
const existingConsultations = allProcedures.filter(
|
|
2064
|
-
(procedure) =>
|
|
2065
|
-
procedure.technology.id === "free-consultation-tech" &&
|
|
2066
|
-
procedure.clinicBranchId === clinicId
|
|
2067
|
-
);
|
|
2068
|
-
|
|
2069
|
-
console.log(
|
|
2070
|
-
`[EnableFreeConsultation] Found ${existingConsultations.length} existing free consultation(s)`
|
|
2071
|
-
);
|
|
2072
|
-
|
|
2073
|
-
// If multiple consultations exist, log a warning and clean up duplicates
|
|
2074
|
-
if (existingConsultations.length > 1) {
|
|
2075
|
-
console.warn(
|
|
2076
|
-
`[EnableFreeConsultation] WARNING: Found ${existingConsultations.length} duplicate free consultations for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2077
|
-
);
|
|
2078
|
-
// Keep the first one, deactivate the rest
|
|
2079
|
-
for (let i = 1; i < existingConsultations.length; i++) {
|
|
2080
|
-
console.log(
|
|
2081
|
-
`[EnableFreeConsultation] Deactivating duplicate consultation ${existingConsultations[i].id}`
|
|
2082
|
-
);
|
|
2083
|
-
await this.getProcedureService().deactivateProcedure(
|
|
2084
|
-
existingConsultations[i].id
|
|
2085
|
-
);
|
|
2086
|
-
}
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
const existingConsultation = existingConsultations[0];
|
|
2090
|
-
|
|
2091
|
-
if (existingConsultation) {
|
|
2092
|
-
if (existingConsultation.isActive) {
|
|
2093
|
-
console.log(
|
|
2094
|
-
`[EnableFreeConsultation] Free consultation already active for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2095
|
-
);
|
|
2096
|
-
return;
|
|
2097
|
-
} else {
|
|
2098
|
-
// Reactivate the existing disabled consultation
|
|
2099
|
-
console.log(
|
|
2100
|
-
`[EnableFreeConsultation] Reactivating existing consultation ${existingConsultation.id}`
|
|
2101
|
-
);
|
|
2102
|
-
await this.getProcedureService().updateProcedure(
|
|
2103
|
-
existingConsultation.id,
|
|
2104
|
-
{ isActive: true }
|
|
2105
|
-
);
|
|
2106
|
-
console.log(
|
|
2107
|
-
`[EnableFreeConsultation] Reactivated existing free consultation for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2108
|
-
);
|
|
2109
|
-
return;
|
|
2110
|
-
}
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
// Final check before creating - race condition guard
|
|
2114
|
-
// Fetch one more time to ensure no procedure was created in parallel
|
|
2115
|
-
console.log(
|
|
2116
|
-
`[EnableFreeConsultation] Final race condition check before creating new procedure`
|
|
2117
|
-
);
|
|
2118
|
-
const finalCheckProcedures =
|
|
2119
|
-
await this.getProcedureService().getProceduresByPractitioner(
|
|
2120
|
-
practitionerId,
|
|
2121
|
-
undefined, // clinicBranchId
|
|
2122
|
-
false // excludeDraftPractitioners - allow draft practitioners
|
|
2123
|
-
);
|
|
2124
|
-
const raceConditionCheck = finalCheckProcedures.find(
|
|
2125
|
-
(procedure) =>
|
|
2126
|
-
procedure.technology.id === "free-consultation-tech" &&
|
|
2127
|
-
procedure.clinicBranchId === clinicId
|
|
2128
|
-
);
|
|
2129
|
-
|
|
2130
|
-
if (raceConditionCheck) {
|
|
2131
|
-
console.log(
|
|
2132
|
-
`[EnableFreeConsultation] Race condition detected! Procedure was created by another request. Using existing procedure ${raceConditionCheck.id}`
|
|
2133
|
-
);
|
|
2134
|
-
if (!raceConditionCheck.isActive) {
|
|
2135
|
-
await this.getProcedureService().updateProcedure(
|
|
2136
|
-
raceConditionCheck.id,
|
|
2137
|
-
{ isActive: true }
|
|
2138
|
-
);
|
|
2139
|
-
}
|
|
2140
|
-
return;
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
// Create procedure data for free consultation (without productId or productsMetadata)
|
|
2144
|
-
const consultationData: Omit<CreateProcedureData, "productId"> = {
|
|
2145
|
-
name: "Free Consultation",
|
|
2146
|
-
nameLower: "free consultation",
|
|
2147
|
-
description:
|
|
2148
|
-
"Free initial consultation to discuss treatment options and assess patient needs.",
|
|
2149
|
-
family: ProcedureFamily.AESTHETICS,
|
|
2150
|
-
categoryId: "consultation",
|
|
2151
|
-
subcategoryId: "free-consultation",
|
|
2152
|
-
technologyId: "free-consultation-tech",
|
|
2153
|
-
price: 0,
|
|
2154
|
-
currency: Currency.EUR,
|
|
2155
|
-
pricingMeasure: PricingMeasure.PER_SESSION,
|
|
2156
|
-
// productsMetadata omitted - no products needed for consultations
|
|
2157
|
-
duration: 30, // 30 minutes consultation
|
|
2158
|
-
practitionerId: practitionerId,
|
|
2159
|
-
clinicBranchId: clinicId,
|
|
2160
|
-
photos: [], // No photos for consultation
|
|
2161
|
-
};
|
|
2162
|
-
|
|
2163
|
-
// Create the consultation procedure using the special method
|
|
2164
|
-
console.log(
|
|
2165
|
-
`[EnableFreeConsultation] Creating new free consultation procedure`
|
|
2166
|
-
);
|
|
2167
|
-
await this.getProcedureService().createConsultationProcedure(
|
|
2168
|
-
consultationData
|
|
2169
|
-
);
|
|
2170
|
-
|
|
2171
|
-
console.log(
|
|
2172
|
-
`[EnableFreeConsultation] Successfully created free consultation for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2173
|
-
);
|
|
2174
|
-
} catch (error) {
|
|
2175
|
-
console.error(
|
|
2176
|
-
`[EnableFreeConsultation] Error enabling free consultation for practitioner ${practitionerId} in clinic ${clinicId}:`,
|
|
2177
|
-
error
|
|
2178
|
-
);
|
|
2179
|
-
throw error;
|
|
2180
|
-
}
|
|
2181
|
-
}
|
|
2182
|
-
|
|
2183
|
-
/**
|
|
2184
|
-
* Ensures that the free consultation infrastructure exists by calling the Cloud Function
|
|
2185
|
-
* @returns Promise<boolean> - True if infrastructure exists or was created successfully
|
|
2186
|
-
*/
|
|
2187
|
-
async ensureFreeConsultationInfrastructure(): Promise<boolean> {
|
|
2188
|
-
try {
|
|
2189
|
-
console.log(
|
|
2190
|
-
"[PRACTITIONER_SERVICE] Ensuring free consultation infrastructure via HTTP"
|
|
2191
|
-
);
|
|
2192
|
-
|
|
2193
|
-
// Check if user is authenticated
|
|
2194
|
-
const currentUser = this.auth.currentUser;
|
|
2195
|
-
if (!currentUser) {
|
|
2196
|
-
throw new Error(
|
|
2197
|
-
"User must be authenticated to ensure free consultation infrastructure"
|
|
2198
|
-
);
|
|
2199
|
-
}
|
|
2200
|
-
|
|
2201
|
-
// Construct the function URL
|
|
2202
|
-
const
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
if
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
*
|
|
2271
|
-
*
|
|
2272
|
-
* @param
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
if
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
//
|
|
2300
|
-
//
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
procedure.
|
|
2316
|
-
procedure.
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
}
|
|
1
|
+
import {
|
|
2
|
+
collection,
|
|
3
|
+
doc,
|
|
4
|
+
getDoc,
|
|
5
|
+
getDocs,
|
|
6
|
+
query,
|
|
7
|
+
where,
|
|
8
|
+
updateDoc,
|
|
9
|
+
setDoc,
|
|
10
|
+
deleteDoc,
|
|
11
|
+
Timestamp,
|
|
12
|
+
serverTimestamp,
|
|
13
|
+
limit,
|
|
14
|
+
startAfter,
|
|
15
|
+
orderBy,
|
|
16
|
+
writeBatch,
|
|
17
|
+
arrayUnion,
|
|
18
|
+
arrayRemove,
|
|
19
|
+
FieldValue,
|
|
20
|
+
} from "firebase/firestore";
|
|
21
|
+
import { BaseService } from "../base.service";
|
|
22
|
+
import { enforceProviderLimit } from "../tier-enforcement";
|
|
23
|
+
import {
|
|
24
|
+
Practitioner,
|
|
25
|
+
CreatePractitionerData,
|
|
26
|
+
UpdatePractitionerData,
|
|
27
|
+
PRACTITIONERS_COLLECTION,
|
|
28
|
+
REGISTER_TOKENS_COLLECTION,
|
|
29
|
+
PractitionerStatus,
|
|
30
|
+
CreateDraftPractitionerData,
|
|
31
|
+
PractitionerToken,
|
|
32
|
+
CreatePractitionerTokenData,
|
|
33
|
+
PractitionerTokenStatus,
|
|
34
|
+
PractitionerBasicInfo,
|
|
35
|
+
} from "../../types/practitioner";
|
|
36
|
+
import { ProcedureSummaryInfo } from "../../types/procedure";
|
|
37
|
+
import { ClinicService } from "../clinic/clinic.service";
|
|
38
|
+
import {
|
|
39
|
+
MediaService,
|
|
40
|
+
MediaAccessLevel,
|
|
41
|
+
MediaResource,
|
|
42
|
+
} from "../media/media.service";
|
|
43
|
+
import {
|
|
44
|
+
practitionerSchema,
|
|
45
|
+
createPractitionerSchema,
|
|
46
|
+
createDraftPractitionerSchema,
|
|
47
|
+
practitionerTokenSchema,
|
|
48
|
+
createPractitionerTokenSchema,
|
|
49
|
+
} from "../../validations/practitioner.schema";
|
|
50
|
+
import { z } from "zod";
|
|
51
|
+
import { Auth } from "firebase/auth";
|
|
52
|
+
import { Firestore } from "firebase/firestore";
|
|
53
|
+
import { FirebaseApp } from "firebase/app";
|
|
54
|
+
import { PractitionerReviewInfo } from "../../types/reviews";
|
|
55
|
+
import { distanceBetween } from "geofire-common";
|
|
56
|
+
import { CertificationSpecialty } from "../../backoffice/types/static/certification.types";
|
|
57
|
+
import { Clinic, DoctorInfo, CLINICS_COLLECTION } from "../../types/clinic";
|
|
58
|
+
import { ClinicInfo } from "../../types/profile";
|
|
59
|
+
import { ProcedureService } from "../procedure/procedure.service";
|
|
60
|
+
import { ProcedureFamily } from "../../backoffice/types/static/procedure-family.types";
|
|
61
|
+
import {
|
|
62
|
+
Currency,
|
|
63
|
+
PricingMeasure,
|
|
64
|
+
} from "../../backoffice/types/static/pricing.types";
|
|
65
|
+
import { CreateProcedureData } from "../../types/procedure";
|
|
66
|
+
|
|
67
|
+
export class PractitionerService extends BaseService {
|
|
68
|
+
private clinicService?: ClinicService;
|
|
69
|
+
private mediaService: MediaService;
|
|
70
|
+
private procedureService?: ProcedureService;
|
|
71
|
+
|
|
72
|
+
constructor(
|
|
73
|
+
db: Firestore,
|
|
74
|
+
auth: Auth,
|
|
75
|
+
app: FirebaseApp,
|
|
76
|
+
clinicService?: ClinicService,
|
|
77
|
+
procedureService?: ProcedureService
|
|
78
|
+
) {
|
|
79
|
+
super(db, auth, app);
|
|
80
|
+
this.clinicService = clinicService;
|
|
81
|
+
this.procedureService = procedureService;
|
|
82
|
+
this.mediaService = new MediaService(db, auth, app);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public getClinicService(): ClinicService {
|
|
86
|
+
if (!this.clinicService) {
|
|
87
|
+
throw new Error("Clinic service not initialized!");
|
|
88
|
+
}
|
|
89
|
+
return this.clinicService;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private getProcedureService(): ProcedureService {
|
|
93
|
+
if (!this.procedureService) {
|
|
94
|
+
throw new Error("Procedure service not initialized!");
|
|
95
|
+
}
|
|
96
|
+
return this.procedureService;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setClinicService(clinicService: ClinicService): void {
|
|
100
|
+
this.clinicService = clinicService;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
setProcedureService(procedureService: ProcedureService): void {
|
|
104
|
+
this.procedureService = procedureService;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handles profile photo upload for practitioners
|
|
109
|
+
* @param profilePhoto - MediaResource (File, Blob, or URL string)
|
|
110
|
+
* @param practitionerId - ID of the practitioner
|
|
111
|
+
* @returns URL string of the uploaded or existing photo
|
|
112
|
+
*/
|
|
113
|
+
private async handleProfilePhotoUpload(
|
|
114
|
+
profilePhoto: MediaResource | undefined | null,
|
|
115
|
+
practitionerId: string
|
|
116
|
+
): Promise<string | undefined> {
|
|
117
|
+
if (!profilePhoto) {
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// If it's already a URL string, return it as is
|
|
122
|
+
if (typeof profilePhoto === "string") {
|
|
123
|
+
return profilePhoto;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// If it's a File or Blob, upload it
|
|
127
|
+
if (profilePhoto instanceof File || profilePhoto instanceof Blob) {
|
|
128
|
+
console.log(
|
|
129
|
+
`[PractitionerService] Uploading profile photo for practitioner ${practitionerId}`
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const mediaMetadata = await this.mediaService.uploadMedia(
|
|
133
|
+
profilePhoto,
|
|
134
|
+
practitionerId, // Using practitionerId as ownerId
|
|
135
|
+
MediaAccessLevel.PUBLIC, // Profile photos should be public
|
|
136
|
+
"practitioner_profile_photos",
|
|
137
|
+
profilePhoto instanceof File
|
|
138
|
+
? profilePhoto.name
|
|
139
|
+
: `profile_photo_${practitionerId}`
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return mediaMetadata.url;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Processes BasicPractitionerInfo to handle profile photo uploads
|
|
150
|
+
* @param basicInfo - The basic info containing potential MediaResource profile photo
|
|
151
|
+
* @param practitionerId - ID of the practitioner
|
|
152
|
+
* @returns Processed basic info with URL string for profileImageUrl
|
|
153
|
+
*/
|
|
154
|
+
private async processBasicInfo(
|
|
155
|
+
basicInfo: PractitionerBasicInfo & {
|
|
156
|
+
profileImageUrl?: MediaResource | null;
|
|
157
|
+
},
|
|
158
|
+
practitionerId: string
|
|
159
|
+
): Promise<PractitionerBasicInfo> {
|
|
160
|
+
const processedBasicInfo = { ...basicInfo };
|
|
161
|
+
|
|
162
|
+
// Normalize email to lowercase to ensure consistent matching
|
|
163
|
+
if (processedBasicInfo.email) {
|
|
164
|
+
processedBasicInfo.email = processedBasicInfo.email.toLowerCase().trim();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Handle profile photo upload if needed
|
|
168
|
+
if (basicInfo.profileImageUrl) {
|
|
169
|
+
const uploadedUrl = await this.handleProfilePhotoUpload(
|
|
170
|
+
basicInfo.profileImageUrl,
|
|
171
|
+
practitionerId
|
|
172
|
+
);
|
|
173
|
+
processedBasicInfo.profileImageUrl = uploadedUrl;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return processedBasicInfo;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Creates a new practitioner
|
|
181
|
+
*/
|
|
182
|
+
async createPractitioner(
|
|
183
|
+
data: CreatePractitionerData
|
|
184
|
+
): Promise<Practitioner> {
|
|
185
|
+
try {
|
|
186
|
+
const validData = createPractitionerSchema.parse(data);
|
|
187
|
+
|
|
188
|
+
// Enforce tier limit: resolve clinicGroupId from the first assigned clinic
|
|
189
|
+
if (validData.clinics && validData.clinics.length > 0) {
|
|
190
|
+
const clinicRef = doc(this.db, CLINICS_COLLECTION, validData.clinics[0]);
|
|
191
|
+
const clinicSnap = await getDoc(clinicRef);
|
|
192
|
+
if (clinicSnap.exists()) {
|
|
193
|
+
const clinicGroupId = (clinicSnap.data() as any).clinicGroupId;
|
|
194
|
+
if (clinicGroupId) {
|
|
195
|
+
await enforceProviderLimit(this.db, clinicGroupId, validData.clinics[0]);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const practitionerId = this.generateId();
|
|
201
|
+
|
|
202
|
+
// Default review info
|
|
203
|
+
const reviewInfo: PractitionerReviewInfo = {
|
|
204
|
+
totalReviews: 0,
|
|
205
|
+
averageRating: 0,
|
|
206
|
+
knowledgeAndExpertise: 0,
|
|
207
|
+
communicationSkills: 0,
|
|
208
|
+
bedSideManner: 0,
|
|
209
|
+
thoroughness: 0,
|
|
210
|
+
trustworthiness: 0,
|
|
211
|
+
recommendationPercentage: 0,
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
// Create practitioner object
|
|
215
|
+
const fullNameLower =
|
|
216
|
+
`${validData.basicInfo.firstName} ${validData.basicInfo.lastName}`.toLowerCase();
|
|
217
|
+
const practitioner: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
218
|
+
createdAt: FieldValue;
|
|
219
|
+
updatedAt: FieldValue;
|
|
220
|
+
} = {
|
|
221
|
+
id: practitionerId,
|
|
222
|
+
userRef: validData.userRef,
|
|
223
|
+
basicInfo: await this.processBasicInfo(
|
|
224
|
+
validData.basicInfo,
|
|
225
|
+
practitionerId
|
|
226
|
+
),
|
|
227
|
+
fullNameLower: fullNameLower, // Ensure this is present
|
|
228
|
+
certification: validData.certification,
|
|
229
|
+
clinics: validData.clinics || [],
|
|
230
|
+
clinicWorkingHours: validData.clinicWorkingHours || [],
|
|
231
|
+
clinicsInfo: [],
|
|
232
|
+
procedures: [],
|
|
233
|
+
proceduresInfo: [],
|
|
234
|
+
reviewInfo,
|
|
235
|
+
isActive: validData.isActive !== undefined ? validData.isActive : true,
|
|
236
|
+
isVerified:
|
|
237
|
+
validData.isVerified !== undefined ? validData.isVerified : false,
|
|
238
|
+
status: validData.status || PractitionerStatus.ACTIVE,
|
|
239
|
+
createdAt: serverTimestamp(),
|
|
240
|
+
updatedAt: serverTimestamp(),
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Validate the entire object
|
|
244
|
+
practitionerSchema.parse({
|
|
245
|
+
...practitioner,
|
|
246
|
+
createdAt: Timestamp.now(),
|
|
247
|
+
updatedAt: Timestamp.now(),
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Create practitioner document
|
|
251
|
+
const practitionerRef = doc(
|
|
252
|
+
this.db,
|
|
253
|
+
PRACTITIONERS_COLLECTION,
|
|
254
|
+
practitionerId
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
await setDoc(practitionerRef, practitioner);
|
|
258
|
+
|
|
259
|
+
// Return the created practitioner
|
|
260
|
+
const createdPractitioner = await this.getPractitioner(practitionerId);
|
|
261
|
+
if (!createdPractitioner) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`Failed to retrieve created practitioner ${practitionerId}`
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
return createdPractitioner;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
if (error instanceof z.ZodError) {
|
|
269
|
+
throw new Error(`Invalid practitioner data: ${error.message}`);
|
|
270
|
+
}
|
|
271
|
+
console.error("Error creating practitioner:", error);
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Kreira novi draft profil zdravstvenog radnika bez povezanog korisnika
|
|
278
|
+
* Koristi se od strane administratora klinike za kreiranje profila i kasnije pozivanje
|
|
279
|
+
* @param data Podaci za kreiranje draft profila
|
|
280
|
+
* @param createdBy ID administratora koji kreira profil
|
|
281
|
+
* @param clinicId ID klinike za koju se kreira profil
|
|
282
|
+
* @returns Objekt koji sadrži kreirani draft profil i token za registraciju
|
|
283
|
+
*/
|
|
284
|
+
async createDraftPractitioner(
|
|
285
|
+
data: CreateDraftPractitionerData,
|
|
286
|
+
createdBy: string,
|
|
287
|
+
clinicId: string
|
|
288
|
+
): Promise<{ practitioner: Practitioner; token: PractitionerToken }> {
|
|
289
|
+
try {
|
|
290
|
+
// Validacija ulaznih podataka
|
|
291
|
+
const validatedData = createDraftPractitionerSchema.parse(data);
|
|
292
|
+
|
|
293
|
+
// Provera da li klinika postoji
|
|
294
|
+
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
295
|
+
if (!clinic) {
|
|
296
|
+
throw new Error(`Clinic ${clinicId} not found`);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Enforce tier limit before creating draft practitioner (per-branch)
|
|
300
|
+
if (clinic.clinicGroupId) {
|
|
301
|
+
await enforceProviderLimit(this.db, clinic.clinicGroupId, clinicId);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Make sure the primary clinic (clinicId) is always included
|
|
305
|
+
// Merge the clinics array with the primary clinicId, avoiding duplicates
|
|
306
|
+
const clinicsToAdd = new Set<string>([clinicId]);
|
|
307
|
+
|
|
308
|
+
// Add additional clinics if provided
|
|
309
|
+
if (data.clinics && data.clinics.length > 0) {
|
|
310
|
+
for (const cId of data.clinics) {
|
|
311
|
+
// Verify each additional clinic exists
|
|
312
|
+
if (cId !== clinicId) {
|
|
313
|
+
// Skip checking the primary clinic again
|
|
314
|
+
const otherClinic = await this.getClinicService().getClinic(cId);
|
|
315
|
+
if (!otherClinic) {
|
|
316
|
+
throw new Error(`Clinic ${cId} not found`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
clinicsToAdd.add(cId);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Convert Set to Array
|
|
324
|
+
const clinics = Array.from(clinicsToAdd);
|
|
325
|
+
|
|
326
|
+
// Initialize default review info for new practitioners
|
|
327
|
+
const defaultReviewInfo: PractitionerReviewInfo = {
|
|
328
|
+
totalReviews: 0,
|
|
329
|
+
averageRating: 0,
|
|
330
|
+
knowledgeAndExpertise: 0,
|
|
331
|
+
communicationSkills: 0,
|
|
332
|
+
bedSideManner: 0,
|
|
333
|
+
thoroughness: 0,
|
|
334
|
+
trustworthiness: 0,
|
|
335
|
+
recommendationPercentage: 0,
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
// Generate ID for the new practitioner
|
|
339
|
+
const practitionerId = this.generateId();
|
|
340
|
+
|
|
341
|
+
// Create clinicsInfo from the merged clinics array
|
|
342
|
+
const clinicsInfo: ClinicInfo[] = [];
|
|
343
|
+
|
|
344
|
+
// Populate clinicsInfo for each clinic
|
|
345
|
+
for (const cId of clinics) {
|
|
346
|
+
const clinicData = await this.getClinicService().getClinic(cId);
|
|
347
|
+
if (clinicData) {
|
|
348
|
+
// Ensure we're creating a ClinicInfo object that matches the interface structure
|
|
349
|
+
clinicsInfo.push({
|
|
350
|
+
id: clinicData.id,
|
|
351
|
+
name: clinicData.name,
|
|
352
|
+
location: clinicData.location,
|
|
353
|
+
contactInfo: clinicData.contactInfo,
|
|
354
|
+
// Make sure we're using the right property for featuredPhoto
|
|
355
|
+
featuredPhoto:
|
|
356
|
+
clinicData.featuredPhotos && clinicData.featuredPhotos.length > 0
|
|
357
|
+
? typeof clinicData.featuredPhotos[0] === "string"
|
|
358
|
+
? clinicData.featuredPhotos[0]
|
|
359
|
+
: ""
|
|
360
|
+
: (typeof clinicData.coverPhoto === "string"
|
|
361
|
+
? clinicData.coverPhoto
|
|
362
|
+
: "") || "",
|
|
363
|
+
description: clinicData.description || null,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Use provided clinicsInfo if available, otherwise use the ones we just created
|
|
369
|
+
const finalClinicsInfo =
|
|
370
|
+
validatedData.clinicsInfo && validatedData.clinicsInfo.length > 0
|
|
371
|
+
? validatedData.clinicsInfo
|
|
372
|
+
: clinicsInfo;
|
|
373
|
+
|
|
374
|
+
const proceduresInfo: ProcedureSummaryInfo[] = [];
|
|
375
|
+
|
|
376
|
+
// Add fullNameLower for draft
|
|
377
|
+
const fullNameLowerDraft =
|
|
378
|
+
`${validatedData.basicInfo.firstName} ${validatedData.basicInfo.lastName}`.toLowerCase();
|
|
379
|
+
const practitionerData: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
380
|
+
createdAt: ReturnType<typeof serverTimestamp>;
|
|
381
|
+
updatedAt: ReturnType<typeof serverTimestamp>;
|
|
382
|
+
} = {
|
|
383
|
+
id: practitionerId,
|
|
384
|
+
userRef: "", // Prazno - biće popunjeno kada korisnik kreira nalog
|
|
385
|
+
basicInfo: await this.processBasicInfo(
|
|
386
|
+
validatedData.basicInfo,
|
|
387
|
+
practitionerId
|
|
388
|
+
),
|
|
389
|
+
fullNameLower: fullNameLowerDraft, // Ensure this is present
|
|
390
|
+
certification: validatedData.certification,
|
|
391
|
+
clinics: clinics,
|
|
392
|
+
clinicWorkingHours: validatedData.clinicWorkingHours || [],
|
|
393
|
+
clinicsInfo: finalClinicsInfo,
|
|
394
|
+
procedures: [],
|
|
395
|
+
proceduresInfo: proceduresInfo,
|
|
396
|
+
reviewInfo: defaultReviewInfo,
|
|
397
|
+
isActive:
|
|
398
|
+
validatedData.isActive !== undefined ? validatedData.isActive : false,
|
|
399
|
+
isVerified:
|
|
400
|
+
validatedData.isVerified !== undefined
|
|
401
|
+
? validatedData.isVerified
|
|
402
|
+
: false,
|
|
403
|
+
status: PractitionerStatus.DRAFT,
|
|
404
|
+
createdAt: serverTimestamp(),
|
|
405
|
+
updatedAt: serverTimestamp(),
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// Validacija kompletnog objekta
|
|
409
|
+
// Koristimo privremeni userRef za validaciju, biće prazan u bazi
|
|
410
|
+
practitionerSchema.parse({
|
|
411
|
+
...practitionerData,
|
|
412
|
+
userRef: "temp-for-validation",
|
|
413
|
+
createdAt: Timestamp.now(),
|
|
414
|
+
updatedAt: Timestamp.now(),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
// Čuvamo u Firestore
|
|
418
|
+
await setDoc(
|
|
419
|
+
doc(this.db, PRACTITIONERS_COLLECTION, practitionerData.id),
|
|
420
|
+
practitionerData
|
|
421
|
+
);
|
|
422
|
+
|
|
423
|
+
const savedPractitioner = await this.getPractitioner(practitionerData.id);
|
|
424
|
+
if (!savedPractitioner) {
|
|
425
|
+
throw new Error("Failed to create draft practitioner profile");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Automatski kreiramo token za registraciju
|
|
429
|
+
const tokenString = this.generateId().slice(0, 6).toUpperCase();
|
|
430
|
+
|
|
431
|
+
// Default expiration is 7 days from now
|
|
432
|
+
const expiration = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
433
|
+
|
|
434
|
+
const token: PractitionerToken = {
|
|
435
|
+
id: this.generateId(),
|
|
436
|
+
token: tokenString,
|
|
437
|
+
practitionerId: practitionerId,
|
|
438
|
+
email: practitionerData.basicInfo.email,
|
|
439
|
+
clinicId: clinicId,
|
|
440
|
+
status: PractitionerTokenStatus.ACTIVE,
|
|
441
|
+
createdBy: createdBy,
|
|
442
|
+
createdAt: Timestamp.now(),
|
|
443
|
+
expiresAt: Timestamp.fromDate(expiration),
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
// Validate token object
|
|
447
|
+
practitionerTokenSchema.parse(token);
|
|
448
|
+
|
|
449
|
+
// Store the token in the practitioner document's register_tokens subcollection
|
|
450
|
+
const tokenPath = `${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
|
|
451
|
+
await setDoc(doc(this.db, tokenPath), token);
|
|
452
|
+
|
|
453
|
+
// Ovde bi bilo slanje emaila sa tokenom, ali to ćemo implementirati kasnije
|
|
454
|
+
// TODO: Implement email sending with Cloud Functions
|
|
455
|
+
|
|
456
|
+
return { practitioner: savedPractitioner, token };
|
|
457
|
+
} catch (error) {
|
|
458
|
+
if (error instanceof z.ZodError) {
|
|
459
|
+
throw new Error("Invalid practitioner data: " + error.message);
|
|
460
|
+
}
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Creates a token for inviting practitioner to claim their profile
|
|
467
|
+
* @param data Data for creating token
|
|
468
|
+
* @param createdBy ID of the user creating the token
|
|
469
|
+
* @returns Created token
|
|
470
|
+
*/
|
|
471
|
+
async createPractitionerToken(
|
|
472
|
+
data: CreatePractitionerTokenData,
|
|
473
|
+
createdBy: string
|
|
474
|
+
): Promise<PractitionerToken> {
|
|
475
|
+
try {
|
|
476
|
+
// Validate data
|
|
477
|
+
const validatedData = createPractitionerTokenSchema.parse(data);
|
|
478
|
+
|
|
479
|
+
// Check if practitioner exists and is in DRAFT status
|
|
480
|
+
const practitioner = await this.getPractitioner(
|
|
481
|
+
validatedData.practitionerId
|
|
482
|
+
);
|
|
483
|
+
if (!practitioner) {
|
|
484
|
+
throw new Error("Practitioner not found");
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
488
|
+
throw new Error(
|
|
489
|
+
"Can only create tokens for practitioners in DRAFT status"
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Check if clinic exists and practitioner belongs to it
|
|
494
|
+
const clinic = await this.getClinicService().getClinic(
|
|
495
|
+
validatedData.clinicId
|
|
496
|
+
);
|
|
497
|
+
if (!clinic) {
|
|
498
|
+
throw new Error(`Clinic ${validatedData.clinicId} not found`);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (!practitioner.clinics.includes(validatedData.clinicId)) {
|
|
502
|
+
throw new Error("Practitioner is not associated with this clinic");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Security check: Verify that the clinic belongs to the clinic group of the user creating the token
|
|
506
|
+
// createdBy can be either clinicGroupId or clinicId
|
|
507
|
+
let expectedClinicGroupId: string | null = null;
|
|
508
|
+
|
|
509
|
+
// First, check if createdBy matches the clinic's clinicGroupId directly
|
|
510
|
+
if (clinic.clinicGroupId === createdBy) {
|
|
511
|
+
// createdBy is the clinicGroupId, which matches - this is valid
|
|
512
|
+
expectedClinicGroupId = createdBy;
|
|
513
|
+
} else {
|
|
514
|
+
// createdBy might be a clinicId, check if that clinic belongs to the same group
|
|
515
|
+
try {
|
|
516
|
+
const creatorClinic = await this.getClinicService().getClinic(createdBy);
|
|
517
|
+
if (creatorClinic && creatorClinic.clinicGroupId === clinic.clinicGroupId) {
|
|
518
|
+
// Both clinics belong to the same group - valid
|
|
519
|
+
expectedClinicGroupId = clinic.clinicGroupId;
|
|
520
|
+
} else {
|
|
521
|
+
throw new Error("Clinic does not belong to your clinic group");
|
|
522
|
+
}
|
|
523
|
+
} catch (error: any) {
|
|
524
|
+
// If createdBy is not a valid clinicId, or clinics don't match, reject
|
|
525
|
+
if (error.message === "Clinic does not belong to your clinic group") {
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
// If getClinic fails, createdBy might be a clinicGroupId that doesn't match
|
|
529
|
+
throw new Error("Clinic does not belong to your clinic group");
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Default expiration is 7 days from now if not specified
|
|
534
|
+
const expiration =
|
|
535
|
+
validatedData.expiresAt ||
|
|
536
|
+
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
537
|
+
|
|
538
|
+
// Generate a token (6 characters) using generateId from BaseService
|
|
539
|
+
const tokenString = this.generateId().slice(0, 6).toUpperCase();
|
|
540
|
+
|
|
541
|
+
const token: PractitionerToken = {
|
|
542
|
+
id: this.generateId(),
|
|
543
|
+
token: tokenString,
|
|
544
|
+
practitionerId: validatedData.practitionerId,
|
|
545
|
+
email: validatedData.email,
|
|
546
|
+
clinicId: validatedData.clinicId,
|
|
547
|
+
status: PractitionerTokenStatus.ACTIVE,
|
|
548
|
+
createdBy: createdBy,
|
|
549
|
+
createdAt: Timestamp.now(),
|
|
550
|
+
expiresAt: Timestamp.fromDate(expiration),
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
// Validate token object
|
|
554
|
+
practitionerTokenSchema.parse(token);
|
|
555
|
+
|
|
556
|
+
// Store the token in the practitioner document's register_tokens subcollection
|
|
557
|
+
const tokenPath = `${PRACTITIONERS_COLLECTION}/${validatedData.practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
|
|
558
|
+
await setDoc(doc(this.db, tokenPath), token);
|
|
559
|
+
|
|
560
|
+
return token;
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (error instanceof z.ZodError) {
|
|
563
|
+
throw new Error("Invalid token data: " + error.message);
|
|
564
|
+
}
|
|
565
|
+
throw error;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Gets active tokens for a practitioner
|
|
571
|
+
* @param practitionerId ID of the practitioner
|
|
572
|
+
* @param clinicId Optional clinic ID to filter tokens by. If provided, only returns tokens for this clinic.
|
|
573
|
+
* @returns Array of active tokens
|
|
574
|
+
*/
|
|
575
|
+
async getPractitionerActiveTokens(
|
|
576
|
+
practitionerId: string,
|
|
577
|
+
clinicId?: string
|
|
578
|
+
): Promise<PractitionerToken[]> {
|
|
579
|
+
const tokensRef = collection(
|
|
580
|
+
this.db,
|
|
581
|
+
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
const conditions = [
|
|
585
|
+
where("status", "==", PractitionerTokenStatus.ACTIVE),
|
|
586
|
+
where("expiresAt", ">", Timestamp.now())
|
|
587
|
+
];
|
|
588
|
+
|
|
589
|
+
// Filter by clinic if provided
|
|
590
|
+
if (clinicId) {
|
|
591
|
+
conditions.push(where("clinicId", "==", clinicId));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const q = query(tokensRef, ...conditions);
|
|
595
|
+
|
|
596
|
+
const querySnapshot = await getDocs(q);
|
|
597
|
+
return querySnapshot.docs.map((doc) => doc.data() as PractitionerToken);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* Gets a token by its string value and validates it
|
|
602
|
+
* @param tokenString The token string to find
|
|
603
|
+
* @returns The token if found and valid, null otherwise
|
|
604
|
+
*/
|
|
605
|
+
async validateToken(tokenString: string): Promise<PractitionerToken | null> {
|
|
606
|
+
// We need to search through all practitioners' register_tokens subcollections
|
|
607
|
+
const practitionersRef = collection(this.db, PRACTITIONERS_COLLECTION);
|
|
608
|
+
const practitionersSnapshot = await getDocs(practitionersRef);
|
|
609
|
+
|
|
610
|
+
for (const practitionerDoc of practitionersSnapshot.docs) {
|
|
611
|
+
const practitionerId = practitionerDoc.id;
|
|
612
|
+
const tokensRef = collection(
|
|
613
|
+
this.db,
|
|
614
|
+
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
console.log(
|
|
618
|
+
`[PRACTITIONER] Validating token for practitioner ${practitionerId}`,
|
|
619
|
+
{
|
|
620
|
+
tokenString,
|
|
621
|
+
timestamp: Timestamp.now().toDate(),
|
|
622
|
+
}
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
const q = query(
|
|
626
|
+
tokensRef,
|
|
627
|
+
where("token", "==", tokenString),
|
|
628
|
+
where("status", "==", PractitionerTokenStatus.ACTIVE),
|
|
629
|
+
where("expiresAt", ">", Timestamp.now())
|
|
630
|
+
);
|
|
631
|
+
|
|
632
|
+
try {
|
|
633
|
+
const tokenSnapshot = await getDocs(q);
|
|
634
|
+
console.log(
|
|
635
|
+
`[PRACTITIONER] Token query results for practitioner ${practitionerId}`,
|
|
636
|
+
{
|
|
637
|
+
found: !tokenSnapshot.empty,
|
|
638
|
+
count: tokenSnapshot.size,
|
|
639
|
+
}
|
|
640
|
+
);
|
|
641
|
+
|
|
642
|
+
if (!tokenSnapshot.empty) {
|
|
643
|
+
const tokenData = tokenSnapshot.docs[0].data() as PractitionerToken;
|
|
644
|
+
console.log(`[PRACTITIONER] Valid token found`, {
|
|
645
|
+
tokenId: tokenData.id,
|
|
646
|
+
expiresAt: tokenData.expiresAt.toDate(),
|
|
647
|
+
});
|
|
648
|
+
return tokenData;
|
|
649
|
+
}
|
|
650
|
+
} catch (error) {
|
|
651
|
+
console.error(
|
|
652
|
+
`[PRACTITIONER] Error validating token for practitioner ${practitionerId}:`,
|
|
653
|
+
error
|
|
654
|
+
);
|
|
655
|
+
// Re-throw the error to be handled by the caller
|
|
656
|
+
throw error;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Marks a token as used
|
|
665
|
+
* @param tokenId ID of the token
|
|
666
|
+
* @param practitionerId ID of the practitioner
|
|
667
|
+
* @param userId ID of the user using the token
|
|
668
|
+
*/
|
|
669
|
+
async markTokenAsUsed(
|
|
670
|
+
tokenId: string,
|
|
671
|
+
practitionerId: string,
|
|
672
|
+
userId: string
|
|
673
|
+
): Promise<void> {
|
|
674
|
+
const tokenRef = doc(
|
|
675
|
+
this.db,
|
|
676
|
+
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${tokenId}`
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
await updateDoc(tokenRef, {
|
|
680
|
+
status: PractitionerTokenStatus.USED,
|
|
681
|
+
usedBy: userId,
|
|
682
|
+
usedAt: Timestamp.now(),
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Revokes a token by setting its status to REVOKED
|
|
688
|
+
* @param tokenId ID of the token
|
|
689
|
+
* @param practitionerId ID of the practitioner
|
|
690
|
+
* @param clinicId ID of the clinic that owns the token. Used to verify ownership before revoking.
|
|
691
|
+
* @throws Error if token doesn't exist or doesn't belong to the specified clinic
|
|
692
|
+
*/
|
|
693
|
+
async revokeToken(
|
|
694
|
+
tokenId: string,
|
|
695
|
+
practitionerId: string,
|
|
696
|
+
clinicId: string
|
|
697
|
+
): Promise<void> {
|
|
698
|
+
const tokenRef = doc(
|
|
699
|
+
this.db,
|
|
700
|
+
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${tokenId}`
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
// First, verify the token exists and belongs to the clinic
|
|
704
|
+
const tokenDoc = await getDoc(tokenRef);
|
|
705
|
+
if (!tokenDoc.exists()) {
|
|
706
|
+
throw new Error("Token not found");
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const tokenData = tokenDoc.data() as PractitionerToken;
|
|
710
|
+
if (tokenData.clinicId !== clinicId) {
|
|
711
|
+
throw new Error("Token does not belong to the specified clinic");
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
// Only revoke if token is still active
|
|
715
|
+
if (tokenData.status !== PractitionerTokenStatus.ACTIVE) {
|
|
716
|
+
throw new Error("Token is not active and cannot be revoked");
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
await updateDoc(tokenRef, {
|
|
720
|
+
status: PractitionerTokenStatus.REVOKED,
|
|
721
|
+
updatedAt: serverTimestamp(),
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Dohvata zdravstvenog radnika po ID-u
|
|
727
|
+
*/
|
|
728
|
+
async getPractitioner(practitionerId: string): Promise<Practitioner | null> {
|
|
729
|
+
const practitionerDoc = await getDoc(
|
|
730
|
+
doc(this.db, PRACTITIONERS_COLLECTION, practitionerId)
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
if (!practitionerDoc.exists()) {
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
return practitionerDoc.data() as Practitioner;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Dohvata zdravstvenog radnika po User ID-u
|
|
742
|
+
*/
|
|
743
|
+
async getPractitionerByUserRef(
|
|
744
|
+
userRef: string
|
|
745
|
+
): Promise<Practitioner | null> {
|
|
746
|
+
const q = query(
|
|
747
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
748
|
+
where("userRef", "==", userRef)
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
const querySnapshot = await getDocs(q);
|
|
752
|
+
if (querySnapshot.empty) {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return querySnapshot.docs[0].data() as Practitioner;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Finds a draft practitioner profile by email address
|
|
761
|
+
* Used to detect if a draft profile exists when a doctor registers without a token
|
|
762
|
+
*
|
|
763
|
+
* @param email - Email address to search for
|
|
764
|
+
* @returns Draft practitioner profile if found, null otherwise
|
|
765
|
+
*
|
|
766
|
+
* @remarks
|
|
767
|
+
* Requires Firestore composite index on:
|
|
768
|
+
* - Collection: practitioners
|
|
769
|
+
* - Fields: basicInfo.email (Ascending), status (Ascending), userRef (Ascending)
|
|
770
|
+
*/
|
|
771
|
+
async findDraftPractitionerByEmail(
|
|
772
|
+
email: string
|
|
773
|
+
): Promise<Practitioner | null> {
|
|
774
|
+
try {
|
|
775
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
776
|
+
|
|
777
|
+
console.log("[PRACTITIONER] Searching for draft practitioner by email", {
|
|
778
|
+
email: normalizedEmail,
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
const q = query(
|
|
782
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
783
|
+
where("basicInfo.email", "==", normalizedEmail),
|
|
784
|
+
where("status", "==", PractitionerStatus.DRAFT),
|
|
785
|
+
where("userRef", "==", ""),
|
|
786
|
+
limit(1)
|
|
787
|
+
);
|
|
788
|
+
|
|
789
|
+
const querySnapshot = await getDocs(q);
|
|
790
|
+
|
|
791
|
+
if (querySnapshot.empty) {
|
|
792
|
+
console.log("[PRACTITIONER] No draft practitioner found for email", {
|
|
793
|
+
email: normalizedEmail,
|
|
794
|
+
});
|
|
795
|
+
return null;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const draftPractitioner = querySnapshot.docs[0].data() as Practitioner;
|
|
799
|
+
console.log("[PRACTITIONER] Draft practitioner found", {
|
|
800
|
+
email: normalizedEmail,
|
|
801
|
+
practitionerId: draftPractitioner.id,
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
return draftPractitioner;
|
|
805
|
+
} catch (error) {
|
|
806
|
+
console.error(
|
|
807
|
+
"[PRACTITIONER] Error finding draft practitioner by email:",
|
|
808
|
+
error
|
|
809
|
+
);
|
|
810
|
+
// If query fails (e.g., index not created), return null to allow registration
|
|
811
|
+
// This prevents blocking registration if index is missing
|
|
812
|
+
return null;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Finds all draft practitioner profiles by email address
|
|
818
|
+
* Used when a doctor signs in with Google to show all clinic invitations
|
|
819
|
+
*
|
|
820
|
+
* @param email - Email address to search for
|
|
821
|
+
* @returns Array of draft practitioner profiles with clinic information
|
|
822
|
+
*
|
|
823
|
+
* @remarks
|
|
824
|
+
* Requires Firestore composite index on:
|
|
825
|
+
* - Collection: practitioners
|
|
826
|
+
* - Fields: basicInfo.email (Ascending), status (Ascending), userRef (Ascending)
|
|
827
|
+
*/
|
|
828
|
+
async getDraftProfilesByEmail(
|
|
829
|
+
email: string
|
|
830
|
+
): Promise<Practitioner[]> {
|
|
831
|
+
try {
|
|
832
|
+
const normalizedEmail = email.toLowerCase().trim();
|
|
833
|
+
|
|
834
|
+
console.log("[PRACTITIONER] Searching for all draft practitioners by email", {
|
|
835
|
+
email: normalizedEmail,
|
|
836
|
+
originalEmail: email,
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
const q = query(
|
|
840
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
841
|
+
where("basicInfo.email", "==", normalizedEmail),
|
|
842
|
+
where("status", "==", PractitionerStatus.DRAFT),
|
|
843
|
+
where("userRef", "==", "")
|
|
844
|
+
);
|
|
845
|
+
|
|
846
|
+
const querySnapshot = await getDocs(q);
|
|
847
|
+
|
|
848
|
+
if (querySnapshot.empty) {
|
|
849
|
+
console.log("[PRACTITIONER] No draft practitioners found for email", {
|
|
850
|
+
email: normalizedEmail,
|
|
851
|
+
originalEmail: email,
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
// Debug: Try to find ANY practitioners with this email (regardless of status)
|
|
855
|
+
const debugQ = query(
|
|
856
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
857
|
+
where("basicInfo.email", "==", normalizedEmail),
|
|
858
|
+
limit(5)
|
|
859
|
+
);
|
|
860
|
+
const debugSnapshot = await getDocs(debugQ);
|
|
861
|
+
console.log("[PRACTITIONER] Debug: Found practitioners with this email (any status):", {
|
|
862
|
+
count: debugSnapshot.size,
|
|
863
|
+
practitioners: debugSnapshot.docs.map(doc => ({
|
|
864
|
+
id: doc.id,
|
|
865
|
+
email: doc.data().basicInfo?.email,
|
|
866
|
+
status: doc.data().status,
|
|
867
|
+
userRef: doc.data().userRef,
|
|
868
|
+
})),
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
return [];
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const draftPractitioners = querySnapshot.docs.map(
|
|
875
|
+
(doc) => doc.data() as Practitioner
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
console.log("[PRACTITIONER] Found draft practitioners", {
|
|
879
|
+
email: normalizedEmail,
|
|
880
|
+
count: draftPractitioners.length,
|
|
881
|
+
practitionerIds: draftPractitioners.map((p) => p.id),
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
return draftPractitioners;
|
|
885
|
+
} catch (error) {
|
|
886
|
+
console.error(
|
|
887
|
+
"[PRACTITIONER] Error finding draft practitioners by email:",
|
|
888
|
+
error
|
|
889
|
+
);
|
|
890
|
+
// If query fails (e.g., index not created), return empty array
|
|
891
|
+
return [];
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* Claims a draft practitioner profile and links it to a user account
|
|
897
|
+
* Used when a doctor selects which clinic(s) to join after Google Sign-In
|
|
898
|
+
*
|
|
899
|
+
* @param practitionerId - ID of the draft practitioner profile to claim
|
|
900
|
+
* @param userId - ID of the user account to link the profile to
|
|
901
|
+
* @returns The claimed practitioner profile
|
|
902
|
+
*/
|
|
903
|
+
async claimDraftProfileWithGoogle(
|
|
904
|
+
practitionerId: string,
|
|
905
|
+
userId: string
|
|
906
|
+
): Promise<Practitioner> {
|
|
907
|
+
try {
|
|
908
|
+
console.log("[PRACTITIONER] Claiming draft profile with Google", {
|
|
909
|
+
practitionerId,
|
|
910
|
+
userId,
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
// Get the draft practitioner profile
|
|
914
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
915
|
+
if (!practitioner) {
|
|
916
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Ensure practitioner is in DRAFT status
|
|
920
|
+
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
921
|
+
throw new Error("This practitioner profile has already been claimed");
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Check if user already has a practitioner profile
|
|
925
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
926
|
+
if (existingPractitioner) {
|
|
927
|
+
// User already has a profile - merge clinics from draft profile into existing profile
|
|
928
|
+
console.log("[PRACTITIONER] User already has profile, merging clinics");
|
|
929
|
+
|
|
930
|
+
// Merge clinics (avoid duplicates)
|
|
931
|
+
const mergedClinics = Array.from(new Set([
|
|
932
|
+
...existingPractitioner.clinics,
|
|
933
|
+
...practitioner.clinics,
|
|
934
|
+
]));
|
|
935
|
+
|
|
936
|
+
// Merge clinic working hours
|
|
937
|
+
const mergedWorkingHours = [...existingPractitioner.clinicWorkingHours];
|
|
938
|
+
for (const workingHours of practitioner.clinicWorkingHours) {
|
|
939
|
+
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
940
|
+
mergedWorkingHours.push(workingHours);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Merge clinics info (avoid duplicates)
|
|
945
|
+
const mergedClinicsInfo = [...existingPractitioner.clinicsInfo];
|
|
946
|
+
for (const clinicInfo of practitioner.clinicsInfo) {
|
|
947
|
+
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
948
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Update existing practitioner with merged data
|
|
953
|
+
const updatedPractitioner = await this.updatePractitioner(existingPractitioner.id, {
|
|
954
|
+
clinics: mergedClinics,
|
|
955
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
956
|
+
clinicsInfo: mergedClinicsInfo,
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
// Delete the draft profile since we've merged it
|
|
960
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId));
|
|
961
|
+
|
|
962
|
+
// Mark all active tokens for the draft practitioner as used
|
|
963
|
+
const activeTokens = await this.getPractitionerActiveTokens(practitionerId);
|
|
964
|
+
for (const token of activeTokens) {
|
|
965
|
+
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
return updatedPractitioner;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Claim the profile by linking it to the user
|
|
972
|
+
const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
|
|
973
|
+
userRef: userId,
|
|
974
|
+
status: PractitionerStatus.ACTIVE,
|
|
975
|
+
});
|
|
976
|
+
|
|
977
|
+
// Mark all active tokens for this practitioner as used
|
|
978
|
+
const activeTokens = await this.getPractitionerActiveTokens(practitionerId);
|
|
979
|
+
for (const token of activeTokens) {
|
|
980
|
+
await this.markTokenAsUsed(token.id, practitionerId, userId);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
console.log("[PRACTITIONER] Draft profile claimed successfully", {
|
|
984
|
+
practitionerId: updatedPractitioner.id,
|
|
985
|
+
userId,
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
return updatedPractitioner;
|
|
989
|
+
} catch (error) {
|
|
990
|
+
console.error(
|
|
991
|
+
"[PRACTITIONER] Error claiming draft profile with Google:",
|
|
992
|
+
error
|
|
993
|
+
);
|
|
994
|
+
throw error;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Claims multiple draft practitioner profiles and merges them into one profile
|
|
1000
|
+
* Used when a doctor selects multiple clinics to join after Google Sign-In
|
|
1001
|
+
*
|
|
1002
|
+
* @param practitionerIds - Array of draft practitioner profile IDs to claim
|
|
1003
|
+
* @param userId - ID of the user account to link the profiles to
|
|
1004
|
+
* @returns The claimed practitioner profile (first one becomes main, others merged)
|
|
1005
|
+
*/
|
|
1006
|
+
async claimMultipleDraftProfilesWithGoogle(
|
|
1007
|
+
practitionerIds: string[],
|
|
1008
|
+
userId: string
|
|
1009
|
+
): Promise<Practitioner> {
|
|
1010
|
+
try {
|
|
1011
|
+
if (practitionerIds.length === 0) {
|
|
1012
|
+
throw new Error("No practitioner IDs provided");
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
console.log("[PRACTITIONER] Claiming multiple draft profiles with Google", {
|
|
1016
|
+
practitionerIds,
|
|
1017
|
+
userId,
|
|
1018
|
+
count: practitionerIds.length,
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
// Get all draft profiles
|
|
1022
|
+
const draftProfiles = await Promise.all(
|
|
1023
|
+
practitionerIds.map(id => this.getPractitioner(id))
|
|
1024
|
+
);
|
|
1025
|
+
|
|
1026
|
+
// Filter out nulls and ensure all are drafts
|
|
1027
|
+
const validDrafts = draftProfiles.filter((p): p is Practitioner => {
|
|
1028
|
+
if (!p) return false;
|
|
1029
|
+
if (p.status !== PractitionerStatus.DRAFT) {
|
|
1030
|
+
throw new Error(`Practitioner ${p.id} has already been claimed`);
|
|
1031
|
+
}
|
|
1032
|
+
return true;
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
if (validDrafts.length === 0) {
|
|
1036
|
+
throw new Error("No valid draft profiles found");
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Check if user already has a practitioner profile
|
|
1040
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
1041
|
+
|
|
1042
|
+
if (existingPractitioner) {
|
|
1043
|
+
// Merge all draft profiles into existing profile
|
|
1044
|
+
let mergedClinics = new Set(existingPractitioner.clinics);
|
|
1045
|
+
let mergedWorkingHours = [...existingPractitioner.clinicWorkingHours];
|
|
1046
|
+
let mergedClinicsInfo = [...existingPractitioner.clinicsInfo];
|
|
1047
|
+
|
|
1048
|
+
for (const draft of validDrafts) {
|
|
1049
|
+
// Merge clinics
|
|
1050
|
+
draft.clinics.forEach(clinicId => mergedClinics.add(clinicId));
|
|
1051
|
+
|
|
1052
|
+
// Merge working hours
|
|
1053
|
+
for (const workingHours of draft.clinicWorkingHours) {
|
|
1054
|
+
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
1055
|
+
mergedWorkingHours.push(workingHours);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
// Merge clinics info
|
|
1060
|
+
for (const clinicInfo of draft.clinicsInfo) {
|
|
1061
|
+
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
1062
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Update existing practitioner
|
|
1068
|
+
const updatedPractitioner = await this.updatePractitioner(existingPractitioner.id, {
|
|
1069
|
+
clinics: Array.from(mergedClinics),
|
|
1070
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
1071
|
+
clinicsInfo: mergedClinicsInfo,
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
// Delete all draft profiles
|
|
1075
|
+
for (const draft of validDrafts) {
|
|
1076
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
1077
|
+
|
|
1078
|
+
// Mark all active tokens as used
|
|
1079
|
+
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
1080
|
+
for (const token of activeTokens) {
|
|
1081
|
+
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
return updatedPractitioner;
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
// Use first draft as the main profile, merge others into it
|
|
1089
|
+
const mainDraft = validDrafts[0];
|
|
1090
|
+
const otherDrafts = validDrafts.slice(1);
|
|
1091
|
+
|
|
1092
|
+
// Merge clinics from other drafts
|
|
1093
|
+
let mergedClinics = new Set(mainDraft.clinics);
|
|
1094
|
+
let mergedWorkingHours = [...mainDraft.clinicWorkingHours];
|
|
1095
|
+
let mergedClinicsInfo = [...mainDraft.clinicsInfo];
|
|
1096
|
+
|
|
1097
|
+
for (const draft of otherDrafts) {
|
|
1098
|
+
draft.clinics.forEach(clinicId => mergedClinics.add(clinicId));
|
|
1099
|
+
|
|
1100
|
+
for (const workingHours of draft.clinicWorkingHours) {
|
|
1101
|
+
if (!mergedWorkingHours.find(wh => wh.clinicId === workingHours.clinicId)) {
|
|
1102
|
+
mergedWorkingHours.push(workingHours);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
for (const clinicInfo of draft.clinicsInfo) {
|
|
1107
|
+
if (!mergedClinicsInfo.find(ci => ci.id === clinicInfo.id)) {
|
|
1108
|
+
mergedClinicsInfo.push(clinicInfo);
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// Claim the main profile
|
|
1114
|
+
const updatedPractitioner = await this.updatePractitioner(mainDraft.id, {
|
|
1115
|
+
userRef: userId,
|
|
1116
|
+
status: PractitionerStatus.ACTIVE,
|
|
1117
|
+
clinics: Array.from(mergedClinics),
|
|
1118
|
+
clinicWorkingHours: mergedWorkingHours,
|
|
1119
|
+
clinicsInfo: mergedClinicsInfo,
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
// Mark all active tokens for main profile as used
|
|
1123
|
+
const mainActiveTokens = await this.getPractitionerActiveTokens(mainDraft.id);
|
|
1124
|
+
for (const token of mainActiveTokens) {
|
|
1125
|
+
await this.markTokenAsUsed(token.id, mainDraft.id, userId);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
// Delete other draft profiles
|
|
1129
|
+
for (const draft of otherDrafts) {
|
|
1130
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, draft.id));
|
|
1131
|
+
|
|
1132
|
+
const activeTokens = await this.getPractitionerActiveTokens(draft.id);
|
|
1133
|
+
for (const token of activeTokens) {
|
|
1134
|
+
await this.markTokenAsUsed(token.id, draft.id, userId);
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
console.log("[PRACTITIONER] Multiple draft profiles claimed successfully", {
|
|
1139
|
+
practitionerId: updatedPractitioner.id,
|
|
1140
|
+
userId,
|
|
1141
|
+
mergedCount: validDrafts.length,
|
|
1142
|
+
});
|
|
1143
|
+
|
|
1144
|
+
return updatedPractitioner;
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
console.error(
|
|
1147
|
+
"[PRACTITIONER] Error claiming multiple draft profiles with Google:",
|
|
1148
|
+
error
|
|
1149
|
+
);
|
|
1150
|
+
throw error;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* Dohvata sve zdravstvene radnike za određenu kliniku sa statusom ACTIVE
|
|
1156
|
+
*/
|
|
1157
|
+
async getPractitionersByClinic(clinicId: string): Promise<Practitioner[]> {
|
|
1158
|
+
const q = query(
|
|
1159
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1160
|
+
where("clinics", "array-contains", clinicId),
|
|
1161
|
+
where("isActive", "==", true),
|
|
1162
|
+
where("status", "==", PractitionerStatus.ACTIVE)
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
const querySnapshot = await getDocs(q);
|
|
1166
|
+
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* Dohvata sve zdravstvene radnike za određenu kliniku
|
|
1171
|
+
*/
|
|
1172
|
+
async getAllPractitionersByClinic(clinicId: string): Promise<Practitioner[]> {
|
|
1173
|
+
const q = query(
|
|
1174
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1175
|
+
where("clinics", "array-contains", clinicId),
|
|
1176
|
+
where("isActive", "==", true)
|
|
1177
|
+
);
|
|
1178
|
+
|
|
1179
|
+
const querySnapshot = await getDocs(q);
|
|
1180
|
+
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Dohvata sve draft zdravstvene radnike za određenu kliniku sa statusom DRAFT
|
|
1185
|
+
*/
|
|
1186
|
+
async getDraftPractitionersByClinic(
|
|
1187
|
+
clinicId: string
|
|
1188
|
+
): Promise<Practitioner[]> {
|
|
1189
|
+
const q = query(
|
|
1190
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1191
|
+
where("clinics", "array-contains", clinicId),
|
|
1192
|
+
where("status", "==", PractitionerStatus.DRAFT)
|
|
1193
|
+
);
|
|
1194
|
+
|
|
1195
|
+
const querySnapshot = await getDocs(q);
|
|
1196
|
+
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/**
|
|
1200
|
+
* Updates a practitioner
|
|
1201
|
+
*/
|
|
1202
|
+
async updatePractitioner(
|
|
1203
|
+
practitionerId: string,
|
|
1204
|
+
data: UpdatePractitionerData
|
|
1205
|
+
): Promise<Practitioner> {
|
|
1206
|
+
try {
|
|
1207
|
+
// Validate update data
|
|
1208
|
+
const validData = data; // Using the passed data directly as it's already validated by the schema type
|
|
1209
|
+
|
|
1210
|
+
// Get current practitioner data
|
|
1211
|
+
const practitionerRef = doc(
|
|
1212
|
+
this.db,
|
|
1213
|
+
PRACTITIONERS_COLLECTION,
|
|
1214
|
+
practitionerId
|
|
1215
|
+
);
|
|
1216
|
+
const practitionerDoc = await getDoc(practitionerRef);
|
|
1217
|
+
|
|
1218
|
+
if (!practitionerDoc.exists()) {
|
|
1219
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
const currentPractitioner = practitionerDoc.data() as Practitioner;
|
|
1223
|
+
|
|
1224
|
+
// Process basicInfo if it's being updated to handle profile photo uploads
|
|
1225
|
+
let processedData: UpdatePractitionerData & { fullNameLower?: string } = {
|
|
1226
|
+
...validData,
|
|
1227
|
+
};
|
|
1228
|
+
if (validData.basicInfo) {
|
|
1229
|
+
processedData.basicInfo = await this.processBasicInfo(
|
|
1230
|
+
validData.basicInfo as PractitionerBasicInfo & {
|
|
1231
|
+
profileImageUrl?: MediaResource | null;
|
|
1232
|
+
},
|
|
1233
|
+
practitionerId
|
|
1234
|
+
);
|
|
1235
|
+
// Always update fullNameLower when basicInfo changes
|
|
1236
|
+
processedData.fullNameLower =
|
|
1237
|
+
`${processedData.basicInfo.firstName} ${processedData.basicInfo.lastName}`.toLowerCase();
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// Prepare update data
|
|
1241
|
+
const updateData: any = {
|
|
1242
|
+
...processedData,
|
|
1243
|
+
updatedAt: serverTimestamp(),
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
// Update practitioner
|
|
1247
|
+
await updateDoc(practitionerRef, updateData);
|
|
1248
|
+
|
|
1249
|
+
// Return updated practitioner
|
|
1250
|
+
const updatedPractitioner = await this.getPractitioner(practitionerId);
|
|
1251
|
+
if (!updatedPractitioner) {
|
|
1252
|
+
throw new Error(
|
|
1253
|
+
`Failed to retrieve updated practitioner ${practitionerId}`
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
return updatedPractitioner;
|
|
1257
|
+
} catch (error) {
|
|
1258
|
+
if (error instanceof z.ZodError) {
|
|
1259
|
+
throw new Error(`Invalid practitioner update data: ${error.message}`);
|
|
1260
|
+
}
|
|
1261
|
+
console.error(`Error updating practitioner ${practitionerId}:`, error);
|
|
1262
|
+
throw error;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* Adds a clinic to a practitioner
|
|
1268
|
+
*/
|
|
1269
|
+
async addClinic(practitionerId: string, clinicId: string): Promise<void> {
|
|
1270
|
+
try {
|
|
1271
|
+
// Get practitioner
|
|
1272
|
+
const practitionerRef = doc(
|
|
1273
|
+
this.db,
|
|
1274
|
+
PRACTITIONERS_COLLECTION,
|
|
1275
|
+
practitionerId
|
|
1276
|
+
);
|
|
1277
|
+
const practitionerDoc = await getDoc(practitionerRef);
|
|
1278
|
+
|
|
1279
|
+
if (!practitionerDoc.exists()) {
|
|
1280
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const practitioner = practitionerDoc.data() as Practitioner;
|
|
1284
|
+
|
|
1285
|
+
// Check if clinic already added
|
|
1286
|
+
if (practitioner.clinics?.includes(clinicId)) {
|
|
1287
|
+
console.log(
|
|
1288
|
+
`Clinic ${clinicId} already added to practitioner ${practitionerId}`
|
|
1289
|
+
);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// Add clinic to clinics array
|
|
1294
|
+
await updateDoc(practitionerRef, {
|
|
1295
|
+
clinics: arrayUnion(clinicId),
|
|
1296
|
+
updatedAt: serverTimestamp(),
|
|
1297
|
+
});
|
|
1298
|
+
} catch (error) {
|
|
1299
|
+
console.error(
|
|
1300
|
+
`Error adding clinic ${clinicId} to practitioner ${practitionerId}:`,
|
|
1301
|
+
error
|
|
1302
|
+
);
|
|
1303
|
+
throw error;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/**
|
|
1308
|
+
* Removes a clinic from a practitioner
|
|
1309
|
+
*/
|
|
1310
|
+
async removeClinic(practitionerId: string, clinicId: string): Promise<void> {
|
|
1311
|
+
try {
|
|
1312
|
+
// Get practitioner
|
|
1313
|
+
const practitionerRef = doc(
|
|
1314
|
+
this.db,
|
|
1315
|
+
PRACTITIONERS_COLLECTION,
|
|
1316
|
+
practitionerId
|
|
1317
|
+
);
|
|
1318
|
+
const practitionerDoc = await getDoc(practitionerRef);
|
|
1319
|
+
|
|
1320
|
+
if (!practitionerDoc.exists()) {
|
|
1321
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Remove clinic from clinics array
|
|
1325
|
+
await updateDoc(practitionerRef, {
|
|
1326
|
+
clinics: arrayRemove(clinicId),
|
|
1327
|
+
updatedAt: serverTimestamp(),
|
|
1328
|
+
});
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
console.error(
|
|
1331
|
+
`Error removing clinic ${clinicId} from practitioner ${practitionerId}:`,
|
|
1332
|
+
error
|
|
1333
|
+
);
|
|
1334
|
+
throw error;
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Deaktivira profil zdravstvenog radnika
|
|
1340
|
+
*/
|
|
1341
|
+
async deactivatePractitioner(practitionerId: string): Promise<void> {
|
|
1342
|
+
await this.updatePractitioner(practitionerId, {
|
|
1343
|
+
isActive: false,
|
|
1344
|
+
});
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/**
|
|
1348
|
+
* Aktivira profil zdravstvenog radnika
|
|
1349
|
+
*/
|
|
1350
|
+
async activatePractitioner(practitionerId: string): Promise<void> {
|
|
1351
|
+
await this.updatePractitioner(practitionerId, {
|
|
1352
|
+
isActive: true,
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Briše profil zdravstvenog radnika
|
|
1358
|
+
*/
|
|
1359
|
+
async deletePractitioner(practitionerId: string): Promise<void> {
|
|
1360
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
1361
|
+
if (!practitioner) {
|
|
1362
|
+
throw new Error("Practitioner not found");
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// TODO: Kada implementiramo subkolekcije, ovde ćemo dodati brisanje povezanih podataka
|
|
1366
|
+
|
|
1367
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId));
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Validates a registration token and claims the associated draft practitioner profile
|
|
1372
|
+
* @param tokenString The token provided by the practitioner
|
|
1373
|
+
* @param userId The ID of the user claiming the profile
|
|
1374
|
+
* @returns The claimed practitioner profile or null if token is invalid
|
|
1375
|
+
*/
|
|
1376
|
+
async validateTokenAndClaimProfile(
|
|
1377
|
+
tokenString: string,
|
|
1378
|
+
userId: string
|
|
1379
|
+
): Promise<Practitioner | null> {
|
|
1380
|
+
// Find the token
|
|
1381
|
+
console.log("[PRACTITIONER] Validating token for claiming profile", {
|
|
1382
|
+
tokenString,
|
|
1383
|
+
userId,
|
|
1384
|
+
});
|
|
1385
|
+
|
|
1386
|
+
const token = await this.validateToken(tokenString);
|
|
1387
|
+
|
|
1388
|
+
if (!token) {
|
|
1389
|
+
console.log(
|
|
1390
|
+
"[PRACTITIONER] Token validation failed - token not found or not valid",
|
|
1391
|
+
{
|
|
1392
|
+
tokenString,
|
|
1393
|
+
}
|
|
1394
|
+
);
|
|
1395
|
+
return null; // Token not found or not valid
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
console.log("[PRACTITIONER] Token successfully validated", {
|
|
1399
|
+
tokenId: token.id,
|
|
1400
|
+
practitionerId: token.practitionerId,
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
// Get the practitioner profile
|
|
1404
|
+
const practitioner = await this.getPractitioner(token.practitionerId);
|
|
1405
|
+
if (!practitioner) {
|
|
1406
|
+
console.log("[PRACTITIONER] Practitioner not found", {
|
|
1407
|
+
practitionerId: token.practitionerId,
|
|
1408
|
+
});
|
|
1409
|
+
return null; // Practitioner not found
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// Ensure practitioner is in DRAFT status
|
|
1413
|
+
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
1414
|
+
console.log("[PRACTITIONER] Practitioner status is not DRAFT", {
|
|
1415
|
+
practitionerId: practitioner.id,
|
|
1416
|
+
status: practitioner.status,
|
|
1417
|
+
});
|
|
1418
|
+
throw new Error("This practitioner profile has already been claimed");
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// Check if user already has a practitioner profile
|
|
1422
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
1423
|
+
if (existingPractitioner) {
|
|
1424
|
+
throw new Error("User already has a practitioner profile");
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// Claim the profile by linking it to the user
|
|
1428
|
+
const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
|
|
1429
|
+
userRef: userId,
|
|
1430
|
+
status: PractitionerStatus.ACTIVE,
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
// Mark the token as used
|
|
1434
|
+
await this.markTokenAsUsed(token.id, token.practitionerId, userId);
|
|
1435
|
+
|
|
1436
|
+
console.log("[PRACTITIONER] Profile claimed successfully", {
|
|
1437
|
+
practitionerId: updatedPractitioner.id,
|
|
1438
|
+
userId,
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
return updatedPractitioner;
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Retrieves all practitioners with optional pagination and draft inclusion
|
|
1446
|
+
*
|
|
1447
|
+
* @param options - Search options
|
|
1448
|
+
* @param options.pagination - Optional limit for number of results per page
|
|
1449
|
+
* @param options.lastDoc - Optional last document for pagination
|
|
1450
|
+
* @param options.includeDraftPractitioners - Whether to include draft practitioners
|
|
1451
|
+
* @returns Array of practitioners and the last document for pagination
|
|
1452
|
+
*/
|
|
1453
|
+
async getAllPractitioners(options?: {
|
|
1454
|
+
pagination?: number;
|
|
1455
|
+
lastDoc?: any;
|
|
1456
|
+
includeDraftPractitioners?: boolean;
|
|
1457
|
+
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
1458
|
+
try {
|
|
1459
|
+
const constraints = [];
|
|
1460
|
+
|
|
1461
|
+
// Filter by status if not including drafts
|
|
1462
|
+
if (!options?.includeDraftPractitioners) {
|
|
1463
|
+
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// Add ordering for consistent pagination
|
|
1467
|
+
constraints.push(orderBy("basicInfo.lastName", "asc"));
|
|
1468
|
+
constraints.push(orderBy("basicInfo.firstName", "asc"));
|
|
1469
|
+
|
|
1470
|
+
// Add pagination if specified
|
|
1471
|
+
if (options?.pagination && options.pagination > 0) {
|
|
1472
|
+
if (options.lastDoc) {
|
|
1473
|
+
constraints.push(startAfter(options.lastDoc));
|
|
1474
|
+
}
|
|
1475
|
+
constraints.push(limit(options.pagination));
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
const q = query(
|
|
1479
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1480
|
+
...constraints
|
|
1481
|
+
);
|
|
1482
|
+
|
|
1483
|
+
const querySnapshot = await getDocs(q);
|
|
1484
|
+
|
|
1485
|
+
const practitioners = querySnapshot.docs.map(
|
|
1486
|
+
(doc) => doc.data() as Practitioner
|
|
1487
|
+
);
|
|
1488
|
+
|
|
1489
|
+
// Get last document for pagination
|
|
1490
|
+
const lastDoc =
|
|
1491
|
+
querySnapshot.docs.length > 0
|
|
1492
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1493
|
+
: null;
|
|
1494
|
+
|
|
1495
|
+
return {
|
|
1496
|
+
practitioners,
|
|
1497
|
+
lastDoc,
|
|
1498
|
+
};
|
|
1499
|
+
} catch (error) {
|
|
1500
|
+
console.error(
|
|
1501
|
+
"[PRACTITIONER_SERVICE] Error getting all practitioners:",
|
|
1502
|
+
error
|
|
1503
|
+
);
|
|
1504
|
+
throw error;
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
/**
|
|
1509
|
+
* Searches and filters practitioners based on multiple criteria
|
|
1510
|
+
*
|
|
1511
|
+
* @param filters - Various filters to apply
|
|
1512
|
+
* @param filters.nameSearch - Optional search text for first/last name
|
|
1513
|
+
* @param filters.certifications - Optional array of certifications to filter by
|
|
1514
|
+
* @param filters.specialties - Optional array of specialties to filter by
|
|
1515
|
+
* @param filters.procedureFamily - Optional procedure family practitioners provide
|
|
1516
|
+
* @param filters.procedureCategory - Optional procedure category practitioners provide
|
|
1517
|
+
* @param filters.procedureSubcategory - Optional procedure subcategory practitioners provide
|
|
1518
|
+
* @param filters.procedureTechnology - Optional procedure technology practitioners provide
|
|
1519
|
+
* @param filters.location - Optional location for distance-based search
|
|
1520
|
+
* @param filters.radiusInKm - Optional radius in kilometers (required if location is provided)
|
|
1521
|
+
* @param filters.minRating - Optional minimum rating (0-5)
|
|
1522
|
+
* @param filters.maxRating - Optional maximum rating (0-5)
|
|
1523
|
+
* @param filters.pagination - Optional number of results per page
|
|
1524
|
+
* @param filters.lastDoc - Optional last document for pagination
|
|
1525
|
+
* @param filters.includeDraftPractitioners - Whether to include draft practitioners
|
|
1526
|
+
* @returns Filtered practitioners and the last document for pagination
|
|
1527
|
+
*/
|
|
1528
|
+
async getPractitionersByFilters(filters: {
|
|
1529
|
+
nameSearch?: string;
|
|
1530
|
+
certifications?: string[];
|
|
1531
|
+
specialties?: CertificationSpecialty[];
|
|
1532
|
+
procedureFamily?: string;
|
|
1533
|
+
procedureCategory?: string;
|
|
1534
|
+
procedureSubcategory?: string;
|
|
1535
|
+
procedureTechnology?: string;
|
|
1536
|
+
location?: { latitude: number; longitude: number };
|
|
1537
|
+
radiusInKm?: number;
|
|
1538
|
+
minRating?: number;
|
|
1539
|
+
maxRating?: number;
|
|
1540
|
+
pagination?: number;
|
|
1541
|
+
lastDoc?: any;
|
|
1542
|
+
includeDraftPractitioners?: boolean;
|
|
1543
|
+
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
1544
|
+
try {
|
|
1545
|
+
console.log(
|
|
1546
|
+
"[PRACTITIONER_SERVICE] Starting practitioner filtering with fallback strategies"
|
|
1547
|
+
);
|
|
1548
|
+
|
|
1549
|
+
// Geo query debug i validacija
|
|
1550
|
+
if (filters.location && filters.radiusInKm) {
|
|
1551
|
+
console.log("[PRACTITIONER_SERVICE] Executing geo query:", {
|
|
1552
|
+
location: filters.location,
|
|
1553
|
+
radius: filters.radiusInKm,
|
|
1554
|
+
serviceName: "PractitionerService",
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
// Validacija location podataka
|
|
1558
|
+
if (!filters.location.latitude || !filters.location.longitude) {
|
|
1559
|
+
console.warn(
|
|
1560
|
+
"[PRACTITIONER_SERVICE] Invalid location data:",
|
|
1561
|
+
filters.location
|
|
1562
|
+
);
|
|
1563
|
+
filters.location = undefined;
|
|
1564
|
+
filters.radiusInKm = undefined;
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Strategy 1: Try fullNameLower search if nameSearch exists
|
|
1569
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1570
|
+
try {
|
|
1571
|
+
console.log(
|
|
1572
|
+
"[PRACTITIONER_SERVICE] Strategy 1: Trying fullNameLower search"
|
|
1573
|
+
);
|
|
1574
|
+
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1575
|
+
const constraints: any[] = [];
|
|
1576
|
+
|
|
1577
|
+
if (!filters.includeDraftPractitioners) {
|
|
1578
|
+
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1579
|
+
}
|
|
1580
|
+
constraints.push(where("isActive", "==", true));
|
|
1581
|
+
constraints.push(where("fullNameLower", ">=", searchTerm));
|
|
1582
|
+
constraints.push(where("fullNameLower", "<=", searchTerm + "\uf8ff"));
|
|
1583
|
+
constraints.push(orderBy("fullNameLower"));
|
|
1584
|
+
|
|
1585
|
+
if (filters.location && filters.radiusInKm) {
|
|
1586
|
+
// Fetch more results when geo filtering will reduce count
|
|
1587
|
+
if (filters.lastDoc) {
|
|
1588
|
+
if (typeof filters.lastDoc.data === "function") {
|
|
1589
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1590
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
1591
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
1592
|
+
} else {
|
|
1593
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
constraints.push(limit((filters.pagination || 10) * 2));
|
|
1597
|
+
} else {
|
|
1598
|
+
if (filters.lastDoc) {
|
|
1599
|
+
if (typeof filters.lastDoc.data === "function") {
|
|
1600
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1601
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
1602
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
1603
|
+
} else {
|
|
1604
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
constraints.push(limit(filters.pagination || 10));
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
const q = query(
|
|
1611
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1612
|
+
...constraints
|
|
1613
|
+
);
|
|
1614
|
+
const querySnapshot = await getDocs(q);
|
|
1615
|
+
let practitioners = querySnapshot.docs.map(
|
|
1616
|
+
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1617
|
+
);
|
|
1618
|
+
const lastDoc =
|
|
1619
|
+
querySnapshot.docs.length > 0
|
|
1620
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1621
|
+
: null;
|
|
1622
|
+
|
|
1623
|
+
// Apply geo filter if location is provided (in-memory, same as Strategy 2)
|
|
1624
|
+
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1625
|
+
const location = filters.location;
|
|
1626
|
+
const radiusInKm = filters.radiusInKm;
|
|
1627
|
+
practitioners = practitioners.filter((practitioner) => {
|
|
1628
|
+
const clinics = practitioner.clinicsInfo || [];
|
|
1629
|
+
return clinics.some((clinic) => {
|
|
1630
|
+
const distanceInKm = distanceBetween(
|
|
1631
|
+
[location.latitude, location.longitude],
|
|
1632
|
+
[clinic.location.latitude, clinic.location.longitude]
|
|
1633
|
+
); // Already returns km
|
|
1634
|
+
return distanceInKm <= radiusInKm;
|
|
1635
|
+
});
|
|
1636
|
+
});
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
console.log(
|
|
1640
|
+
`[PRACTITIONER_SERVICE] Strategy 1 success: ${practitioners.length} practitioners`
|
|
1641
|
+
);
|
|
1642
|
+
|
|
1643
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1644
|
+
if (practitioners.length < (filters.pagination || 10)) {
|
|
1645
|
+
return { practitioners, lastDoc: null };
|
|
1646
|
+
}
|
|
1647
|
+
return { practitioners, lastDoc };
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
console.log("[PRACTITIONER_SERVICE] Strategy 1 failed:", error);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Strategy 2: Basic query with createdAt ordering (no name search)
|
|
1654
|
+
try {
|
|
1655
|
+
console.log(
|
|
1656
|
+
"[PRACTITIONER_SERVICE] Strategy 2: Basic query with createdAt ordering"
|
|
1657
|
+
);
|
|
1658
|
+
const constraints: any[] = [];
|
|
1659
|
+
|
|
1660
|
+
if (!filters.includeDraftPractitioners) {
|
|
1661
|
+
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1662
|
+
}
|
|
1663
|
+
constraints.push(where("isActive", "==", true));
|
|
1664
|
+
|
|
1665
|
+
// Add other filters that work well with Firestore
|
|
1666
|
+
if (filters.certifications && filters.certifications.length > 0) {
|
|
1667
|
+
const certificationsToMatch =
|
|
1668
|
+
filters.certifications as CertificationSpecialty[];
|
|
1669
|
+
constraints.push(
|
|
1670
|
+
where(
|
|
1671
|
+
"certification.specialties",
|
|
1672
|
+
"array-contains-any",
|
|
1673
|
+
certificationsToMatch
|
|
1674
|
+
)
|
|
1675
|
+
);
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
if (filters.minRating !== undefined) {
|
|
1679
|
+
constraints.push(
|
|
1680
|
+
where("reviewInfo.averageRating", ">=", filters.minRating)
|
|
1681
|
+
);
|
|
1682
|
+
}
|
|
1683
|
+
if (filters.maxRating !== undefined) {
|
|
1684
|
+
constraints.push(
|
|
1685
|
+
where("reviewInfo.averageRating", "<=", filters.maxRating)
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
constraints.push(orderBy("createdAt", "desc"));
|
|
1690
|
+
|
|
1691
|
+
// Pagination sa createdAt - poboljšano za geo queries
|
|
1692
|
+
if (filters.location && filters.radiusInKm) {
|
|
1693
|
+
// Ne koristiti lastDoc za geo queries, već preuzmi više rezultata
|
|
1694
|
+
constraints.push(limit((filters.pagination || 10) * 2)); // Dvostruko više za geo filter
|
|
1695
|
+
} else {
|
|
1696
|
+
if (filters.lastDoc) {
|
|
1697
|
+
if (typeof filters.lastDoc.data === "function") {
|
|
1698
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1699
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
1700
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
1701
|
+
} else {
|
|
1702
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
constraints.push(limit(filters.pagination || 10));
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
const q = query(
|
|
1709
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1710
|
+
...constraints
|
|
1711
|
+
);
|
|
1712
|
+
const querySnapshot = await getDocs(q);
|
|
1713
|
+
let practitioners = querySnapshot.docs.map(
|
|
1714
|
+
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1715
|
+
);
|
|
1716
|
+
|
|
1717
|
+
// Apply geo filter if needed (this is the only in-memory filter we keep)
|
|
1718
|
+
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1719
|
+
const location = filters.location;
|
|
1720
|
+
const radiusInKm = filters.radiusInKm;
|
|
1721
|
+
practitioners = practitioners.filter((practitioner) => {
|
|
1722
|
+
const clinics = practitioner.clinicsInfo || [];
|
|
1723
|
+
return clinics.some((clinic) => {
|
|
1724
|
+
const distanceInKm = distanceBetween(
|
|
1725
|
+
[location.latitude, location.longitude],
|
|
1726
|
+
[clinic.location.latitude, clinic.location.longitude]
|
|
1727
|
+
); // Already returns km
|
|
1728
|
+
return distanceInKm <= radiusInKm;
|
|
1729
|
+
});
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
// Ograniči na pagination broj nakon geo filtera
|
|
1733
|
+
practitioners = practitioners.slice(0, filters.pagination || 10);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// Apply all remaining client-side filters using centralized function
|
|
1737
|
+
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1738
|
+
|
|
1739
|
+
const lastDoc =
|
|
1740
|
+
querySnapshot.docs.length > 0
|
|
1741
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1742
|
+
: null;
|
|
1743
|
+
console.log(
|
|
1744
|
+
`[PRACTITIONER_SERVICE] Strategy 2 success: ${practitioners.length} practitioners`
|
|
1745
|
+
);
|
|
1746
|
+
|
|
1747
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1748
|
+
if (practitioners.length < (filters.pagination || 10)) {
|
|
1749
|
+
return { practitioners, lastDoc: null };
|
|
1750
|
+
}
|
|
1751
|
+
return { practitioners, lastDoc };
|
|
1752
|
+
} catch (error) {
|
|
1753
|
+
console.log("[PRACTITIONER_SERVICE] Strategy 2 failed:", error);
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// Strategy 3: Minimal query fallback
|
|
1757
|
+
try {
|
|
1758
|
+
console.log(
|
|
1759
|
+
"[PRACTITIONER_SERVICE] Strategy 3: Minimal query fallback"
|
|
1760
|
+
);
|
|
1761
|
+
const constraints: any[] = [
|
|
1762
|
+
where("isActive", "==", true),
|
|
1763
|
+
orderBy("createdAt", "desc"),
|
|
1764
|
+
limit(filters.pagination || 10),
|
|
1765
|
+
];
|
|
1766
|
+
|
|
1767
|
+
const q = query(
|
|
1768
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1769
|
+
...constraints
|
|
1770
|
+
);
|
|
1771
|
+
const querySnapshot = await getDocs(q);
|
|
1772
|
+
let practitioners = querySnapshot.docs.map(
|
|
1773
|
+
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1774
|
+
);
|
|
1775
|
+
|
|
1776
|
+
// Apply all client-side filters using centralized function
|
|
1777
|
+
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1778
|
+
|
|
1779
|
+
const lastDoc =
|
|
1780
|
+
querySnapshot.docs.length > 0
|
|
1781
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1782
|
+
: null;
|
|
1783
|
+
console.log(
|
|
1784
|
+
`[PRACTITIONER_SERVICE] Strategy 3 success: ${practitioners.length} practitioners`
|
|
1785
|
+
);
|
|
1786
|
+
|
|
1787
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1788
|
+
if (practitioners.length < (filters.pagination || 10)) {
|
|
1789
|
+
return { practitioners, lastDoc: null };
|
|
1790
|
+
}
|
|
1791
|
+
return { practitioners, lastDoc };
|
|
1792
|
+
} catch (error) {
|
|
1793
|
+
console.log("[PRACTITIONER_SERVICE] Strategy 3 failed:", error);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// Strategy 4: Client-side filtering fallback (kao u procedure/clinic services)
|
|
1797
|
+
try {
|
|
1798
|
+
console.log(
|
|
1799
|
+
"[PRACTITIONER_SERVICE] Strategy 4: Client-side filtering fallback"
|
|
1800
|
+
);
|
|
1801
|
+
|
|
1802
|
+
const constraints: any[] = [
|
|
1803
|
+
where("isActive", "==", true),
|
|
1804
|
+
where("status", "==", PractitionerStatus.ACTIVE),
|
|
1805
|
+
orderBy("createdAt", "desc"),
|
|
1806
|
+
limit(filters.pagination || 10),
|
|
1807
|
+
];
|
|
1808
|
+
|
|
1809
|
+
const q = query(
|
|
1810
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1811
|
+
...constraints
|
|
1812
|
+
);
|
|
1813
|
+
const querySnapshot = await getDocs(q);
|
|
1814
|
+
let practitioners = querySnapshot.docs.map(
|
|
1815
|
+
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1816
|
+
);
|
|
1817
|
+
|
|
1818
|
+
// Apply all client-side filters using centralized function
|
|
1819
|
+
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1820
|
+
|
|
1821
|
+
const lastDoc =
|
|
1822
|
+
querySnapshot.docs.length > 0
|
|
1823
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1824
|
+
: null;
|
|
1825
|
+
console.log(
|
|
1826
|
+
`[PRACTITIONER_SERVICE] Strategy 4 success: ${practitioners.length} practitioners`
|
|
1827
|
+
);
|
|
1828
|
+
|
|
1829
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1830
|
+
if (practitioners.length < (filters.pagination || 10)) {
|
|
1831
|
+
return { practitioners, lastDoc: null };
|
|
1832
|
+
}
|
|
1833
|
+
return { practitioners, lastDoc };
|
|
1834
|
+
} catch (error) {
|
|
1835
|
+
console.log("[PRACTITIONER_SERVICE] Strategy 4 failed:", error);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
// All strategies failed
|
|
1839
|
+
console.log(
|
|
1840
|
+
"[PRACTITIONER_SERVICE] All strategies failed, returning empty result"
|
|
1841
|
+
);
|
|
1842
|
+
return { practitioners: [], lastDoc: null };
|
|
1843
|
+
} catch (error) {
|
|
1844
|
+
console.error(
|
|
1845
|
+
"[PRACTITIONER_SERVICE] Error filtering practitioners:",
|
|
1846
|
+
error
|
|
1847
|
+
);
|
|
1848
|
+
return { practitioners: [], lastDoc: null };
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* Applies in-memory filters to practitioners array
|
|
1854
|
+
* Used when Firestore queries fail or for complex filtering
|
|
1855
|
+
*/
|
|
1856
|
+
private applyInMemoryFilters(
|
|
1857
|
+
practitioners: Practitioner[],
|
|
1858
|
+
filters: any
|
|
1859
|
+
): Practitioner[] {
|
|
1860
|
+
let filteredPractitioners = [...practitioners]; // Create copy to avoid mutating original
|
|
1861
|
+
|
|
1862
|
+
// Name search filter
|
|
1863
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1864
|
+
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1865
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1866
|
+
const firstName = (
|
|
1867
|
+
practitioner.basicInfo?.firstName || ""
|
|
1868
|
+
).toLowerCase();
|
|
1869
|
+
const lastName = (practitioner.basicInfo?.lastName || "").toLowerCase();
|
|
1870
|
+
const fullName = `${firstName} ${lastName}`.trim();
|
|
1871
|
+
const fullNameLower = practitioner.fullNameLower || "";
|
|
1872
|
+
|
|
1873
|
+
return (
|
|
1874
|
+
firstName.includes(searchTerm) ||
|
|
1875
|
+
lastName.includes(searchTerm) ||
|
|
1876
|
+
fullName.includes(searchTerm) ||
|
|
1877
|
+
fullNameLower.includes(searchTerm)
|
|
1878
|
+
);
|
|
1879
|
+
});
|
|
1880
|
+
console.log(
|
|
1881
|
+
`[PRACTITIONER_SERVICE] Applied name filter, results: ${filteredPractitioners.length}`
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
// Certifications filtering
|
|
1886
|
+
if (filters.certifications && filters.certifications.length > 0) {
|
|
1887
|
+
const certificationsToMatch = filters.certifications;
|
|
1888
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1889
|
+
const practitionerCerts = practitioner.certification?.specialties || [];
|
|
1890
|
+
return certificationsToMatch.some((cert: any) =>
|
|
1891
|
+
practitionerCerts.includes(cert as CertificationSpecialty)
|
|
1892
|
+
);
|
|
1893
|
+
});
|
|
1894
|
+
console.log(
|
|
1895
|
+
`[PRACTITIONER_SERVICE] Applied certifications filter, results: ${filteredPractitioners.length}`
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// Specialties filtering
|
|
1900
|
+
if (filters.specialties && filters.specialties.length > 0) {
|
|
1901
|
+
const specialtiesToMatch = filters.specialties;
|
|
1902
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1903
|
+
const practitionerSpecs = practitioner.certification?.specialties || [];
|
|
1904
|
+
return specialtiesToMatch.some((spec: any) =>
|
|
1905
|
+
practitionerSpecs.includes(spec)
|
|
1906
|
+
);
|
|
1907
|
+
});
|
|
1908
|
+
console.log(
|
|
1909
|
+
`[PRACTITIONER_SERVICE] Applied specialties filter, results: ${filteredPractitioners.length}`
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// Rating filtering
|
|
1914
|
+
if (filters.minRating !== undefined || filters.maxRating !== undefined) {
|
|
1915
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1916
|
+
const rating = practitioner.reviewInfo?.averageRating || 0;
|
|
1917
|
+
if (filters.minRating !== undefined && rating < filters.minRating)
|
|
1918
|
+
return false;
|
|
1919
|
+
if (filters.maxRating !== undefined && rating > filters.maxRating)
|
|
1920
|
+
return false;
|
|
1921
|
+
return true;
|
|
1922
|
+
});
|
|
1923
|
+
console.log(
|
|
1924
|
+
`[PRACTITIONER_SERVICE] Applied rating filter, results: ${filteredPractitioners.length}`
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// Procedure family filtering
|
|
1929
|
+
if (filters.procedureFamily) {
|
|
1930
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1931
|
+
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1932
|
+
return proceduresInfo.some(
|
|
1933
|
+
(proc) => proc.family === filters.procedureFamily
|
|
1934
|
+
);
|
|
1935
|
+
});
|
|
1936
|
+
console.log(
|
|
1937
|
+
`[PRACTITIONER_SERVICE] Applied procedure family filter, results: ${filteredPractitioners.length}`
|
|
1938
|
+
);
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
// Procedure category filtering
|
|
1942
|
+
if (filters.procedureCategory) {
|
|
1943
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1944
|
+
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1945
|
+
return proceduresInfo.some(
|
|
1946
|
+
(proc) => proc.categoryName === filters.procedureCategory
|
|
1947
|
+
);
|
|
1948
|
+
});
|
|
1949
|
+
console.log(
|
|
1950
|
+
`[PRACTITIONER_SERVICE] Applied procedure category filter, results: ${filteredPractitioners.length}`
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
// Procedure subcategory filtering
|
|
1955
|
+
if (filters.procedureSubcategory) {
|
|
1956
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1957
|
+
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1958
|
+
return proceduresInfo.some(
|
|
1959
|
+
(proc) => proc.subcategoryName === filters.procedureSubcategory
|
|
1960
|
+
);
|
|
1961
|
+
});
|
|
1962
|
+
console.log(
|
|
1963
|
+
`[PRACTITIONER_SERVICE] Applied procedure subcategory filter, results: ${filteredPractitioners.length}`
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
// Procedure technology filtering
|
|
1968
|
+
if (filters.procedureTechnology) {
|
|
1969
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1970
|
+
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1971
|
+
return proceduresInfo.some(
|
|
1972
|
+
(proc) => proc.technologyName === filters.procedureTechnology
|
|
1973
|
+
);
|
|
1974
|
+
});
|
|
1975
|
+
console.log(
|
|
1976
|
+
`[PRACTITIONER_SERVICE] Applied procedure technology filter, results: ${filteredPractitioners.length}`
|
|
1977
|
+
);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// Geo-radius filter
|
|
1981
|
+
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1982
|
+
const location = filters.location;
|
|
1983
|
+
const radiusInKm = filters.radiusInKm;
|
|
1984
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1985
|
+
const clinics = practitioner.clinicsInfo || [];
|
|
1986
|
+
return clinics.some((clinic) => {
|
|
1987
|
+
const distanceInKm = distanceBetween(
|
|
1988
|
+
[location.latitude, location.longitude],
|
|
1989
|
+
[clinic.location.latitude, clinic.location.longitude]
|
|
1990
|
+
); // Already returns km
|
|
1991
|
+
return distanceInKm <= radiusInKm;
|
|
1992
|
+
});
|
|
1993
|
+
});
|
|
1994
|
+
console.log(
|
|
1995
|
+
`[PRACTITIONER_SERVICE] Applied geo filter, results: ${filteredPractitioners.length}`
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
return filteredPractitioners;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
/**
|
|
2003
|
+
* Enables free consultation for a practitioner in a specific clinic
|
|
2004
|
+
* Creates a free consultation procedure with hardcoded parameters
|
|
2005
|
+
* @param practitionerId - ID of the practitioner
|
|
2006
|
+
* @param clinicId - ID of the clinic
|
|
2007
|
+
* @returns The created consultation procedure
|
|
2008
|
+
*/
|
|
2009
|
+
async EnableFreeConsultation(
|
|
2010
|
+
practitionerId: string,
|
|
2011
|
+
clinicId: string
|
|
2012
|
+
): Promise<void> {
|
|
2013
|
+
try {
|
|
2014
|
+
console.log(
|
|
2015
|
+
`[EnableFreeConsultation] Starting for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2016
|
+
);
|
|
2017
|
+
|
|
2018
|
+
// First, ensure the free consultation infrastructure exists
|
|
2019
|
+
await this.ensureFreeConsultationInfrastructure();
|
|
2020
|
+
|
|
2021
|
+
// Validate that practitioner exists and is active
|
|
2022
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
2023
|
+
if (!practitioner) {
|
|
2024
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
// No need to check for is practitioner active
|
|
2028
|
+
// if (!practitioner.isActive) {
|
|
2029
|
+
// throw new Error(`Practitioner ${practitionerId} is not active`);
|
|
2030
|
+
// }
|
|
2031
|
+
|
|
2032
|
+
// Validate that clinic exists
|
|
2033
|
+
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
2034
|
+
if (!clinic) {
|
|
2035
|
+
throw new Error(`Clinic ${clinicId} not found`);
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// Check if practitioner is associated with this clinic
|
|
2039
|
+
if (!practitioner.clinics.includes(clinicId)) {
|
|
2040
|
+
throw new Error(
|
|
2041
|
+
`Practitioner ${practitionerId} is not associated with clinic ${clinicId}`
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// CRITICAL: Double-check for existing procedures to prevent race conditions
|
|
2046
|
+
// Fetch procedures again right before creation/update
|
|
2047
|
+
// IMPORTANT: Pass false for excludeDraftPractitioners to work with draft practitioners
|
|
2048
|
+
const [activeProcedures, inactiveProcedures] = await Promise.all([
|
|
2049
|
+
this.getProcedureService().getProceduresByPractitioner(
|
|
2050
|
+
practitionerId,
|
|
2051
|
+
undefined, // clinicBranchId
|
|
2052
|
+
false // excludeDraftPractitioners - allow draft practitioners
|
|
2053
|
+
),
|
|
2054
|
+
this.getProcedureService().getInactiveProceduresByPractitioner(
|
|
2055
|
+
practitionerId
|
|
2056
|
+
),
|
|
2057
|
+
]);
|
|
2058
|
+
|
|
2059
|
+
// Combine active and inactive procedures
|
|
2060
|
+
const allProcedures = [...activeProcedures, ...inactiveProcedures];
|
|
2061
|
+
|
|
2062
|
+
// Check if free consultation already exists (active or inactive)
|
|
2063
|
+
const existingConsultations = allProcedures.filter(
|
|
2064
|
+
(procedure) =>
|
|
2065
|
+
procedure.technology.id === "free-consultation-tech" &&
|
|
2066
|
+
procedure.clinicBranchId === clinicId
|
|
2067
|
+
);
|
|
2068
|
+
|
|
2069
|
+
console.log(
|
|
2070
|
+
`[EnableFreeConsultation] Found ${existingConsultations.length} existing free consultation(s)`
|
|
2071
|
+
);
|
|
2072
|
+
|
|
2073
|
+
// If multiple consultations exist, log a warning and clean up duplicates
|
|
2074
|
+
if (existingConsultations.length > 1) {
|
|
2075
|
+
console.warn(
|
|
2076
|
+
`[EnableFreeConsultation] WARNING: Found ${existingConsultations.length} duplicate free consultations for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2077
|
+
);
|
|
2078
|
+
// Keep the first one, deactivate the rest
|
|
2079
|
+
for (let i = 1; i < existingConsultations.length; i++) {
|
|
2080
|
+
console.log(
|
|
2081
|
+
`[EnableFreeConsultation] Deactivating duplicate consultation ${existingConsultations[i].id}`
|
|
2082
|
+
);
|
|
2083
|
+
await this.getProcedureService().deactivateProcedure(
|
|
2084
|
+
existingConsultations[i].id
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
const existingConsultation = existingConsultations[0];
|
|
2090
|
+
|
|
2091
|
+
if (existingConsultation) {
|
|
2092
|
+
if (existingConsultation.isActive) {
|
|
2093
|
+
console.log(
|
|
2094
|
+
`[EnableFreeConsultation] Free consultation already active for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2095
|
+
);
|
|
2096
|
+
return;
|
|
2097
|
+
} else {
|
|
2098
|
+
// Reactivate the existing disabled consultation
|
|
2099
|
+
console.log(
|
|
2100
|
+
`[EnableFreeConsultation] Reactivating existing consultation ${existingConsultation.id}`
|
|
2101
|
+
);
|
|
2102
|
+
await this.getProcedureService().updateProcedure(
|
|
2103
|
+
existingConsultation.id,
|
|
2104
|
+
{ isActive: true }
|
|
2105
|
+
);
|
|
2106
|
+
console.log(
|
|
2107
|
+
`[EnableFreeConsultation] Reactivated existing free consultation for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2108
|
+
);
|
|
2109
|
+
return;
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// Final check before creating - race condition guard
|
|
2114
|
+
// Fetch one more time to ensure no procedure was created in parallel
|
|
2115
|
+
console.log(
|
|
2116
|
+
`[EnableFreeConsultation] Final race condition check before creating new procedure`
|
|
2117
|
+
);
|
|
2118
|
+
const finalCheckProcedures =
|
|
2119
|
+
await this.getProcedureService().getProceduresByPractitioner(
|
|
2120
|
+
practitionerId,
|
|
2121
|
+
undefined, // clinicBranchId
|
|
2122
|
+
false // excludeDraftPractitioners - allow draft practitioners
|
|
2123
|
+
);
|
|
2124
|
+
const raceConditionCheck = finalCheckProcedures.find(
|
|
2125
|
+
(procedure) =>
|
|
2126
|
+
procedure.technology.id === "free-consultation-tech" &&
|
|
2127
|
+
procedure.clinicBranchId === clinicId
|
|
2128
|
+
);
|
|
2129
|
+
|
|
2130
|
+
if (raceConditionCheck) {
|
|
2131
|
+
console.log(
|
|
2132
|
+
`[EnableFreeConsultation] Race condition detected! Procedure was created by another request. Using existing procedure ${raceConditionCheck.id}`
|
|
2133
|
+
);
|
|
2134
|
+
if (!raceConditionCheck.isActive) {
|
|
2135
|
+
await this.getProcedureService().updateProcedure(
|
|
2136
|
+
raceConditionCheck.id,
|
|
2137
|
+
{ isActive: true }
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
return;
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
// Create procedure data for free consultation (without productId or productsMetadata)
|
|
2144
|
+
const consultationData: Omit<CreateProcedureData, "productId"> = {
|
|
2145
|
+
name: "Free Consultation",
|
|
2146
|
+
nameLower: "free consultation",
|
|
2147
|
+
description:
|
|
2148
|
+
"Free initial consultation to discuss treatment options and assess patient needs.",
|
|
2149
|
+
family: ProcedureFamily.AESTHETICS,
|
|
2150
|
+
categoryId: "consultation",
|
|
2151
|
+
subcategoryId: "free-consultation",
|
|
2152
|
+
technologyId: "free-consultation-tech",
|
|
2153
|
+
price: 0,
|
|
2154
|
+
currency: Currency.EUR,
|
|
2155
|
+
pricingMeasure: PricingMeasure.PER_SESSION,
|
|
2156
|
+
// productsMetadata omitted - no products needed for consultations
|
|
2157
|
+
duration: 30, // 30 minutes consultation
|
|
2158
|
+
practitionerId: practitionerId,
|
|
2159
|
+
clinicBranchId: clinicId,
|
|
2160
|
+
photos: [], // No photos for consultation
|
|
2161
|
+
};
|
|
2162
|
+
|
|
2163
|
+
// Create the consultation procedure using the special method
|
|
2164
|
+
console.log(
|
|
2165
|
+
`[EnableFreeConsultation] Creating new free consultation procedure`
|
|
2166
|
+
);
|
|
2167
|
+
await this.getProcedureService().createConsultationProcedure(
|
|
2168
|
+
consultationData
|
|
2169
|
+
);
|
|
2170
|
+
|
|
2171
|
+
console.log(
|
|
2172
|
+
`[EnableFreeConsultation] Successfully created free consultation for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2173
|
+
);
|
|
2174
|
+
} catch (error) {
|
|
2175
|
+
console.error(
|
|
2176
|
+
`[EnableFreeConsultation] Error enabling free consultation for practitioner ${practitionerId} in clinic ${clinicId}:`,
|
|
2177
|
+
error
|
|
2178
|
+
);
|
|
2179
|
+
throw error;
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/**
|
|
2184
|
+
* Ensures that the free consultation infrastructure exists by calling the Cloud Function
|
|
2185
|
+
* @returns Promise<boolean> - True if infrastructure exists or was created successfully
|
|
2186
|
+
*/
|
|
2187
|
+
async ensureFreeConsultationInfrastructure(): Promise<boolean> {
|
|
2188
|
+
try {
|
|
2189
|
+
console.log(
|
|
2190
|
+
"[PRACTITIONER_SERVICE] Ensuring free consultation infrastructure via HTTP"
|
|
2191
|
+
);
|
|
2192
|
+
|
|
2193
|
+
// Check if user is authenticated
|
|
2194
|
+
const currentUser = this.auth.currentUser;
|
|
2195
|
+
if (!currentUser) {
|
|
2196
|
+
throw new Error(
|
|
2197
|
+
"User must be authenticated to ensure free consultation infrastructure"
|
|
2198
|
+
);
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
// Construct the function URL from the Firebase project ID
|
|
2202
|
+
const projectId = this.app.options.projectId;
|
|
2203
|
+
const functionUrl = `https://europe-west6-${projectId}.cloudfunctions.net/bookingApi/ensureFreeConsultationInfrastructure`;
|
|
2204
|
+
|
|
2205
|
+
// Get the authenticated user's ID token
|
|
2206
|
+
const idToken = await currentUser.getIdToken();
|
|
2207
|
+
|
|
2208
|
+
console.log(
|
|
2209
|
+
`[PRACTITIONER_SERVICE] Making fetch request to ${functionUrl}`
|
|
2210
|
+
);
|
|
2211
|
+
|
|
2212
|
+
// Make the HTTP request
|
|
2213
|
+
const response = await fetch(functionUrl, {
|
|
2214
|
+
method: "POST",
|
|
2215
|
+
mode: "cors",
|
|
2216
|
+
cache: "no-cache",
|
|
2217
|
+
credentials: "omit",
|
|
2218
|
+
headers: {
|
|
2219
|
+
"Content-Type": "application/json",
|
|
2220
|
+
Authorization: `Bearer ${idToken}`,
|
|
2221
|
+
},
|
|
2222
|
+
redirect: "follow",
|
|
2223
|
+
referrerPolicy: "no-referrer",
|
|
2224
|
+
body: JSON.stringify({}), // Empty body as no parameters needed
|
|
2225
|
+
});
|
|
2226
|
+
|
|
2227
|
+
console.log(
|
|
2228
|
+
`[PRACTITIONER_SERVICE] Received response ${response.status}: ${response.statusText}`
|
|
2229
|
+
);
|
|
2230
|
+
|
|
2231
|
+
// Check if the request was successful
|
|
2232
|
+
if (!response.ok) {
|
|
2233
|
+
const errorText = await response.text();
|
|
2234
|
+
console.error(
|
|
2235
|
+
`[PRACTITIONER_SERVICE] Error response details: ${errorText}`
|
|
2236
|
+
);
|
|
2237
|
+
throw new Error(
|
|
2238
|
+
`Failed to ensure free consultation infrastructure: ${response.status} ${response.statusText} - ${errorText}`
|
|
2239
|
+
);
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// Parse the response
|
|
2243
|
+
const result = await response.json();
|
|
2244
|
+
console.log(
|
|
2245
|
+
`[PRACTITIONER_SERVICE] Infrastructure check response:`,
|
|
2246
|
+
result
|
|
2247
|
+
);
|
|
2248
|
+
|
|
2249
|
+
if (!result.success) {
|
|
2250
|
+
throw new Error(
|
|
2251
|
+
result.error || "Failed to ensure free consultation infrastructure"
|
|
2252
|
+
);
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
console.log(
|
|
2256
|
+
`[PRACTITIONER_SERVICE] Free consultation infrastructure ensured successfully`
|
|
2257
|
+
);
|
|
2258
|
+
|
|
2259
|
+
return result.infrastructureExists;
|
|
2260
|
+
} catch (error) {
|
|
2261
|
+
console.error(
|
|
2262
|
+
"[PRACTITIONER_SERVICE] Error ensuring free consultation infrastructure:",
|
|
2263
|
+
error
|
|
2264
|
+
);
|
|
2265
|
+
throw error;
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* Disables free consultation for a practitioner in a specific clinic
|
|
2271
|
+
* Finds and deactivates the existing free consultation procedure
|
|
2272
|
+
* @param practitionerId - ID of the practitioner
|
|
2273
|
+
* @param clinicId - ID of the clinic
|
|
2274
|
+
*/
|
|
2275
|
+
async DisableFreeConsultation(
|
|
2276
|
+
practitionerId: string,
|
|
2277
|
+
clinicId: string
|
|
2278
|
+
): Promise<void> {
|
|
2279
|
+
try {
|
|
2280
|
+
// Validate that practitioner exists
|
|
2281
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
2282
|
+
if (!practitioner) {
|
|
2283
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
// Validate that clinic exists
|
|
2287
|
+
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
2288
|
+
if (!clinic) {
|
|
2289
|
+
throw new Error(`Clinic ${clinicId} not found`);
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// Check if practitioner is associated with this clinic
|
|
2293
|
+
if (!practitioner.clinics.includes(clinicId)) {
|
|
2294
|
+
throw new Error(
|
|
2295
|
+
`Practitioner ${practitionerId} is not associated with clinic ${clinicId}`
|
|
2296
|
+
);
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Find the free consultation procedure for this practitioner in this clinic
|
|
2300
|
+
// Use the more specific search by technology ID instead of name
|
|
2301
|
+
// IMPORTANT: Pass false for excludeDraftPractitioners to allow disabling for draft practitioners
|
|
2302
|
+
const existingProcedures =
|
|
2303
|
+
await this.getProcedureService().getProceduresByPractitioner(
|
|
2304
|
+
practitionerId,
|
|
2305
|
+
undefined, // clinicBranchId (optional)
|
|
2306
|
+
false // excludeDraftPractitioners - must be false to find procedures for draft practitioners
|
|
2307
|
+
);
|
|
2308
|
+
|
|
2309
|
+
console.log(
|
|
2310
|
+
`[DisableFreeConsultation] Found ${existingProcedures.length} procedures for practitioner ${practitionerId}`
|
|
2311
|
+
);
|
|
2312
|
+
|
|
2313
|
+
const freeConsultation = existingProcedures.find(
|
|
2314
|
+
(procedure) =>
|
|
2315
|
+
procedure.technology.id === "free-consultation-tech" &&
|
|
2316
|
+
procedure.clinicBranchId === clinicId &&
|
|
2317
|
+
procedure.isActive
|
|
2318
|
+
);
|
|
2319
|
+
|
|
2320
|
+
if (!freeConsultation) {
|
|
2321
|
+
console.log(
|
|
2322
|
+
`[DisableFreeConsultation] No active free consultation found for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2323
|
+
);
|
|
2324
|
+
console.log(
|
|
2325
|
+
`[DisableFreeConsultation] Existing procedures:`,
|
|
2326
|
+
existingProcedures.map(p => ({
|
|
2327
|
+
id: p.id,
|
|
2328
|
+
name: p.name,
|
|
2329
|
+
technologyId: p.technology?.id,
|
|
2330
|
+
clinicBranchId: p.clinicBranchId,
|
|
2331
|
+
isActive: p.isActive
|
|
2332
|
+
}))
|
|
2333
|
+
);
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
console.log(
|
|
2338
|
+
`[DisableFreeConsultation] Found free consultation procedure ${freeConsultation.id}, deactivating...`
|
|
2339
|
+
);
|
|
2340
|
+
|
|
2341
|
+
// Deactivate the consultation procedure
|
|
2342
|
+
await this.getProcedureService().deactivateProcedure(freeConsultation.id);
|
|
2343
|
+
|
|
2344
|
+
console.log(
|
|
2345
|
+
`Free consultation disabled for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
2346
|
+
);
|
|
2347
|
+
} catch (error) {
|
|
2348
|
+
console.error(
|
|
2349
|
+
`Error disabling free consultation for practitioner ${practitionerId} in clinic ${clinicId}:`,
|
|
2350
|
+
error
|
|
2351
|
+
);
|
|
2352
|
+
throw error;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
}
|