@blackcode_sa/metaestetics-api 1.13.3 → 1.13.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin/index.d.mts +15 -28
- package/dist/admin/index.d.ts +15 -28
- package/dist/index.d.mts +18 -30
- package/dist/index.d.ts +18 -30
- package/dist/index.js +11 -3
- package/dist/index.mjs +11 -3
- package/package.json +121 -119
- package/src/__mocks__/firstore.ts +10 -10
- package/src/admin/aggregation/README.md +79 -79
- package/src/admin/aggregation/appointment/README.md +128 -128
- package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
- package/src/admin/aggregation/appointment/index.ts +1 -1
- package/src/admin/aggregation/clinic/README.md +52 -52
- package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
- package/src/admin/aggregation/clinic/index.ts +1 -1
- package/src/admin/aggregation/forms/README.md +13 -13
- package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
- package/src/admin/aggregation/forms/index.ts +1 -1
- package/src/admin/aggregation/index.ts +8 -8
- package/src/admin/aggregation/patient/README.md +27 -27
- package/src/admin/aggregation/patient/index.ts +1 -1
- package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
- package/src/admin/aggregation/practitioner/README.md +42 -42
- package/src/admin/aggregation/practitioner/index.ts +1 -1
- package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
- package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
- package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
- package/src/admin/aggregation/procedure/README.md +43 -43
- package/src/admin/aggregation/procedure/index.ts +1 -1
- package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
- package/src/admin/aggregation/reviews/index.ts +1 -1
- package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
- package/src/admin/analytics/analytics.admin.service.ts +278 -278
- package/src/admin/analytics/index.ts +2 -2
- package/src/admin/booking/README.md +125 -125
- package/src/admin/booking/booking.admin.ts +1037 -1037
- package/src/admin/booking/booking.calculator.ts +712 -712
- package/src/admin/booking/booking.types.ts +59 -59
- package/src/admin/booking/index.ts +3 -3
- package/src/admin/booking/timezones-problem.md +185 -185
- package/src/admin/calendar/README.md +7 -7
- package/src/admin/calendar/calendar.admin.service.ts +345 -345
- package/src/admin/calendar/index.ts +1 -1
- package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
- package/src/admin/documentation-templates/index.ts +1 -1
- package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
- package/src/admin/free-consultation/index.ts +1 -1
- package/src/admin/index.ts +81 -81
- package/src/admin/logger/index.ts +78 -78
- package/src/admin/mailing/README.md +95 -95
- package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
- package/src/admin/mailing/appointment/index.ts +1 -1
- package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
- package/src/admin/mailing/base.mailing.service.ts +208 -208
- package/src/admin/mailing/index.ts +3 -3
- package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
- package/src/admin/mailing/practitionerInvite/index.ts +2 -2
- package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
- package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
- package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
- package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
- package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
- package/src/admin/notifications/index.ts +1 -1
- package/src/admin/notifications/notifications.admin.ts +710 -710
- package/src/admin/requirements/README.md +128 -128
- package/src/admin/requirements/index.ts +1 -1
- package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
- package/src/admin/users/index.ts +1 -1
- package/src/admin/users/user-profile.admin.ts +405 -405
- package/src/backoffice/constants/certification.constants.ts +13 -13
- package/src/backoffice/constants/index.ts +1 -1
- package/src/backoffice/errors/backoffice.errors.ts +181 -181
- package/src/backoffice/errors/index.ts +1 -1
- package/src/backoffice/expo-safe/README.md +26 -26
- package/src/backoffice/expo-safe/index.ts +41 -41
- package/src/backoffice/index.ts +5 -5
- package/src/backoffice/services/FIXES_README.md +102 -102
- package/src/backoffice/services/README.md +57 -57
- package/src/backoffice/services/analytics.service.proposal.md +863 -863
- package/src/backoffice/services/analytics.service.summary.md +143 -143
- package/src/backoffice/services/brand.service.ts +256 -256
- package/src/backoffice/services/category.service.ts +384 -384
- package/src/backoffice/services/constants.service.ts +385 -385
- package/src/backoffice/services/documentation-template.service.ts +202 -202
- package/src/backoffice/services/index.ts +10 -10
- package/src/backoffice/services/migrate-products.ts +116 -116
- package/src/backoffice/services/product.service.ts +553 -553
- package/src/backoffice/services/requirement.service.ts +235 -235
- package/src/backoffice/services/subcategory.service.ts +461 -461
- package/src/backoffice/services/technology.service.ts +1151 -1151
- package/src/backoffice/types/README.md +12 -12
- package/src/backoffice/types/admin-constants.types.ts +69 -69
- package/src/backoffice/types/brand.types.ts +29 -29
- package/src/backoffice/types/category.types.ts +67 -67
- package/src/backoffice/types/documentation-templates.types.ts +28 -28
- package/src/backoffice/types/index.ts +10 -10
- package/src/backoffice/types/procedure-product.types.ts +38 -38
- package/src/backoffice/types/product.types.ts +240 -240
- package/src/backoffice/types/requirement.types.ts +63 -63
- package/src/backoffice/types/static/README.md +18 -18
- package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
- package/src/backoffice/types/static/certification.types.ts +37 -37
- package/src/backoffice/types/static/contraindication.types.ts +19 -19
- package/src/backoffice/types/static/index.ts +6 -6
- package/src/backoffice/types/static/pricing.types.ts +16 -16
- package/src/backoffice/types/static/procedure-family.types.ts +14 -14
- package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
- package/src/backoffice/types/subcategory.types.ts +34 -34
- package/src/backoffice/types/technology.types.ts +168 -168
- package/src/backoffice/validations/index.ts +1 -1
- package/src/backoffice/validations/schemas.ts +164 -164
- package/src/config/__mocks__/firebase.ts +99 -99
- package/src/config/firebase.ts +78 -78
- package/src/config/index.ts +9 -9
- package/src/errors/auth.error.ts +6 -6
- package/src/errors/auth.errors.ts +200 -200
- package/src/errors/clinic.errors.ts +32 -32
- package/src/errors/firebase.errors.ts +47 -47
- package/src/errors/user.errors.ts +99 -99
- package/src/index.backup.ts +407 -407
- package/src/index.ts +6 -6
- package/src/locales/en.ts +31 -31
- package/src/recommender/admin/index.ts +1 -1
- package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
- package/src/recommender/front/index.ts +1 -1
- package/src/recommender/front/services/onboarding.service.ts +5 -5
- package/src/recommender/front/services/recommender.service.ts +3 -3
- package/src/recommender/index.ts +1 -1
- package/src/services/PATIENTAUTH.MD +197 -197
- package/src/services/README.md +106 -106
- package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
- package/src/services/__tests__/auth/auth.setup.ts +293 -293
- package/src/services/__tests__/auth.service.test.ts +346 -346
- package/src/services/__tests__/base.service.test.ts +77 -77
- package/src/services/__tests__/user.service.test.ts +528 -528
- package/src/services/analytics/ARCHITECTURE.md +199 -199
- package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
- package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
- package/src/services/analytics/QUICK_START.md +393 -393
- package/src/services/analytics/README.md +304 -304
- package/src/services/analytics/SUMMARY.md +141 -141
- package/src/services/analytics/TRENDS.md +380 -380
- package/src/services/analytics/USAGE_GUIDE.md +518 -518
- package/src/services/analytics/analytics-cloud.service.ts +222 -222
- package/src/services/analytics/analytics.service.ts +2142 -2142
- package/src/services/analytics/index.ts +4 -4
- package/src/services/analytics/review-analytics.service.ts +941 -941
- package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
- package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
- package/src/services/analytics/utils/grouping.utils.ts +434 -434
- package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
- package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
- package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
- package/src/services/appointment/README.md +17 -17
- package/src/services/appointment/appointment.service.ts +2558 -2558
- package/src/services/appointment/index.ts +1 -1
- package/src/services/appointment/utils/appointment.utils.ts +552 -552
- package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
- package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
- package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
- package/src/services/appointment/utils/zone-management.utils.ts +353 -353
- package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
- package/src/services/auth/auth.service.ts +989 -989
- package/src/services/auth/auth.v2.service.ts +961 -961
- package/src/services/auth/index.ts +7 -7
- package/src/services/auth/utils/error.utils.ts +90 -90
- package/src/services/auth/utils/firebase.utils.ts +49 -49
- package/src/services/auth/utils/index.ts +21 -21
- package/src/services/auth/utils/practitioner.utils.ts +125 -125
- package/src/services/base.service.ts +41 -41
- package/src/services/calendar/calendar.service.ts +1077 -1077
- package/src/services/calendar/calendar.v2.service.ts +1683 -1683
- package/src/services/calendar/calendar.v3.service.ts +313 -313
- package/src/services/calendar/externalCalendar.service.ts +178 -178
- package/src/services/calendar/index.ts +5 -5
- package/src/services/calendar/synced-calendars.service.ts +743 -743
- package/src/services/calendar/utils/appointment.utils.ts +265 -265
- package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
- package/src/services/calendar/utils/clinic.utils.ts +237 -237
- package/src/services/calendar/utils/docs.utils.ts +157 -157
- package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
- package/src/services/calendar/utils/index.ts +8 -8
- package/src/services/calendar/utils/patient.utils.ts +198 -198
- package/src/services/calendar/utils/practitioner.utils.ts +221 -221
- package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
- package/src/services/clinic/README.md +204 -204
- package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
- package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
- package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
- package/src/services/clinic/billing-transactions.service.ts +217 -217
- package/src/services/clinic/clinic-admin.service.ts +202 -202
- package/src/services/clinic/clinic-group.service.ts +310 -310
- package/src/services/clinic/clinic.service.ts +708 -708
- package/src/services/clinic/index.ts +5 -5
- package/src/services/clinic/practitioner-invite.service.ts +519 -519
- package/src/services/clinic/utils/admin.utils.ts +551 -551
- package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
- package/src/services/clinic/utils/clinic.utils.ts +949 -949
- package/src/services/clinic/utils/filter.utils.d.ts +23 -23
- package/src/services/clinic/utils/filter.utils.ts +446 -446
- package/src/services/clinic/utils/index.ts +11 -11
- package/src/services/clinic/utils/photos.utils.ts +188 -188
- package/src/services/clinic/utils/search.utils.ts +84 -84
- package/src/services/clinic/utils/tag.utils.ts +124 -124
- package/src/services/documentation-templates/documentation-template.service.ts +537 -537
- package/src/services/documentation-templates/filled-document.service.ts +587 -587
- package/src/services/documentation-templates/index.ts +2 -2
- package/src/services/index.ts +14 -14
- package/src/services/media/index.ts +1 -1
- package/src/services/media/media.service.ts +418 -418
- package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
- package/src/services/notifications/index.ts +1 -1
- package/src/services/notifications/notification.service.ts +215 -215
- package/src/services/patient/README.md +48 -48
- package/src/services/patient/To-Do.md +43 -43
- package/src/services/patient/__tests__/patient.service.test.ts +294 -294
- package/src/services/patient/index.ts +2 -2
- package/src/services/patient/patient.service.ts +883 -883
- package/src/services/patient/patientRequirements.service.ts +285 -285
- package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
- package/src/services/patient/utils/clinic.utils.ts +80 -80
- package/src/services/patient/utils/docs.utils.ts +142 -142
- package/src/services/patient/utils/index.ts +9 -9
- package/src/services/patient/utils/location.utils.ts +126 -126
- package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
- package/src/services/patient/utils/medical.utils.ts +458 -458
- package/src/services/patient/utils/practitioner.utils.ts +260 -260
- package/src/services/patient/utils/profile.utils.ts +510 -510
- package/src/services/patient/utils/sensitive.utils.ts +260 -260
- package/src/services/patient/utils/token.utils.ts +211 -211
- package/src/services/practitioner/README.md +145 -145
- package/src/services/practitioner/index.ts +1 -1
- package/src/services/practitioner/practitioner.service.ts +1742 -1742
- package/src/services/procedure/README.md +163 -163
- package/src/services/procedure/index.ts +1 -1
- package/src/services/procedure/procedure.service.ts +2200 -2191
- package/src/services/reviews/index.ts +1 -1
- package/src/services/reviews/reviews.service.ts +734 -734
- package/src/services/user/index.ts +1 -1
- package/src/services/user/user.service.ts +489 -489
- package/src/services/user/user.v2.service.ts +466 -466
- package/src/types/analytics/analytics.types.ts +597 -597
- package/src/types/analytics/grouped-analytics.types.ts +173 -173
- package/src/types/analytics/index.ts +4 -4
- package/src/types/analytics/stored-analytics.types.ts +137 -137
- package/src/types/appointment/index.ts +480 -480
- package/src/types/calendar/index.ts +258 -258
- package/src/types/calendar/synced-calendar.types.ts +66 -66
- package/src/types/clinic/index.ts +498 -489
- package/src/types/clinic/practitioner-invite.types.ts +91 -91
- package/src/types/clinic/preferences.types.ts +159 -159
- package/src/types/clinic/to-do +3 -3
- package/src/types/documentation-templates/index.ts +308 -308
- package/src/types/index.ts +47 -47
- package/src/types/notifications/README.md +77 -77
- package/src/types/notifications/index.ts +286 -286
- package/src/types/patient/aesthetic-analysis.types.ts +66 -66
- package/src/types/patient/allergies.ts +58 -58
- package/src/types/patient/index.ts +275 -275
- package/src/types/patient/medical-info.types.ts +152 -152
- package/src/types/patient/patient-requirements.ts +92 -92
- package/src/types/patient/token.types.ts +61 -61
- package/src/types/practitioner/index.ts +206 -206
- package/src/types/procedure/index.ts +181 -181
- package/src/types/profile/index.ts +39 -39
- package/src/types/reviews/index.ts +132 -132
- package/src/types/tz-lookup.d.ts +4 -4
- package/src/types/user/index.ts +38 -38
- package/src/utils/TIMESTAMPS.md +176 -176
- package/src/utils/TimestampUtils.ts +241 -241
- package/src/utils/index.ts +1 -1
- package/src/validations/appointment.schema.ts +574 -574
- package/src/validations/calendar.schema.ts +225 -225
- package/src/validations/clinic.schema.ts +494 -493
- package/src/validations/common.schema.ts +25 -25
- package/src/validations/documentation-templates/index.ts +1 -1
- package/src/validations/documentation-templates/template.schema.ts +220 -220
- package/src/validations/documentation-templates.schema.ts +10 -10
- package/src/validations/index.ts +20 -20
- package/src/validations/media.schema.ts +10 -10
- package/src/validations/notification.schema.ts +90 -90
- package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
- package/src/validations/patient/medical-info.schema.ts +125 -125
- package/src/validations/patient/patient-requirements.schema.ts +84 -84
- package/src/validations/patient/token.schema.ts +29 -29
- package/src/validations/patient.schema.ts +217 -217
- package/src/validations/practitioner.schema.ts +222 -222
- package/src/validations/procedure-product.schema.ts +41 -41
- package/src/validations/procedure.schema.ts +124 -124
- package/src/validations/profile-info.schema.ts +41 -41
- package/src/validations/reviews.schema.ts +195 -195
- package/src/validations/schemas.ts +104 -104
- package/src/validations/shared.schema.ts +78 -78
|
@@ -1,1742 +1,1742 @@
|
|
|
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 {
|
|
23
|
-
Practitioner,
|
|
24
|
-
CreatePractitionerData,
|
|
25
|
-
UpdatePractitionerData,
|
|
26
|
-
PRACTITIONERS_COLLECTION,
|
|
27
|
-
REGISTER_TOKENS_COLLECTION,
|
|
28
|
-
PractitionerStatus,
|
|
29
|
-
CreateDraftPractitionerData,
|
|
30
|
-
PractitionerToken,
|
|
31
|
-
CreatePractitionerTokenData,
|
|
32
|
-
PractitionerTokenStatus,
|
|
33
|
-
PractitionerBasicInfo,
|
|
34
|
-
} from "../../types/practitioner";
|
|
35
|
-
import { ProcedureSummaryInfo } from "../../types/procedure";
|
|
36
|
-
import { ClinicService } from "../clinic/clinic.service";
|
|
37
|
-
import {
|
|
38
|
-
MediaService,
|
|
39
|
-
MediaAccessLevel,
|
|
40
|
-
MediaResource,
|
|
41
|
-
} from "../media/media.service";
|
|
42
|
-
import {
|
|
43
|
-
practitionerSchema,
|
|
44
|
-
createPractitionerSchema,
|
|
45
|
-
createDraftPractitionerSchema,
|
|
46
|
-
practitionerTokenSchema,
|
|
47
|
-
createPractitionerTokenSchema,
|
|
48
|
-
} from "../../validations/practitioner.schema";
|
|
49
|
-
import { z } from "zod";
|
|
50
|
-
import { Auth } from "firebase/auth";
|
|
51
|
-
import { Firestore } from "firebase/firestore";
|
|
52
|
-
import { FirebaseApp } from "firebase/app";
|
|
53
|
-
import { PractitionerReviewInfo } from "../../types/reviews";
|
|
54
|
-
import { distanceBetween } from "geofire-common";
|
|
55
|
-
import { CertificationSpecialty } from "../../backoffice/types/static/certification.types";
|
|
56
|
-
import { Clinic, DoctorInfo, CLINICS_COLLECTION } from "../../types/clinic";
|
|
57
|
-
import { ClinicInfo } from "../../types/profile";
|
|
58
|
-
import { ProcedureService } from "../procedure/procedure.service";
|
|
59
|
-
import { ProcedureFamily } from "../../backoffice/types/static/procedure-family.types";
|
|
60
|
-
import {
|
|
61
|
-
Currency,
|
|
62
|
-
PricingMeasure,
|
|
63
|
-
} from "../../backoffice/types/static/pricing.types";
|
|
64
|
-
import { CreateProcedureData } from "../../types/procedure";
|
|
65
|
-
|
|
66
|
-
export class PractitionerService extends BaseService {
|
|
67
|
-
private clinicService?: ClinicService;
|
|
68
|
-
private mediaService: MediaService;
|
|
69
|
-
private procedureService?: ProcedureService;
|
|
70
|
-
|
|
71
|
-
constructor(
|
|
72
|
-
db: Firestore,
|
|
73
|
-
auth: Auth,
|
|
74
|
-
app: FirebaseApp,
|
|
75
|
-
clinicService?: ClinicService,
|
|
76
|
-
procedureService?: ProcedureService
|
|
77
|
-
) {
|
|
78
|
-
super(db, auth, app);
|
|
79
|
-
this.clinicService = clinicService;
|
|
80
|
-
this.procedureService = procedureService;
|
|
81
|
-
this.mediaService = new MediaService(db, auth, app);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private getClinicService(): ClinicService {
|
|
85
|
-
if (!this.clinicService) {
|
|
86
|
-
throw new Error("Clinic service not initialized!");
|
|
87
|
-
}
|
|
88
|
-
return this.clinicService;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
private getProcedureService(): ProcedureService {
|
|
92
|
-
if (!this.procedureService) {
|
|
93
|
-
throw new Error("Procedure service not initialized!");
|
|
94
|
-
}
|
|
95
|
-
return this.procedureService;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
setClinicService(clinicService: ClinicService): void {
|
|
99
|
-
this.clinicService = clinicService;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
setProcedureService(procedureService: ProcedureService): void {
|
|
103
|
-
this.procedureService = procedureService;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Handles profile photo upload for practitioners
|
|
108
|
-
* @param profilePhoto - MediaResource (File, Blob, or URL string)
|
|
109
|
-
* @param practitionerId - ID of the practitioner
|
|
110
|
-
* @returns URL string of the uploaded or existing photo
|
|
111
|
-
*/
|
|
112
|
-
private async handleProfilePhotoUpload(
|
|
113
|
-
profilePhoto: MediaResource | undefined | null,
|
|
114
|
-
practitionerId: string
|
|
115
|
-
): Promise<string | undefined> {
|
|
116
|
-
if (!profilePhoto) {
|
|
117
|
-
return undefined;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// If it's already a URL string, return it as is
|
|
121
|
-
if (typeof profilePhoto === "string") {
|
|
122
|
-
return profilePhoto;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// If it's a File or Blob, upload it
|
|
126
|
-
if (profilePhoto instanceof File || profilePhoto instanceof Blob) {
|
|
127
|
-
console.log(
|
|
128
|
-
`[PractitionerService] Uploading profile photo for practitioner ${practitionerId}`
|
|
129
|
-
);
|
|
130
|
-
|
|
131
|
-
const mediaMetadata = await this.mediaService.uploadMedia(
|
|
132
|
-
profilePhoto,
|
|
133
|
-
practitionerId, // Using practitionerId as ownerId
|
|
134
|
-
MediaAccessLevel.PUBLIC, // Profile photos should be public
|
|
135
|
-
"practitioner_profile_photos",
|
|
136
|
-
profilePhoto instanceof File
|
|
137
|
-
? profilePhoto.name
|
|
138
|
-
: `profile_photo_${practitionerId}`
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
return mediaMetadata.url;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return undefined;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Processes BasicPractitionerInfo to handle profile photo uploads
|
|
149
|
-
* @param basicInfo - The basic info containing potential MediaResource profile photo
|
|
150
|
-
* @param practitionerId - ID of the practitioner
|
|
151
|
-
* @returns Processed basic info with URL string for profileImageUrl
|
|
152
|
-
*/
|
|
153
|
-
private async processBasicInfo(
|
|
154
|
-
basicInfo: PractitionerBasicInfo & {
|
|
155
|
-
profileImageUrl?: MediaResource | null;
|
|
156
|
-
},
|
|
157
|
-
practitionerId: string
|
|
158
|
-
): Promise<PractitionerBasicInfo> {
|
|
159
|
-
const processedBasicInfo = { ...basicInfo };
|
|
160
|
-
|
|
161
|
-
// Handle profile photo upload if needed
|
|
162
|
-
if (basicInfo.profileImageUrl) {
|
|
163
|
-
const uploadedUrl = await this.handleProfilePhotoUpload(
|
|
164
|
-
basicInfo.profileImageUrl,
|
|
165
|
-
practitionerId
|
|
166
|
-
);
|
|
167
|
-
processedBasicInfo.profileImageUrl = uploadedUrl;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
return processedBasicInfo;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Creates a new practitioner
|
|
175
|
-
*/
|
|
176
|
-
async createPractitioner(
|
|
177
|
-
data: CreatePractitionerData
|
|
178
|
-
): Promise<Practitioner> {
|
|
179
|
-
try {
|
|
180
|
-
const validData = createPractitionerSchema.parse(data);
|
|
181
|
-
const practitionerId = this.generateId();
|
|
182
|
-
|
|
183
|
-
// Default review info
|
|
184
|
-
const reviewInfo: PractitionerReviewInfo = {
|
|
185
|
-
totalReviews: 0,
|
|
186
|
-
averageRating: 0,
|
|
187
|
-
knowledgeAndExpertise: 0,
|
|
188
|
-
communicationSkills: 0,
|
|
189
|
-
bedSideManner: 0,
|
|
190
|
-
thoroughness: 0,
|
|
191
|
-
trustworthiness: 0,
|
|
192
|
-
recommendationPercentage: 0,
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
// Create practitioner object
|
|
196
|
-
const fullNameLower =
|
|
197
|
-
`${validData.basicInfo.firstName} ${validData.basicInfo.lastName}`.toLowerCase();
|
|
198
|
-
const practitioner: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
199
|
-
createdAt: FieldValue;
|
|
200
|
-
updatedAt: FieldValue;
|
|
201
|
-
} = {
|
|
202
|
-
id: practitionerId,
|
|
203
|
-
userRef: validData.userRef,
|
|
204
|
-
basicInfo: await this.processBasicInfo(
|
|
205
|
-
validData.basicInfo,
|
|
206
|
-
practitionerId
|
|
207
|
-
),
|
|
208
|
-
fullNameLower: fullNameLower, // Ensure this is present
|
|
209
|
-
certification: validData.certification,
|
|
210
|
-
clinics: validData.clinics || [],
|
|
211
|
-
clinicWorkingHours: validData.clinicWorkingHours || [],
|
|
212
|
-
clinicsInfo: [],
|
|
213
|
-
procedures: [],
|
|
214
|
-
proceduresInfo: [],
|
|
215
|
-
reviewInfo,
|
|
216
|
-
isActive: validData.isActive !== undefined ? validData.isActive : true,
|
|
217
|
-
isVerified:
|
|
218
|
-
validData.isVerified !== undefined ? validData.isVerified : false,
|
|
219
|
-
status: validData.status || PractitionerStatus.ACTIVE,
|
|
220
|
-
createdAt: serverTimestamp(),
|
|
221
|
-
updatedAt: serverTimestamp(),
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
// Validate the entire object
|
|
225
|
-
practitionerSchema.parse({
|
|
226
|
-
...practitioner,
|
|
227
|
-
createdAt: Timestamp.now(),
|
|
228
|
-
updatedAt: Timestamp.now(),
|
|
229
|
-
});
|
|
230
|
-
|
|
231
|
-
// Create practitioner document
|
|
232
|
-
const practitionerRef = doc(
|
|
233
|
-
this.db,
|
|
234
|
-
PRACTITIONERS_COLLECTION,
|
|
235
|
-
practitionerId
|
|
236
|
-
);
|
|
237
|
-
|
|
238
|
-
await setDoc(practitionerRef, practitioner);
|
|
239
|
-
|
|
240
|
-
// Return the created practitioner
|
|
241
|
-
const createdPractitioner = await this.getPractitioner(practitionerId);
|
|
242
|
-
if (!createdPractitioner) {
|
|
243
|
-
throw new Error(
|
|
244
|
-
`Failed to retrieve created practitioner ${practitionerId}`
|
|
245
|
-
);
|
|
246
|
-
}
|
|
247
|
-
return createdPractitioner;
|
|
248
|
-
} catch (error) {
|
|
249
|
-
if (error instanceof z.ZodError) {
|
|
250
|
-
throw new Error(`Invalid practitioner data: ${error.message}`);
|
|
251
|
-
}
|
|
252
|
-
console.error("Error creating practitioner:", error);
|
|
253
|
-
throw error;
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* Kreira novi draft profil zdravstvenog radnika bez povezanog korisnika
|
|
259
|
-
* Koristi se od strane administratora klinike za kreiranje profila i kasnije pozivanje
|
|
260
|
-
* @param data Podaci za kreiranje draft profila
|
|
261
|
-
* @param createdBy ID administratora koji kreira profil
|
|
262
|
-
* @param clinicId ID klinike za koju se kreira profil
|
|
263
|
-
* @returns Objekt koji sadrži kreirani draft profil i token za registraciju
|
|
264
|
-
*/
|
|
265
|
-
async createDraftPractitioner(
|
|
266
|
-
data: CreateDraftPractitionerData,
|
|
267
|
-
createdBy: string,
|
|
268
|
-
clinicId: string
|
|
269
|
-
): Promise<{ practitioner: Practitioner; token: PractitionerToken }> {
|
|
270
|
-
try {
|
|
271
|
-
// Validacija ulaznih podataka
|
|
272
|
-
const validatedData = createDraftPractitionerSchema.parse(data);
|
|
273
|
-
|
|
274
|
-
// Provera da li klinika postoji
|
|
275
|
-
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
276
|
-
if (!clinic) {
|
|
277
|
-
throw new Error(`Clinic ${clinicId} not found`);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Make sure the primary clinic (clinicId) is always included
|
|
281
|
-
// Merge the clinics array with the primary clinicId, avoiding duplicates
|
|
282
|
-
const clinicsToAdd = new Set<string>([clinicId]);
|
|
283
|
-
|
|
284
|
-
// Add additional clinics if provided
|
|
285
|
-
if (data.clinics && data.clinics.length > 0) {
|
|
286
|
-
for (const cId of data.clinics) {
|
|
287
|
-
// Verify each additional clinic exists
|
|
288
|
-
if (cId !== clinicId) {
|
|
289
|
-
// Skip checking the primary clinic again
|
|
290
|
-
const otherClinic = await this.getClinicService().getClinic(cId);
|
|
291
|
-
if (!otherClinic) {
|
|
292
|
-
throw new Error(`Clinic ${cId} not found`);
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
clinicsToAdd.add(cId);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Convert Set to Array
|
|
300
|
-
const clinics = Array.from(clinicsToAdd);
|
|
301
|
-
|
|
302
|
-
// Initialize default review info for new practitioners
|
|
303
|
-
const defaultReviewInfo: PractitionerReviewInfo = {
|
|
304
|
-
totalReviews: 0,
|
|
305
|
-
averageRating: 0,
|
|
306
|
-
knowledgeAndExpertise: 0,
|
|
307
|
-
communicationSkills: 0,
|
|
308
|
-
bedSideManner: 0,
|
|
309
|
-
thoroughness: 0,
|
|
310
|
-
trustworthiness: 0,
|
|
311
|
-
recommendationPercentage: 0,
|
|
312
|
-
};
|
|
313
|
-
|
|
314
|
-
// Generate ID for the new practitioner
|
|
315
|
-
const practitionerId = this.generateId();
|
|
316
|
-
|
|
317
|
-
// Create clinicsInfo from the merged clinics array
|
|
318
|
-
const clinicsInfo: ClinicInfo[] = [];
|
|
319
|
-
|
|
320
|
-
// Populate clinicsInfo for each clinic
|
|
321
|
-
for (const cId of clinics) {
|
|
322
|
-
const clinicData = await this.getClinicService().getClinic(cId);
|
|
323
|
-
if (clinicData) {
|
|
324
|
-
// Ensure we're creating a ClinicInfo object that matches the interface structure
|
|
325
|
-
clinicsInfo.push({
|
|
326
|
-
id: clinicData.id,
|
|
327
|
-
name: clinicData.name,
|
|
328
|
-
location: clinicData.location,
|
|
329
|
-
contactInfo: clinicData.contactInfo,
|
|
330
|
-
// Make sure we're using the right property for featuredPhoto
|
|
331
|
-
featuredPhoto:
|
|
332
|
-
clinicData.featuredPhotos && clinicData.featuredPhotos.length > 0
|
|
333
|
-
? typeof clinicData.featuredPhotos[0] === "string"
|
|
334
|
-
? clinicData.featuredPhotos[0]
|
|
335
|
-
: ""
|
|
336
|
-
: (typeof clinicData.coverPhoto === "string"
|
|
337
|
-
? clinicData.coverPhoto
|
|
338
|
-
: "") || "",
|
|
339
|
-
description: clinicData.description || null,
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// Use provided clinicsInfo if available, otherwise use the ones we just created
|
|
345
|
-
const finalClinicsInfo =
|
|
346
|
-
validatedData.clinicsInfo && validatedData.clinicsInfo.length > 0
|
|
347
|
-
? validatedData.clinicsInfo
|
|
348
|
-
: clinicsInfo;
|
|
349
|
-
|
|
350
|
-
const proceduresInfo: ProcedureSummaryInfo[] = [];
|
|
351
|
-
|
|
352
|
-
// Add fullNameLower for draft
|
|
353
|
-
const fullNameLowerDraft =
|
|
354
|
-
`${validatedData.basicInfo.firstName} ${validatedData.basicInfo.lastName}`.toLowerCase();
|
|
355
|
-
const practitionerData: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
356
|
-
createdAt: ReturnType<typeof serverTimestamp>;
|
|
357
|
-
updatedAt: ReturnType<typeof serverTimestamp>;
|
|
358
|
-
} = {
|
|
359
|
-
id: practitionerId,
|
|
360
|
-
userRef: "", // Prazno - biće popunjeno kada korisnik kreira nalog
|
|
361
|
-
basicInfo: await this.processBasicInfo(
|
|
362
|
-
validatedData.basicInfo,
|
|
363
|
-
practitionerId
|
|
364
|
-
),
|
|
365
|
-
fullNameLower: fullNameLowerDraft, // Ensure this is present
|
|
366
|
-
certification: validatedData.certification,
|
|
367
|
-
clinics: clinics,
|
|
368
|
-
clinicWorkingHours: validatedData.clinicWorkingHours || [],
|
|
369
|
-
clinicsInfo: finalClinicsInfo,
|
|
370
|
-
procedures: [],
|
|
371
|
-
proceduresInfo: proceduresInfo,
|
|
372
|
-
reviewInfo: defaultReviewInfo,
|
|
373
|
-
isActive:
|
|
374
|
-
validatedData.isActive !== undefined ? validatedData.isActive : false,
|
|
375
|
-
isVerified:
|
|
376
|
-
validatedData.isVerified !== undefined
|
|
377
|
-
? validatedData.isVerified
|
|
378
|
-
: false,
|
|
379
|
-
status: PractitionerStatus.DRAFT,
|
|
380
|
-
createdAt: serverTimestamp(),
|
|
381
|
-
updatedAt: serverTimestamp(),
|
|
382
|
-
};
|
|
383
|
-
|
|
384
|
-
// Validacija kompletnog objekta
|
|
385
|
-
// Koristimo privremeni userRef za validaciju, biće prazan u bazi
|
|
386
|
-
practitionerSchema.parse({
|
|
387
|
-
...practitionerData,
|
|
388
|
-
userRef: "temp-for-validation",
|
|
389
|
-
createdAt: Timestamp.now(),
|
|
390
|
-
updatedAt: Timestamp.now(),
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
// Čuvamo u Firestore
|
|
394
|
-
await setDoc(
|
|
395
|
-
doc(this.db, PRACTITIONERS_COLLECTION, practitionerData.id),
|
|
396
|
-
practitionerData
|
|
397
|
-
);
|
|
398
|
-
|
|
399
|
-
const savedPractitioner = await this.getPractitioner(practitionerData.id);
|
|
400
|
-
if (!savedPractitioner) {
|
|
401
|
-
throw new Error("Failed to create draft practitioner profile");
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// Automatski kreiramo token za registraciju
|
|
405
|
-
const tokenString = this.generateId().slice(0, 6).toUpperCase();
|
|
406
|
-
|
|
407
|
-
// Default expiration is 7 days from now
|
|
408
|
-
const expiration = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
409
|
-
|
|
410
|
-
const token: PractitionerToken = {
|
|
411
|
-
id: this.generateId(),
|
|
412
|
-
token: tokenString,
|
|
413
|
-
practitionerId: practitionerId,
|
|
414
|
-
email: practitionerData.basicInfo.email,
|
|
415
|
-
clinicId: clinicId,
|
|
416
|
-
status: PractitionerTokenStatus.ACTIVE,
|
|
417
|
-
createdBy: createdBy,
|
|
418
|
-
createdAt: Timestamp.now(),
|
|
419
|
-
expiresAt: Timestamp.fromDate(expiration),
|
|
420
|
-
};
|
|
421
|
-
|
|
422
|
-
// Validate token object
|
|
423
|
-
practitionerTokenSchema.parse(token);
|
|
424
|
-
|
|
425
|
-
// Store the token in the practitioner document's register_tokens subcollection
|
|
426
|
-
const tokenPath = `${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
|
|
427
|
-
await setDoc(doc(this.db, tokenPath), token);
|
|
428
|
-
|
|
429
|
-
// Ovde bi bilo slanje emaila sa tokenom, ali to ćemo implementirati kasnije
|
|
430
|
-
// TODO: Implement email sending with Cloud Functions
|
|
431
|
-
|
|
432
|
-
return { practitioner: savedPractitioner, token };
|
|
433
|
-
} catch (error) {
|
|
434
|
-
if (error instanceof z.ZodError) {
|
|
435
|
-
throw new Error("Invalid practitioner data: " + error.message);
|
|
436
|
-
}
|
|
437
|
-
throw error;
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* Creates a token for inviting practitioner to claim their profile
|
|
443
|
-
* @param data Data for creating token
|
|
444
|
-
* @param createdBy ID of the user creating the token
|
|
445
|
-
* @returns Created token
|
|
446
|
-
*/
|
|
447
|
-
async createPractitionerToken(
|
|
448
|
-
data: CreatePractitionerTokenData,
|
|
449
|
-
createdBy: string
|
|
450
|
-
): Promise<PractitionerToken> {
|
|
451
|
-
try {
|
|
452
|
-
// Validate data
|
|
453
|
-
const validatedData = createPractitionerTokenSchema.parse(data);
|
|
454
|
-
|
|
455
|
-
// Check if practitioner exists and is in DRAFT status
|
|
456
|
-
const practitioner = await this.getPractitioner(
|
|
457
|
-
validatedData.practitionerId
|
|
458
|
-
);
|
|
459
|
-
if (!practitioner) {
|
|
460
|
-
throw new Error("Practitioner not found");
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
464
|
-
throw new Error(
|
|
465
|
-
"Can only create tokens for practitioners in DRAFT status"
|
|
466
|
-
);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
// Check if clinic exists and practitioner belongs to it
|
|
470
|
-
const clinic = await this.getClinicService().getClinic(
|
|
471
|
-
validatedData.clinicId
|
|
472
|
-
);
|
|
473
|
-
if (!clinic) {
|
|
474
|
-
throw new Error(`Clinic ${validatedData.clinicId} not found`);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
if (!practitioner.clinics.includes(validatedData.clinicId)) {
|
|
478
|
-
throw new Error("Practitioner is not associated with this clinic");
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
// Default expiration is 7 days from now if not specified
|
|
482
|
-
const expiration =
|
|
483
|
-
validatedData.expiresAt ||
|
|
484
|
-
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
485
|
-
|
|
486
|
-
// Generate a token (6 characters) using generateId from BaseService
|
|
487
|
-
const tokenString = this.generateId().slice(0, 6).toUpperCase();
|
|
488
|
-
|
|
489
|
-
const token: PractitionerToken = {
|
|
490
|
-
id: this.generateId(),
|
|
491
|
-
token: tokenString,
|
|
492
|
-
practitionerId: validatedData.practitionerId,
|
|
493
|
-
email: validatedData.email,
|
|
494
|
-
clinicId: validatedData.clinicId,
|
|
495
|
-
status: PractitionerTokenStatus.ACTIVE,
|
|
496
|
-
createdBy: createdBy,
|
|
497
|
-
createdAt: Timestamp.now(),
|
|
498
|
-
expiresAt: Timestamp.fromDate(expiration),
|
|
499
|
-
};
|
|
500
|
-
|
|
501
|
-
// Validate token object
|
|
502
|
-
practitionerTokenSchema.parse(token);
|
|
503
|
-
|
|
504
|
-
// Store the token in the practitioner document's register_tokens subcollection
|
|
505
|
-
const tokenPath = `${PRACTITIONERS_COLLECTION}/${validatedData.practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
|
|
506
|
-
await setDoc(doc(this.db, tokenPath), token);
|
|
507
|
-
|
|
508
|
-
return token;
|
|
509
|
-
} catch (error) {
|
|
510
|
-
if (error instanceof z.ZodError) {
|
|
511
|
-
throw new Error("Invalid token data: " + error.message);
|
|
512
|
-
}
|
|
513
|
-
throw error;
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
|
|
517
|
-
/**
|
|
518
|
-
* Gets active tokens for a practitioner
|
|
519
|
-
* @param practitionerId ID of the practitioner
|
|
520
|
-
* @returns Array of active tokens
|
|
521
|
-
*/
|
|
522
|
-
async getPractitionerActiveTokens(
|
|
523
|
-
practitionerId: string
|
|
524
|
-
): Promise<PractitionerToken[]> {
|
|
525
|
-
const tokensRef = collection(
|
|
526
|
-
this.db,
|
|
527
|
-
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
|
|
528
|
-
);
|
|
529
|
-
|
|
530
|
-
const q = query(
|
|
531
|
-
tokensRef,
|
|
532
|
-
where("status", "==", PractitionerTokenStatus.ACTIVE),
|
|
533
|
-
where("expiresAt", ">", Timestamp.now())
|
|
534
|
-
);
|
|
535
|
-
|
|
536
|
-
const querySnapshot = await getDocs(q);
|
|
537
|
-
return querySnapshot.docs.map((doc) => doc.data() as PractitionerToken);
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Gets a token by its string value and validates it
|
|
542
|
-
* @param tokenString The token string to find
|
|
543
|
-
* @returns The token if found and valid, null otherwise
|
|
544
|
-
*/
|
|
545
|
-
async validateToken(tokenString: string): Promise<PractitionerToken | null> {
|
|
546
|
-
// We need to search through all practitioners' register_tokens subcollections
|
|
547
|
-
const practitionersRef = collection(this.db, PRACTITIONERS_COLLECTION);
|
|
548
|
-
const practitionersSnapshot = await getDocs(practitionersRef);
|
|
549
|
-
|
|
550
|
-
for (const practitionerDoc of practitionersSnapshot.docs) {
|
|
551
|
-
const practitionerId = practitionerDoc.id;
|
|
552
|
-
const tokensRef = collection(
|
|
553
|
-
this.db,
|
|
554
|
-
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
|
|
555
|
-
);
|
|
556
|
-
|
|
557
|
-
console.log(
|
|
558
|
-
`[PRACTITIONER] Validating token for practitioner ${practitionerId}`,
|
|
559
|
-
{
|
|
560
|
-
tokenString,
|
|
561
|
-
timestamp: Timestamp.now().toDate(),
|
|
562
|
-
}
|
|
563
|
-
);
|
|
564
|
-
|
|
565
|
-
const q = query(
|
|
566
|
-
tokensRef,
|
|
567
|
-
where("token", "==", tokenString),
|
|
568
|
-
where("status", "==", PractitionerTokenStatus.ACTIVE),
|
|
569
|
-
where("expiresAt", ">", Timestamp.now())
|
|
570
|
-
);
|
|
571
|
-
|
|
572
|
-
try {
|
|
573
|
-
const tokenSnapshot = await getDocs(q);
|
|
574
|
-
console.log(
|
|
575
|
-
`[PRACTITIONER] Token query results for practitioner ${practitionerId}`,
|
|
576
|
-
{
|
|
577
|
-
found: !tokenSnapshot.empty,
|
|
578
|
-
count: tokenSnapshot.size,
|
|
579
|
-
}
|
|
580
|
-
);
|
|
581
|
-
|
|
582
|
-
if (!tokenSnapshot.empty) {
|
|
583
|
-
const tokenData = tokenSnapshot.docs[0].data() as PractitionerToken;
|
|
584
|
-
console.log(`[PRACTITIONER] Valid token found`, {
|
|
585
|
-
tokenId: tokenData.id,
|
|
586
|
-
expiresAt: tokenData.expiresAt.toDate(),
|
|
587
|
-
});
|
|
588
|
-
return tokenData;
|
|
589
|
-
}
|
|
590
|
-
} catch (error) {
|
|
591
|
-
console.error(
|
|
592
|
-
`[PRACTITIONER] Error validating token for practitioner ${practitionerId}:`,
|
|
593
|
-
error
|
|
594
|
-
);
|
|
595
|
-
// Re-throw the error to be handled by the caller
|
|
596
|
-
throw error;
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
return null;
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/**
|
|
604
|
-
* Marks a token as used
|
|
605
|
-
* @param tokenId ID of the token
|
|
606
|
-
* @param practitionerId ID of the practitioner
|
|
607
|
-
* @param userId ID of the user using the token
|
|
608
|
-
*/
|
|
609
|
-
async markTokenAsUsed(
|
|
610
|
-
tokenId: string,
|
|
611
|
-
practitionerId: string,
|
|
612
|
-
userId: string
|
|
613
|
-
): Promise<void> {
|
|
614
|
-
const tokenRef = doc(
|
|
615
|
-
this.db,
|
|
616
|
-
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${tokenId}`
|
|
617
|
-
);
|
|
618
|
-
|
|
619
|
-
await updateDoc(tokenRef, {
|
|
620
|
-
status: PractitionerTokenStatus.USED,
|
|
621
|
-
usedBy: userId,
|
|
622
|
-
usedAt: Timestamp.now(),
|
|
623
|
-
});
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/**
|
|
627
|
-
* Dohvata zdravstvenog radnika po ID-u
|
|
628
|
-
*/
|
|
629
|
-
async getPractitioner(practitionerId: string): Promise<Practitioner | null> {
|
|
630
|
-
const practitionerDoc = await getDoc(
|
|
631
|
-
doc(this.db, PRACTITIONERS_COLLECTION, practitionerId)
|
|
632
|
-
);
|
|
633
|
-
|
|
634
|
-
if (!practitionerDoc.exists()) {
|
|
635
|
-
return null;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
return practitionerDoc.data() as Practitioner;
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
/**
|
|
642
|
-
* Dohvata zdravstvenog radnika po User ID-u
|
|
643
|
-
*/
|
|
644
|
-
async getPractitionerByUserRef(
|
|
645
|
-
userRef: string
|
|
646
|
-
): Promise<Practitioner | null> {
|
|
647
|
-
const q = query(
|
|
648
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
649
|
-
where("userRef", "==", userRef)
|
|
650
|
-
);
|
|
651
|
-
|
|
652
|
-
const querySnapshot = await getDocs(q);
|
|
653
|
-
if (querySnapshot.empty) {
|
|
654
|
-
return null;
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
return querySnapshot.docs[0].data() as Practitioner;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/**
|
|
661
|
-
* Dohvata sve zdravstvene radnike za određenu kliniku sa statusom ACTIVE
|
|
662
|
-
*/
|
|
663
|
-
async getPractitionersByClinic(clinicId: string): Promise<Practitioner[]> {
|
|
664
|
-
const q = query(
|
|
665
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
666
|
-
where("clinics", "array-contains", clinicId),
|
|
667
|
-
where("isActive", "==", true),
|
|
668
|
-
where("status", "==", PractitionerStatus.ACTIVE)
|
|
669
|
-
);
|
|
670
|
-
|
|
671
|
-
const querySnapshot = await getDocs(q);
|
|
672
|
-
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
/**
|
|
676
|
-
* Dohvata sve zdravstvene radnike za određenu kliniku
|
|
677
|
-
*/
|
|
678
|
-
async getAllPractitionersByClinic(clinicId: string): Promise<Practitioner[]> {
|
|
679
|
-
const q = query(
|
|
680
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
681
|
-
where("clinics", "array-contains", clinicId),
|
|
682
|
-
where("isActive", "==", true)
|
|
683
|
-
);
|
|
684
|
-
|
|
685
|
-
const querySnapshot = await getDocs(q);
|
|
686
|
-
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* Dohvata sve draft zdravstvene radnike za određenu kliniku sa statusom DRAFT
|
|
691
|
-
*/
|
|
692
|
-
async getDraftPractitionersByClinic(
|
|
693
|
-
clinicId: string
|
|
694
|
-
): Promise<Practitioner[]> {
|
|
695
|
-
const q = query(
|
|
696
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
697
|
-
where("clinics", "array-contains", clinicId),
|
|
698
|
-
where("status", "==", PractitionerStatus.DRAFT)
|
|
699
|
-
);
|
|
700
|
-
|
|
701
|
-
const querySnapshot = await getDocs(q);
|
|
702
|
-
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
/**
|
|
706
|
-
* Updates a practitioner
|
|
707
|
-
*/
|
|
708
|
-
async updatePractitioner(
|
|
709
|
-
practitionerId: string,
|
|
710
|
-
data: UpdatePractitionerData
|
|
711
|
-
): Promise<Practitioner> {
|
|
712
|
-
try {
|
|
713
|
-
// Validate update data
|
|
714
|
-
const validData = data; // Using the passed data directly as it's already validated by the schema type
|
|
715
|
-
|
|
716
|
-
// Get current practitioner data
|
|
717
|
-
const practitionerRef = doc(
|
|
718
|
-
this.db,
|
|
719
|
-
PRACTITIONERS_COLLECTION,
|
|
720
|
-
practitionerId
|
|
721
|
-
);
|
|
722
|
-
const practitionerDoc = await getDoc(practitionerRef);
|
|
723
|
-
|
|
724
|
-
if (!practitionerDoc.exists()) {
|
|
725
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
const currentPractitioner = practitionerDoc.data() as Practitioner;
|
|
729
|
-
|
|
730
|
-
// Process basicInfo if it's being updated to handle profile photo uploads
|
|
731
|
-
let processedData: UpdatePractitionerData & { fullNameLower?: string } = {
|
|
732
|
-
...validData,
|
|
733
|
-
};
|
|
734
|
-
if (validData.basicInfo) {
|
|
735
|
-
processedData.basicInfo = await this.processBasicInfo(
|
|
736
|
-
validData.basicInfo as PractitionerBasicInfo & {
|
|
737
|
-
profileImageUrl?: MediaResource | null;
|
|
738
|
-
},
|
|
739
|
-
practitionerId
|
|
740
|
-
);
|
|
741
|
-
// Always update fullNameLower when basicInfo changes
|
|
742
|
-
processedData.fullNameLower =
|
|
743
|
-
`${processedData.basicInfo.firstName} ${processedData.basicInfo.lastName}`.toLowerCase();
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// Prepare update data
|
|
747
|
-
const updateData: any = {
|
|
748
|
-
...processedData,
|
|
749
|
-
updatedAt: serverTimestamp(),
|
|
750
|
-
};
|
|
751
|
-
|
|
752
|
-
// Update practitioner
|
|
753
|
-
await updateDoc(practitionerRef, updateData);
|
|
754
|
-
|
|
755
|
-
// Return updated practitioner
|
|
756
|
-
const updatedPractitioner = await this.getPractitioner(practitionerId);
|
|
757
|
-
if (!updatedPractitioner) {
|
|
758
|
-
throw new Error(
|
|
759
|
-
`Failed to retrieve updated practitioner ${practitionerId}`
|
|
760
|
-
);
|
|
761
|
-
}
|
|
762
|
-
return updatedPractitioner;
|
|
763
|
-
} catch (error) {
|
|
764
|
-
if (error instanceof z.ZodError) {
|
|
765
|
-
throw new Error(`Invalid practitioner update data: ${error.message}`);
|
|
766
|
-
}
|
|
767
|
-
console.error(`Error updating practitioner ${practitionerId}:`, error);
|
|
768
|
-
throw error;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
|
|
772
|
-
/**
|
|
773
|
-
* Adds a clinic to a practitioner
|
|
774
|
-
*/
|
|
775
|
-
async addClinic(practitionerId: string, clinicId: string): Promise<void> {
|
|
776
|
-
try {
|
|
777
|
-
// Get practitioner
|
|
778
|
-
const practitionerRef = doc(
|
|
779
|
-
this.db,
|
|
780
|
-
PRACTITIONERS_COLLECTION,
|
|
781
|
-
practitionerId
|
|
782
|
-
);
|
|
783
|
-
const practitionerDoc = await getDoc(practitionerRef);
|
|
784
|
-
|
|
785
|
-
if (!practitionerDoc.exists()) {
|
|
786
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
const practitioner = practitionerDoc.data() as Practitioner;
|
|
790
|
-
|
|
791
|
-
// Check if clinic already added
|
|
792
|
-
if (practitioner.clinics?.includes(clinicId)) {
|
|
793
|
-
console.log(
|
|
794
|
-
`Clinic ${clinicId} already added to practitioner ${practitionerId}`
|
|
795
|
-
);
|
|
796
|
-
return;
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// Add clinic to clinics array
|
|
800
|
-
await updateDoc(practitionerRef, {
|
|
801
|
-
clinics: arrayUnion(clinicId),
|
|
802
|
-
updatedAt: serverTimestamp(),
|
|
803
|
-
});
|
|
804
|
-
} catch (error) {
|
|
805
|
-
console.error(
|
|
806
|
-
`Error adding clinic ${clinicId} to practitioner ${practitionerId}:`,
|
|
807
|
-
error
|
|
808
|
-
);
|
|
809
|
-
throw error;
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
/**
|
|
814
|
-
* Removes a clinic from a practitioner
|
|
815
|
-
*/
|
|
816
|
-
async removeClinic(practitionerId: string, clinicId: string): Promise<void> {
|
|
817
|
-
try {
|
|
818
|
-
// Get practitioner
|
|
819
|
-
const practitionerRef = doc(
|
|
820
|
-
this.db,
|
|
821
|
-
PRACTITIONERS_COLLECTION,
|
|
822
|
-
practitionerId
|
|
823
|
-
);
|
|
824
|
-
const practitionerDoc = await getDoc(practitionerRef);
|
|
825
|
-
|
|
826
|
-
if (!practitionerDoc.exists()) {
|
|
827
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
828
|
-
}
|
|
829
|
-
|
|
830
|
-
// Remove clinic from clinics array
|
|
831
|
-
await updateDoc(practitionerRef, {
|
|
832
|
-
clinics: arrayRemove(clinicId),
|
|
833
|
-
updatedAt: serverTimestamp(),
|
|
834
|
-
});
|
|
835
|
-
} catch (error) {
|
|
836
|
-
console.error(
|
|
837
|
-
`Error removing clinic ${clinicId} from practitioner ${practitionerId}:`,
|
|
838
|
-
error
|
|
839
|
-
);
|
|
840
|
-
throw error;
|
|
841
|
-
}
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
/**
|
|
845
|
-
* Deaktivira profil zdravstvenog radnika
|
|
846
|
-
*/
|
|
847
|
-
async deactivatePractitioner(practitionerId: string): Promise<void> {
|
|
848
|
-
await this.updatePractitioner(practitionerId, {
|
|
849
|
-
isActive: false,
|
|
850
|
-
});
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/**
|
|
854
|
-
* Aktivira profil zdravstvenog radnika
|
|
855
|
-
*/
|
|
856
|
-
async activatePractitioner(practitionerId: string): Promise<void> {
|
|
857
|
-
await this.updatePractitioner(practitionerId, {
|
|
858
|
-
isActive: true,
|
|
859
|
-
});
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
/**
|
|
863
|
-
* Briše profil zdravstvenog radnika
|
|
864
|
-
*/
|
|
865
|
-
async deletePractitioner(practitionerId: string): Promise<void> {
|
|
866
|
-
const practitioner = await this.getPractitioner(practitionerId);
|
|
867
|
-
if (!practitioner) {
|
|
868
|
-
throw new Error("Practitioner not found");
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
// TODO: Kada implementiramo subkolekcije, ovde ćemo dodati brisanje povezanih podataka
|
|
872
|
-
|
|
873
|
-
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId));
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
/**
|
|
877
|
-
* Validates a registration token and claims the associated draft practitioner profile
|
|
878
|
-
* @param tokenString The token provided by the practitioner
|
|
879
|
-
* @param userId The ID of the user claiming the profile
|
|
880
|
-
* @returns The claimed practitioner profile or null if token is invalid
|
|
881
|
-
*/
|
|
882
|
-
async validateTokenAndClaimProfile(
|
|
883
|
-
tokenString: string,
|
|
884
|
-
userId: string
|
|
885
|
-
): Promise<Practitioner | null> {
|
|
886
|
-
// Find the token
|
|
887
|
-
console.log("[PRACTITIONER] Validating token for claiming profile", {
|
|
888
|
-
tokenString,
|
|
889
|
-
userId,
|
|
890
|
-
});
|
|
891
|
-
|
|
892
|
-
const token = await this.validateToken(tokenString);
|
|
893
|
-
|
|
894
|
-
if (!token) {
|
|
895
|
-
console.log(
|
|
896
|
-
"[PRACTITIONER] Token validation failed - token not found or not valid",
|
|
897
|
-
{
|
|
898
|
-
tokenString,
|
|
899
|
-
}
|
|
900
|
-
);
|
|
901
|
-
return null; // Token not found or not valid
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
console.log("[PRACTITIONER] Token successfully validated", {
|
|
905
|
-
tokenId: token.id,
|
|
906
|
-
practitionerId: token.practitionerId,
|
|
907
|
-
});
|
|
908
|
-
|
|
909
|
-
// Get the practitioner profile
|
|
910
|
-
const practitioner = await this.getPractitioner(token.practitionerId);
|
|
911
|
-
if (!practitioner) {
|
|
912
|
-
console.log("[PRACTITIONER] Practitioner not found", {
|
|
913
|
-
practitionerId: token.practitionerId,
|
|
914
|
-
});
|
|
915
|
-
return null; // Practitioner not found
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
// Ensure practitioner is in DRAFT status
|
|
919
|
-
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
920
|
-
console.log("[PRACTITIONER] Practitioner status is not DRAFT", {
|
|
921
|
-
practitionerId: practitioner.id,
|
|
922
|
-
status: practitioner.status,
|
|
923
|
-
});
|
|
924
|
-
throw new Error("This practitioner profile has already been claimed");
|
|
925
|
-
}
|
|
926
|
-
|
|
927
|
-
// Check if user already has a practitioner profile
|
|
928
|
-
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
929
|
-
if (existingPractitioner) {
|
|
930
|
-
throw new Error("User already has a practitioner profile");
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
// Claim the profile by linking it to the user
|
|
934
|
-
const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
|
|
935
|
-
userRef: userId,
|
|
936
|
-
status: PractitionerStatus.ACTIVE,
|
|
937
|
-
});
|
|
938
|
-
|
|
939
|
-
// Mark the token as used
|
|
940
|
-
await this.markTokenAsUsed(token.id, token.practitionerId, userId);
|
|
941
|
-
|
|
942
|
-
console.log("[PRACTITIONER] Profile claimed successfully", {
|
|
943
|
-
practitionerId: updatedPractitioner.id,
|
|
944
|
-
userId,
|
|
945
|
-
});
|
|
946
|
-
|
|
947
|
-
return updatedPractitioner;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/**
|
|
951
|
-
* Retrieves all practitioners with optional pagination and draft inclusion
|
|
952
|
-
*
|
|
953
|
-
* @param options - Search options
|
|
954
|
-
* @param options.pagination - Optional limit for number of results per page
|
|
955
|
-
* @param options.lastDoc - Optional last document for pagination
|
|
956
|
-
* @param options.includeDraftPractitioners - Whether to include draft practitioners
|
|
957
|
-
* @returns Array of practitioners and the last document for pagination
|
|
958
|
-
*/
|
|
959
|
-
async getAllPractitioners(options?: {
|
|
960
|
-
pagination?: number;
|
|
961
|
-
lastDoc?: any;
|
|
962
|
-
includeDraftPractitioners?: boolean;
|
|
963
|
-
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
964
|
-
try {
|
|
965
|
-
const constraints = [];
|
|
966
|
-
|
|
967
|
-
// Filter by status if not including drafts
|
|
968
|
-
if (!options?.includeDraftPractitioners) {
|
|
969
|
-
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
// Add ordering for consistent pagination
|
|
973
|
-
constraints.push(orderBy("basicInfo.lastName", "asc"));
|
|
974
|
-
constraints.push(orderBy("basicInfo.firstName", "asc"));
|
|
975
|
-
|
|
976
|
-
// Add pagination if specified
|
|
977
|
-
if (options?.pagination && options.pagination > 0) {
|
|
978
|
-
if (options.lastDoc) {
|
|
979
|
-
constraints.push(startAfter(options.lastDoc));
|
|
980
|
-
}
|
|
981
|
-
constraints.push(limit(options.pagination));
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
const q = query(
|
|
985
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
986
|
-
...constraints
|
|
987
|
-
);
|
|
988
|
-
|
|
989
|
-
const querySnapshot = await getDocs(q);
|
|
990
|
-
|
|
991
|
-
const practitioners = querySnapshot.docs.map(
|
|
992
|
-
(doc) => doc.data() as Practitioner
|
|
993
|
-
);
|
|
994
|
-
|
|
995
|
-
// Get last document for pagination
|
|
996
|
-
const lastDoc =
|
|
997
|
-
querySnapshot.docs.length > 0
|
|
998
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
999
|
-
: null;
|
|
1000
|
-
|
|
1001
|
-
return {
|
|
1002
|
-
practitioners,
|
|
1003
|
-
lastDoc,
|
|
1004
|
-
};
|
|
1005
|
-
} catch (error) {
|
|
1006
|
-
console.error(
|
|
1007
|
-
"[PRACTITIONER_SERVICE] Error getting all practitioners:",
|
|
1008
|
-
error
|
|
1009
|
-
);
|
|
1010
|
-
throw error;
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
/**
|
|
1015
|
-
* Searches and filters practitioners based on multiple criteria
|
|
1016
|
-
*
|
|
1017
|
-
* @param filters - Various filters to apply
|
|
1018
|
-
* @param filters.nameSearch - Optional search text for first/last name
|
|
1019
|
-
* @param filters.certifications - Optional array of certifications to filter by
|
|
1020
|
-
* @param filters.specialties - Optional array of specialties to filter by
|
|
1021
|
-
* @param filters.procedureFamily - Optional procedure family practitioners provide
|
|
1022
|
-
* @param filters.procedureCategory - Optional procedure category practitioners provide
|
|
1023
|
-
* @param filters.procedureSubcategory - Optional procedure subcategory practitioners provide
|
|
1024
|
-
* @param filters.procedureTechnology - Optional procedure technology practitioners provide
|
|
1025
|
-
* @param filters.location - Optional location for distance-based search
|
|
1026
|
-
* @param filters.radiusInKm - Optional radius in kilometers (required if location is provided)
|
|
1027
|
-
* @param filters.minRating - Optional minimum rating (0-5)
|
|
1028
|
-
* @param filters.maxRating - Optional maximum rating (0-5)
|
|
1029
|
-
* @param filters.pagination - Optional number of results per page
|
|
1030
|
-
* @param filters.lastDoc - Optional last document for pagination
|
|
1031
|
-
* @param filters.includeDraftPractitioners - Whether to include draft practitioners
|
|
1032
|
-
* @returns Filtered practitioners and the last document for pagination
|
|
1033
|
-
*/
|
|
1034
|
-
async getPractitionersByFilters(filters: {
|
|
1035
|
-
nameSearch?: string;
|
|
1036
|
-
certifications?: string[];
|
|
1037
|
-
specialties?: CertificationSpecialty[];
|
|
1038
|
-
procedureFamily?: string;
|
|
1039
|
-
procedureCategory?: string;
|
|
1040
|
-
procedureSubcategory?: string;
|
|
1041
|
-
procedureTechnology?: string;
|
|
1042
|
-
location?: { latitude: number; longitude: number };
|
|
1043
|
-
radiusInKm?: number;
|
|
1044
|
-
minRating?: number;
|
|
1045
|
-
maxRating?: number;
|
|
1046
|
-
pagination?: number;
|
|
1047
|
-
lastDoc?: any;
|
|
1048
|
-
includeDraftPractitioners?: boolean;
|
|
1049
|
-
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
1050
|
-
try {
|
|
1051
|
-
console.log(
|
|
1052
|
-
"[PRACTITIONER_SERVICE] Starting practitioner filtering with fallback strategies"
|
|
1053
|
-
);
|
|
1054
|
-
|
|
1055
|
-
// Geo query debug i validacija
|
|
1056
|
-
if (filters.location && filters.radiusInKm) {
|
|
1057
|
-
console.log("[PRACTITIONER_SERVICE] Executing geo query:", {
|
|
1058
|
-
location: filters.location,
|
|
1059
|
-
radius: filters.radiusInKm,
|
|
1060
|
-
serviceName: "PractitionerService",
|
|
1061
|
-
});
|
|
1062
|
-
|
|
1063
|
-
// Validacija location podataka
|
|
1064
|
-
if (!filters.location.latitude || !filters.location.longitude) {
|
|
1065
|
-
console.warn(
|
|
1066
|
-
"[PRACTITIONER_SERVICE] Invalid location data:",
|
|
1067
|
-
filters.location
|
|
1068
|
-
);
|
|
1069
|
-
filters.location = undefined;
|
|
1070
|
-
filters.radiusInKm = undefined;
|
|
1071
|
-
}
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
// Strategy 1: Try fullNameLower search if nameSearch exists
|
|
1075
|
-
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1076
|
-
try {
|
|
1077
|
-
console.log(
|
|
1078
|
-
"[PRACTITIONER_SERVICE] Strategy 1: Trying fullNameLower search"
|
|
1079
|
-
);
|
|
1080
|
-
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1081
|
-
const constraints: any[] = [];
|
|
1082
|
-
|
|
1083
|
-
if (!filters.includeDraftPractitioners) {
|
|
1084
|
-
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1085
|
-
}
|
|
1086
|
-
constraints.push(where("isActive", "==", true));
|
|
1087
|
-
constraints.push(where("fullNameLower", ">=", searchTerm));
|
|
1088
|
-
constraints.push(where("fullNameLower", "<=", searchTerm + "\uf8ff"));
|
|
1089
|
-
constraints.push(orderBy("fullNameLower"));
|
|
1090
|
-
|
|
1091
|
-
if (filters.lastDoc) {
|
|
1092
|
-
if (typeof filters.lastDoc.data === "function") {
|
|
1093
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1094
|
-
} else if (Array.isArray(filters.lastDoc)) {
|
|
1095
|
-
constraints.push(startAfter(...filters.lastDoc));
|
|
1096
|
-
} else {
|
|
1097
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1098
|
-
}
|
|
1099
|
-
}
|
|
1100
|
-
constraints.push(limit(filters.pagination || 10));
|
|
1101
|
-
|
|
1102
|
-
const q = query(
|
|
1103
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1104
|
-
...constraints
|
|
1105
|
-
);
|
|
1106
|
-
const querySnapshot = await getDocs(q);
|
|
1107
|
-
const practitioners = querySnapshot.docs.map(
|
|
1108
|
-
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1109
|
-
);
|
|
1110
|
-
const lastDoc =
|
|
1111
|
-
querySnapshot.docs.length > 0
|
|
1112
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1113
|
-
: null;
|
|
1114
|
-
|
|
1115
|
-
console.log(
|
|
1116
|
-
`[PRACTITIONER_SERVICE] Strategy 1 success: ${practitioners.length} practitioners`
|
|
1117
|
-
);
|
|
1118
|
-
|
|
1119
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1120
|
-
if (practitioners.length < (filters.pagination || 10)) {
|
|
1121
|
-
return { practitioners, lastDoc: null };
|
|
1122
|
-
}
|
|
1123
|
-
return { practitioners, lastDoc };
|
|
1124
|
-
} catch (error) {
|
|
1125
|
-
console.log("[PRACTITIONER_SERVICE] Strategy 1 failed:", error);
|
|
1126
|
-
}
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
// Strategy 2: Basic query with createdAt ordering (no name search)
|
|
1130
|
-
try {
|
|
1131
|
-
console.log(
|
|
1132
|
-
"[PRACTITIONER_SERVICE] Strategy 2: Basic query with createdAt ordering"
|
|
1133
|
-
);
|
|
1134
|
-
const constraints: any[] = [];
|
|
1135
|
-
|
|
1136
|
-
if (!filters.includeDraftPractitioners) {
|
|
1137
|
-
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1138
|
-
}
|
|
1139
|
-
constraints.push(where("isActive", "==", true));
|
|
1140
|
-
|
|
1141
|
-
// Add other filters that work well with Firestore
|
|
1142
|
-
if (filters.certifications && filters.certifications.length > 0) {
|
|
1143
|
-
const certificationsToMatch =
|
|
1144
|
-
filters.certifications as CertificationSpecialty[];
|
|
1145
|
-
constraints.push(
|
|
1146
|
-
where(
|
|
1147
|
-
"certification.specialties",
|
|
1148
|
-
"array-contains-any",
|
|
1149
|
-
certificationsToMatch
|
|
1150
|
-
)
|
|
1151
|
-
);
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
if (filters.minRating !== undefined) {
|
|
1155
|
-
constraints.push(
|
|
1156
|
-
where("reviewInfo.averageRating", ">=", filters.minRating)
|
|
1157
|
-
);
|
|
1158
|
-
}
|
|
1159
|
-
if (filters.maxRating !== undefined) {
|
|
1160
|
-
constraints.push(
|
|
1161
|
-
where("reviewInfo.averageRating", "<=", filters.maxRating)
|
|
1162
|
-
);
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
constraints.push(orderBy("createdAt", "desc"));
|
|
1166
|
-
|
|
1167
|
-
// Pagination sa createdAt - poboljšano za geo queries
|
|
1168
|
-
if (filters.location && filters.radiusInKm) {
|
|
1169
|
-
// Ne koristiti lastDoc za geo queries, već preuzmi više rezultata
|
|
1170
|
-
constraints.push(limit((filters.pagination || 10) * 2)); // Dvostruko više za geo filter
|
|
1171
|
-
} else {
|
|
1172
|
-
if (filters.lastDoc) {
|
|
1173
|
-
if (typeof filters.lastDoc.data === "function") {
|
|
1174
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1175
|
-
} else if (Array.isArray(filters.lastDoc)) {
|
|
1176
|
-
constraints.push(startAfter(...filters.lastDoc));
|
|
1177
|
-
} else {
|
|
1178
|
-
constraints.push(startAfter(filters.lastDoc));
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
constraints.push(limit(filters.pagination || 10));
|
|
1182
|
-
}
|
|
1183
|
-
|
|
1184
|
-
const q = query(
|
|
1185
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1186
|
-
...constraints
|
|
1187
|
-
);
|
|
1188
|
-
const querySnapshot = await getDocs(q);
|
|
1189
|
-
let practitioners = querySnapshot.docs.map(
|
|
1190
|
-
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1191
|
-
);
|
|
1192
|
-
|
|
1193
|
-
// Apply geo filter if needed (this is the only in-memory filter we keep)
|
|
1194
|
-
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1195
|
-
const location = filters.location;
|
|
1196
|
-
const radiusInKm = filters.radiusInKm;
|
|
1197
|
-
practitioners = practitioners.filter((practitioner) => {
|
|
1198
|
-
const clinics = practitioner.clinicsInfo || [];
|
|
1199
|
-
return clinics.some((clinic) => {
|
|
1200
|
-
const distance = distanceBetween(
|
|
1201
|
-
[location.latitude, location.longitude],
|
|
1202
|
-
[clinic.location.latitude, clinic.location.longitude]
|
|
1203
|
-
);
|
|
1204
|
-
const distanceInKm = distance / 1000;
|
|
1205
|
-
return distanceInKm <= radiusInKm;
|
|
1206
|
-
});
|
|
1207
|
-
});
|
|
1208
|
-
|
|
1209
|
-
// Ograniči na pagination broj nakon geo filtera
|
|
1210
|
-
practitioners = practitioners.slice(0, filters.pagination || 10);
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
// Apply all remaining client-side filters using centralized function
|
|
1214
|
-
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1215
|
-
|
|
1216
|
-
const lastDoc =
|
|
1217
|
-
querySnapshot.docs.length > 0
|
|
1218
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1219
|
-
: null;
|
|
1220
|
-
console.log(
|
|
1221
|
-
`[PRACTITIONER_SERVICE] Strategy 2 success: ${practitioners.length} practitioners`
|
|
1222
|
-
);
|
|
1223
|
-
|
|
1224
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1225
|
-
if (practitioners.length < (filters.pagination || 10)) {
|
|
1226
|
-
return { practitioners, lastDoc: null };
|
|
1227
|
-
}
|
|
1228
|
-
return { practitioners, lastDoc };
|
|
1229
|
-
} catch (error) {
|
|
1230
|
-
console.log("[PRACTITIONER_SERVICE] Strategy 2 failed:", error);
|
|
1231
|
-
}
|
|
1232
|
-
|
|
1233
|
-
// Strategy 3: Minimal query fallback
|
|
1234
|
-
try {
|
|
1235
|
-
console.log(
|
|
1236
|
-
"[PRACTITIONER_SERVICE] Strategy 3: Minimal query fallback"
|
|
1237
|
-
);
|
|
1238
|
-
const constraints: any[] = [
|
|
1239
|
-
where("isActive", "==", true),
|
|
1240
|
-
orderBy("createdAt", "desc"),
|
|
1241
|
-
limit(filters.pagination || 10),
|
|
1242
|
-
];
|
|
1243
|
-
|
|
1244
|
-
const q = query(
|
|
1245
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1246
|
-
...constraints
|
|
1247
|
-
);
|
|
1248
|
-
const querySnapshot = await getDocs(q);
|
|
1249
|
-
let practitioners = querySnapshot.docs.map(
|
|
1250
|
-
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1251
|
-
);
|
|
1252
|
-
|
|
1253
|
-
// Apply all client-side filters using centralized function
|
|
1254
|
-
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1255
|
-
|
|
1256
|
-
const lastDoc =
|
|
1257
|
-
querySnapshot.docs.length > 0
|
|
1258
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1259
|
-
: null;
|
|
1260
|
-
console.log(
|
|
1261
|
-
`[PRACTITIONER_SERVICE] Strategy 3 success: ${practitioners.length} practitioners`
|
|
1262
|
-
);
|
|
1263
|
-
|
|
1264
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1265
|
-
if (practitioners.length < (filters.pagination || 10)) {
|
|
1266
|
-
return { practitioners, lastDoc: null };
|
|
1267
|
-
}
|
|
1268
|
-
return { practitioners, lastDoc };
|
|
1269
|
-
} catch (error) {
|
|
1270
|
-
console.log("[PRACTITIONER_SERVICE] Strategy 3 failed:", error);
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
// Strategy 4: Client-side filtering fallback (kao u procedure/clinic services)
|
|
1274
|
-
try {
|
|
1275
|
-
console.log(
|
|
1276
|
-
"[PRACTITIONER_SERVICE] Strategy 4: Client-side filtering fallback"
|
|
1277
|
-
);
|
|
1278
|
-
|
|
1279
|
-
const constraints: any[] = [
|
|
1280
|
-
where("isActive", "==", true),
|
|
1281
|
-
where("status", "==", PractitionerStatus.ACTIVE),
|
|
1282
|
-
orderBy("createdAt", "desc"),
|
|
1283
|
-
limit(filters.pagination || 10),
|
|
1284
|
-
];
|
|
1285
|
-
|
|
1286
|
-
const q = query(
|
|
1287
|
-
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1288
|
-
...constraints
|
|
1289
|
-
);
|
|
1290
|
-
const querySnapshot = await getDocs(q);
|
|
1291
|
-
let practitioners = querySnapshot.docs.map(
|
|
1292
|
-
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1293
|
-
);
|
|
1294
|
-
|
|
1295
|
-
// Apply all client-side filters using centralized function
|
|
1296
|
-
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1297
|
-
|
|
1298
|
-
const lastDoc =
|
|
1299
|
-
querySnapshot.docs.length > 0
|
|
1300
|
-
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1301
|
-
: null;
|
|
1302
|
-
console.log(
|
|
1303
|
-
`[PRACTITIONER_SERVICE] Strategy 4 success: ${practitioners.length} practitioners`
|
|
1304
|
-
);
|
|
1305
|
-
|
|
1306
|
-
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1307
|
-
if (practitioners.length < (filters.pagination || 10)) {
|
|
1308
|
-
return { practitioners, lastDoc: null };
|
|
1309
|
-
}
|
|
1310
|
-
return { practitioners, lastDoc };
|
|
1311
|
-
} catch (error) {
|
|
1312
|
-
console.log("[PRACTITIONER_SERVICE] Strategy 4 failed:", error);
|
|
1313
|
-
}
|
|
1314
|
-
|
|
1315
|
-
// All strategies failed
|
|
1316
|
-
console.log(
|
|
1317
|
-
"[PRACTITIONER_SERVICE] All strategies failed, returning empty result"
|
|
1318
|
-
);
|
|
1319
|
-
return { practitioners: [], lastDoc: null };
|
|
1320
|
-
} catch (error) {
|
|
1321
|
-
console.error(
|
|
1322
|
-
"[PRACTITIONER_SERVICE] Error filtering practitioners:",
|
|
1323
|
-
error
|
|
1324
|
-
);
|
|
1325
|
-
return { practitioners: [], lastDoc: null };
|
|
1326
|
-
}
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
/**
|
|
1330
|
-
* Applies in-memory filters to practitioners array
|
|
1331
|
-
* Used when Firestore queries fail or for complex filtering
|
|
1332
|
-
*/
|
|
1333
|
-
private applyInMemoryFilters(
|
|
1334
|
-
practitioners: Practitioner[],
|
|
1335
|
-
filters: any
|
|
1336
|
-
): Practitioner[] {
|
|
1337
|
-
let filteredPractitioners = [...practitioners]; // Create copy to avoid mutating original
|
|
1338
|
-
|
|
1339
|
-
// Name search filter
|
|
1340
|
-
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1341
|
-
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1342
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1343
|
-
const firstName = (
|
|
1344
|
-
practitioner.basicInfo?.firstName || ""
|
|
1345
|
-
).toLowerCase();
|
|
1346
|
-
const lastName = (practitioner.basicInfo?.lastName || "").toLowerCase();
|
|
1347
|
-
const fullName = `${firstName} ${lastName}`.trim();
|
|
1348
|
-
const fullNameLower = practitioner.fullNameLower || "";
|
|
1349
|
-
|
|
1350
|
-
return (
|
|
1351
|
-
firstName.includes(searchTerm) ||
|
|
1352
|
-
lastName.includes(searchTerm) ||
|
|
1353
|
-
fullName.includes(searchTerm) ||
|
|
1354
|
-
fullNameLower.includes(searchTerm)
|
|
1355
|
-
);
|
|
1356
|
-
});
|
|
1357
|
-
console.log(
|
|
1358
|
-
`[PRACTITIONER_SERVICE] Applied name filter, results: ${filteredPractitioners.length}`
|
|
1359
|
-
);
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
// Certifications filtering
|
|
1363
|
-
if (filters.certifications && filters.certifications.length > 0) {
|
|
1364
|
-
const certificationsToMatch = filters.certifications;
|
|
1365
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1366
|
-
const practitionerCerts = practitioner.certification?.specialties || [];
|
|
1367
|
-
return certificationsToMatch.some((cert: any) =>
|
|
1368
|
-
practitionerCerts.includes(cert as CertificationSpecialty)
|
|
1369
|
-
);
|
|
1370
|
-
});
|
|
1371
|
-
console.log(
|
|
1372
|
-
`[PRACTITIONER_SERVICE] Applied certifications filter, results: ${filteredPractitioners.length}`
|
|
1373
|
-
);
|
|
1374
|
-
}
|
|
1375
|
-
|
|
1376
|
-
// Specialties filtering
|
|
1377
|
-
if (filters.specialties && filters.specialties.length > 0) {
|
|
1378
|
-
const specialtiesToMatch = filters.specialties;
|
|
1379
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1380
|
-
const practitionerSpecs = practitioner.certification?.specialties || [];
|
|
1381
|
-
return specialtiesToMatch.some((spec: any) =>
|
|
1382
|
-
practitionerSpecs.includes(spec)
|
|
1383
|
-
);
|
|
1384
|
-
});
|
|
1385
|
-
console.log(
|
|
1386
|
-
`[PRACTITIONER_SERVICE] Applied specialties filter, results: ${filteredPractitioners.length}`
|
|
1387
|
-
);
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
// Rating filtering
|
|
1391
|
-
if (filters.minRating !== undefined || filters.maxRating !== undefined) {
|
|
1392
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1393
|
-
const rating = practitioner.reviewInfo?.averageRating || 0;
|
|
1394
|
-
if (filters.minRating !== undefined && rating < filters.minRating)
|
|
1395
|
-
return false;
|
|
1396
|
-
if (filters.maxRating !== undefined && rating > filters.maxRating)
|
|
1397
|
-
return false;
|
|
1398
|
-
return true;
|
|
1399
|
-
});
|
|
1400
|
-
console.log(
|
|
1401
|
-
`[PRACTITIONER_SERVICE] Applied rating filter, results: ${filteredPractitioners.length}`
|
|
1402
|
-
);
|
|
1403
|
-
}
|
|
1404
|
-
|
|
1405
|
-
// Procedure family filtering
|
|
1406
|
-
if (filters.procedureFamily) {
|
|
1407
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1408
|
-
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1409
|
-
return proceduresInfo.some(
|
|
1410
|
-
(proc) => proc.family === filters.procedureFamily
|
|
1411
|
-
);
|
|
1412
|
-
});
|
|
1413
|
-
console.log(
|
|
1414
|
-
`[PRACTITIONER_SERVICE] Applied procedure family filter, results: ${filteredPractitioners.length}`
|
|
1415
|
-
);
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
// Procedure category filtering
|
|
1419
|
-
if (filters.procedureCategory) {
|
|
1420
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1421
|
-
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1422
|
-
return proceduresInfo.some(
|
|
1423
|
-
(proc) => proc.categoryName === filters.procedureCategory
|
|
1424
|
-
);
|
|
1425
|
-
});
|
|
1426
|
-
console.log(
|
|
1427
|
-
`[PRACTITIONER_SERVICE] Applied procedure category filter, results: ${filteredPractitioners.length}`
|
|
1428
|
-
);
|
|
1429
|
-
}
|
|
1430
|
-
|
|
1431
|
-
// Procedure subcategory filtering
|
|
1432
|
-
if (filters.procedureSubcategory) {
|
|
1433
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1434
|
-
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1435
|
-
return proceduresInfo.some(
|
|
1436
|
-
(proc) => proc.subcategoryName === filters.procedureSubcategory
|
|
1437
|
-
);
|
|
1438
|
-
});
|
|
1439
|
-
console.log(
|
|
1440
|
-
`[PRACTITIONER_SERVICE] Applied procedure subcategory filter, results: ${filteredPractitioners.length}`
|
|
1441
|
-
);
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
// Procedure technology filtering
|
|
1445
|
-
if (filters.procedureTechnology) {
|
|
1446
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1447
|
-
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1448
|
-
return proceduresInfo.some(
|
|
1449
|
-
(proc) => proc.technologyName === filters.procedureTechnology
|
|
1450
|
-
);
|
|
1451
|
-
});
|
|
1452
|
-
console.log(
|
|
1453
|
-
`[PRACTITIONER_SERVICE] Applied procedure technology filter, results: ${filteredPractitioners.length}`
|
|
1454
|
-
);
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
// Geo-radius filter
|
|
1458
|
-
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1459
|
-
const location = filters.location;
|
|
1460
|
-
const radiusInKm = filters.radiusInKm;
|
|
1461
|
-
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1462
|
-
const clinics = practitioner.clinicsInfo || [];
|
|
1463
|
-
return clinics.some((clinic) => {
|
|
1464
|
-
const distance = distanceBetween(
|
|
1465
|
-
[location.latitude, location.longitude],
|
|
1466
|
-
[clinic.location.latitude, clinic.location.longitude]
|
|
1467
|
-
);
|
|
1468
|
-
const distanceInKm = distance / 1000;
|
|
1469
|
-
return distanceInKm <= radiusInKm;
|
|
1470
|
-
});
|
|
1471
|
-
});
|
|
1472
|
-
console.log(
|
|
1473
|
-
`[PRACTITIONER_SERVICE] Applied geo filter, results: ${filteredPractitioners.length}`
|
|
1474
|
-
);
|
|
1475
|
-
}
|
|
1476
|
-
|
|
1477
|
-
return filteredPractitioners;
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
/**
|
|
1481
|
-
* Enables free consultation for a practitioner in a specific clinic
|
|
1482
|
-
* Creates a free consultation procedure with hardcoded parameters
|
|
1483
|
-
* @param practitionerId - ID of the practitioner
|
|
1484
|
-
* @param clinicId - ID of the clinic
|
|
1485
|
-
* @returns The created consultation procedure
|
|
1486
|
-
*/
|
|
1487
|
-
async EnableFreeConsultation(
|
|
1488
|
-
practitionerId: string,
|
|
1489
|
-
clinicId: string
|
|
1490
|
-
): Promise<void> {
|
|
1491
|
-
try {
|
|
1492
|
-
// First, ensure the free consultation infrastructure exists
|
|
1493
|
-
await this.ensureFreeConsultationInfrastructure();
|
|
1494
|
-
|
|
1495
|
-
// Validate that practitioner exists and is active
|
|
1496
|
-
const practitioner = await this.getPractitioner(practitionerId);
|
|
1497
|
-
if (!practitioner) {
|
|
1498
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1499
|
-
}
|
|
1500
|
-
|
|
1501
|
-
// No need to check for is practitioner active
|
|
1502
|
-
// if (!practitioner.isActive) {
|
|
1503
|
-
// throw new Error(`Practitioner ${practitionerId} is not active`);
|
|
1504
|
-
// }
|
|
1505
|
-
|
|
1506
|
-
// Validate that clinic exists
|
|
1507
|
-
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
1508
|
-
if (!clinic) {
|
|
1509
|
-
throw new Error(`Clinic ${clinicId} not found`);
|
|
1510
|
-
}
|
|
1511
|
-
|
|
1512
|
-
// Check if practitioner is associated with this clinic
|
|
1513
|
-
if (!practitioner.clinics.includes(clinicId)) {
|
|
1514
|
-
throw new Error(
|
|
1515
|
-
`Practitioner ${practitionerId} is not associated with clinic ${clinicId}`
|
|
1516
|
-
);
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
// Get all procedures for this practitioner (including inactive ones)
|
|
1520
|
-
const [activeProcedures, inactiveProcedures] = await Promise.all([
|
|
1521
|
-
this.getProcedureService().getProceduresByPractitioner(practitionerId),
|
|
1522
|
-
this.getProcedureService().getInactiveProceduresByPractitioner(
|
|
1523
|
-
practitionerId
|
|
1524
|
-
),
|
|
1525
|
-
]);
|
|
1526
|
-
|
|
1527
|
-
// Combine active and inactive procedures
|
|
1528
|
-
const allProcedures = [...activeProcedures, ...inactiveProcedures];
|
|
1529
|
-
|
|
1530
|
-
// Check if free consultation already exists (active or inactive)
|
|
1531
|
-
const existingConsultation = allProcedures.find(
|
|
1532
|
-
(procedure) =>
|
|
1533
|
-
procedure.technology.id === "free-consultation-tech" &&
|
|
1534
|
-
procedure.clinicBranchId === clinicId
|
|
1535
|
-
);
|
|
1536
|
-
|
|
1537
|
-
if (existingConsultation) {
|
|
1538
|
-
if (existingConsultation.isActive) {
|
|
1539
|
-
console.log(
|
|
1540
|
-
`Free consultation already active for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1541
|
-
);
|
|
1542
|
-
return;
|
|
1543
|
-
} else {
|
|
1544
|
-
// Reactivate the existing disabled consultation
|
|
1545
|
-
await this.getProcedureService().updateProcedure(
|
|
1546
|
-
existingConsultation.id,
|
|
1547
|
-
{ isActive: true }
|
|
1548
|
-
);
|
|
1549
|
-
console.log(
|
|
1550
|
-
`Reactivated existing free consultation for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1551
|
-
);
|
|
1552
|
-
return;
|
|
1553
|
-
}
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
// Create procedure data for free consultation (without productId or productsMetadata)
|
|
1557
|
-
const consultationData: Omit<CreateProcedureData, "productId"> = {
|
|
1558
|
-
name: "Free Consultation",
|
|
1559
|
-
nameLower: "free consultation",
|
|
1560
|
-
description:
|
|
1561
|
-
"Free initial consultation to discuss treatment options and assess patient needs.",
|
|
1562
|
-
family: ProcedureFamily.AESTHETICS,
|
|
1563
|
-
categoryId: "consultation",
|
|
1564
|
-
subcategoryId: "free-consultation",
|
|
1565
|
-
technologyId: "free-consultation-tech",
|
|
1566
|
-
price: 0,
|
|
1567
|
-
currency: Currency.EUR,
|
|
1568
|
-
pricingMeasure: PricingMeasure.PER_SESSION,
|
|
1569
|
-
// productsMetadata omitted - no products needed for consultations
|
|
1570
|
-
duration: 30, // 30 minutes consultation
|
|
1571
|
-
practitionerId: practitionerId,
|
|
1572
|
-
clinicBranchId: clinicId,
|
|
1573
|
-
photos: [], // No photos for consultation
|
|
1574
|
-
};
|
|
1575
|
-
|
|
1576
|
-
// Create the consultation procedure using the special method
|
|
1577
|
-
await this.getProcedureService().createConsultationProcedure(
|
|
1578
|
-
consultationData
|
|
1579
|
-
);
|
|
1580
|
-
|
|
1581
|
-
console.log(
|
|
1582
|
-
`Free consultation enabled for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1583
|
-
);
|
|
1584
|
-
} catch (error) {
|
|
1585
|
-
console.error(
|
|
1586
|
-
`Error enabling free consultation for practitioner ${practitionerId} in clinic ${clinicId}:`,
|
|
1587
|
-
error
|
|
1588
|
-
);
|
|
1589
|
-
throw error;
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
|
|
1593
|
-
/**
|
|
1594
|
-
* Ensures that the free consultation infrastructure exists by calling the Cloud Function
|
|
1595
|
-
* @returns Promise<boolean> - True if infrastructure exists or was created successfully
|
|
1596
|
-
*/
|
|
1597
|
-
async ensureFreeConsultationInfrastructure(): Promise<boolean> {
|
|
1598
|
-
try {
|
|
1599
|
-
console.log(
|
|
1600
|
-
"[PRACTITIONER_SERVICE] Ensuring free consultation infrastructure via HTTP"
|
|
1601
|
-
);
|
|
1602
|
-
|
|
1603
|
-
// Check if user is authenticated
|
|
1604
|
-
const currentUser = this.auth.currentUser;
|
|
1605
|
-
if (!currentUser) {
|
|
1606
|
-
throw new Error(
|
|
1607
|
-
"User must be authenticated to ensure free consultation infrastructure"
|
|
1608
|
-
);
|
|
1609
|
-
}
|
|
1610
|
-
|
|
1611
|
-
// Construct the function URL for the Express app endpoint
|
|
1612
|
-
const functionUrl = `https://europe-west6-metaestetics.cloudfunctions.net/bookingApi/ensureFreeConsultationInfrastructure`;
|
|
1613
|
-
|
|
1614
|
-
// Get the authenticated user's ID token
|
|
1615
|
-
const idToken = await currentUser.getIdToken();
|
|
1616
|
-
|
|
1617
|
-
console.log(
|
|
1618
|
-
`[PRACTITIONER_SERVICE] Making fetch request to ${functionUrl}`
|
|
1619
|
-
);
|
|
1620
|
-
|
|
1621
|
-
// Make the HTTP request
|
|
1622
|
-
const response = await fetch(functionUrl, {
|
|
1623
|
-
method: "POST",
|
|
1624
|
-
mode: "cors",
|
|
1625
|
-
cache: "no-cache",
|
|
1626
|
-
credentials: "omit",
|
|
1627
|
-
headers: {
|
|
1628
|
-
"Content-Type": "application/json",
|
|
1629
|
-
Authorization: `Bearer ${idToken}`,
|
|
1630
|
-
},
|
|
1631
|
-
redirect: "follow",
|
|
1632
|
-
referrerPolicy: "no-referrer",
|
|
1633
|
-
body: JSON.stringify({}), // Empty body as no parameters needed
|
|
1634
|
-
});
|
|
1635
|
-
|
|
1636
|
-
console.log(
|
|
1637
|
-
`[PRACTITIONER_SERVICE] Received response ${response.status}: ${response.statusText}`
|
|
1638
|
-
);
|
|
1639
|
-
|
|
1640
|
-
// Check if the request was successful
|
|
1641
|
-
if (!response.ok) {
|
|
1642
|
-
const errorText = await response.text();
|
|
1643
|
-
console.error(
|
|
1644
|
-
`[PRACTITIONER_SERVICE] Error response details: ${errorText}`
|
|
1645
|
-
);
|
|
1646
|
-
throw new Error(
|
|
1647
|
-
`Failed to ensure free consultation infrastructure: ${response.status} ${response.statusText} - ${errorText}`
|
|
1648
|
-
);
|
|
1649
|
-
}
|
|
1650
|
-
|
|
1651
|
-
// Parse the response
|
|
1652
|
-
const result = await response.json();
|
|
1653
|
-
console.log(
|
|
1654
|
-
`[PRACTITIONER_SERVICE] Infrastructure check response:`,
|
|
1655
|
-
result
|
|
1656
|
-
);
|
|
1657
|
-
|
|
1658
|
-
if (!result.success) {
|
|
1659
|
-
throw new Error(
|
|
1660
|
-
result.error || "Failed to ensure free consultation infrastructure"
|
|
1661
|
-
);
|
|
1662
|
-
}
|
|
1663
|
-
|
|
1664
|
-
console.log(
|
|
1665
|
-
`[PRACTITIONER_SERVICE] Free consultation infrastructure ensured successfully`
|
|
1666
|
-
);
|
|
1667
|
-
|
|
1668
|
-
return result.infrastructureExists;
|
|
1669
|
-
} catch (error) {
|
|
1670
|
-
console.error(
|
|
1671
|
-
"[PRACTITIONER_SERVICE] Error ensuring free consultation infrastructure:",
|
|
1672
|
-
error
|
|
1673
|
-
);
|
|
1674
|
-
throw error;
|
|
1675
|
-
}
|
|
1676
|
-
}
|
|
1677
|
-
|
|
1678
|
-
/**
|
|
1679
|
-
* Disables free consultation for a practitioner in a specific clinic
|
|
1680
|
-
* Finds and deactivates the existing free consultation procedure
|
|
1681
|
-
* @param practitionerId - ID of the practitioner
|
|
1682
|
-
* @param clinicId - ID of the clinic
|
|
1683
|
-
*/
|
|
1684
|
-
async DisableFreeConsultation(
|
|
1685
|
-
practitionerId: string,
|
|
1686
|
-
clinicId: string
|
|
1687
|
-
): Promise<void> {
|
|
1688
|
-
try {
|
|
1689
|
-
// Validate that practitioner exists
|
|
1690
|
-
const practitioner = await this.getPractitioner(practitionerId);
|
|
1691
|
-
if (!practitioner) {
|
|
1692
|
-
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
// Validate that clinic exists
|
|
1696
|
-
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
1697
|
-
if (!clinic) {
|
|
1698
|
-
throw new Error(`Clinic ${clinicId} not found`);
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
// Check if practitioner is associated with this clinic
|
|
1702
|
-
if (!practitioner.clinics.includes(clinicId)) {
|
|
1703
|
-
throw new Error(
|
|
1704
|
-
`Practitioner ${practitionerId} is not associated with clinic ${clinicId}`
|
|
1705
|
-
);
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
// Find the free consultation procedure for this practitioner in this clinic
|
|
1709
|
-
// Use the more specific search by technology ID instead of name
|
|
1710
|
-
const existingProcedures =
|
|
1711
|
-
await this.getProcedureService().getProceduresByPractitioner(
|
|
1712
|
-
practitionerId
|
|
1713
|
-
);
|
|
1714
|
-
const freeConsultation = existingProcedures.find(
|
|
1715
|
-
(procedure) =>
|
|
1716
|
-
procedure.technology.id === "free-consultation-tech" &&
|
|
1717
|
-
procedure.clinicBranchId === clinicId &&
|
|
1718
|
-
procedure.isActive
|
|
1719
|
-
);
|
|
1720
|
-
|
|
1721
|
-
if (!freeConsultation) {
|
|
1722
|
-
console.log(
|
|
1723
|
-
`No active free consultation found for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1724
|
-
);
|
|
1725
|
-
return;
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
// Deactivate the consultation procedure
|
|
1729
|
-
await this.getProcedureService().deactivateProcedure(freeConsultation.id);
|
|
1730
|
-
|
|
1731
|
-
console.log(
|
|
1732
|
-
`Free consultation disabled for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1733
|
-
);
|
|
1734
|
-
} catch (error) {
|
|
1735
|
-
console.error(
|
|
1736
|
-
`Error disabling free consultation for practitioner ${practitionerId} in clinic ${clinicId}:`,
|
|
1737
|
-
error
|
|
1738
|
-
);
|
|
1739
|
-
throw error;
|
|
1740
|
-
}
|
|
1741
|
-
}
|
|
1742
|
-
}
|
|
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 {
|
|
23
|
+
Practitioner,
|
|
24
|
+
CreatePractitionerData,
|
|
25
|
+
UpdatePractitionerData,
|
|
26
|
+
PRACTITIONERS_COLLECTION,
|
|
27
|
+
REGISTER_TOKENS_COLLECTION,
|
|
28
|
+
PractitionerStatus,
|
|
29
|
+
CreateDraftPractitionerData,
|
|
30
|
+
PractitionerToken,
|
|
31
|
+
CreatePractitionerTokenData,
|
|
32
|
+
PractitionerTokenStatus,
|
|
33
|
+
PractitionerBasicInfo,
|
|
34
|
+
} from "../../types/practitioner";
|
|
35
|
+
import { ProcedureSummaryInfo } from "../../types/procedure";
|
|
36
|
+
import { ClinicService } from "../clinic/clinic.service";
|
|
37
|
+
import {
|
|
38
|
+
MediaService,
|
|
39
|
+
MediaAccessLevel,
|
|
40
|
+
MediaResource,
|
|
41
|
+
} from "../media/media.service";
|
|
42
|
+
import {
|
|
43
|
+
practitionerSchema,
|
|
44
|
+
createPractitionerSchema,
|
|
45
|
+
createDraftPractitionerSchema,
|
|
46
|
+
practitionerTokenSchema,
|
|
47
|
+
createPractitionerTokenSchema,
|
|
48
|
+
} from "../../validations/practitioner.schema";
|
|
49
|
+
import { z } from "zod";
|
|
50
|
+
import { Auth } from "firebase/auth";
|
|
51
|
+
import { Firestore } from "firebase/firestore";
|
|
52
|
+
import { FirebaseApp } from "firebase/app";
|
|
53
|
+
import { PractitionerReviewInfo } from "../../types/reviews";
|
|
54
|
+
import { distanceBetween } from "geofire-common";
|
|
55
|
+
import { CertificationSpecialty } from "../../backoffice/types/static/certification.types";
|
|
56
|
+
import { Clinic, DoctorInfo, CLINICS_COLLECTION } from "../../types/clinic";
|
|
57
|
+
import { ClinicInfo } from "../../types/profile";
|
|
58
|
+
import { ProcedureService } from "../procedure/procedure.service";
|
|
59
|
+
import { ProcedureFamily } from "../../backoffice/types/static/procedure-family.types";
|
|
60
|
+
import {
|
|
61
|
+
Currency,
|
|
62
|
+
PricingMeasure,
|
|
63
|
+
} from "../../backoffice/types/static/pricing.types";
|
|
64
|
+
import { CreateProcedureData } from "../../types/procedure";
|
|
65
|
+
|
|
66
|
+
export class PractitionerService extends BaseService {
|
|
67
|
+
private clinicService?: ClinicService;
|
|
68
|
+
private mediaService: MediaService;
|
|
69
|
+
private procedureService?: ProcedureService;
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
db: Firestore,
|
|
73
|
+
auth: Auth,
|
|
74
|
+
app: FirebaseApp,
|
|
75
|
+
clinicService?: ClinicService,
|
|
76
|
+
procedureService?: ProcedureService
|
|
77
|
+
) {
|
|
78
|
+
super(db, auth, app);
|
|
79
|
+
this.clinicService = clinicService;
|
|
80
|
+
this.procedureService = procedureService;
|
|
81
|
+
this.mediaService = new MediaService(db, auth, app);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private getClinicService(): ClinicService {
|
|
85
|
+
if (!this.clinicService) {
|
|
86
|
+
throw new Error("Clinic service not initialized!");
|
|
87
|
+
}
|
|
88
|
+
return this.clinicService;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private getProcedureService(): ProcedureService {
|
|
92
|
+
if (!this.procedureService) {
|
|
93
|
+
throw new Error("Procedure service not initialized!");
|
|
94
|
+
}
|
|
95
|
+
return this.procedureService;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
setClinicService(clinicService: ClinicService): void {
|
|
99
|
+
this.clinicService = clinicService;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
setProcedureService(procedureService: ProcedureService): void {
|
|
103
|
+
this.procedureService = procedureService;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Handles profile photo upload for practitioners
|
|
108
|
+
* @param profilePhoto - MediaResource (File, Blob, or URL string)
|
|
109
|
+
* @param practitionerId - ID of the practitioner
|
|
110
|
+
* @returns URL string of the uploaded or existing photo
|
|
111
|
+
*/
|
|
112
|
+
private async handleProfilePhotoUpload(
|
|
113
|
+
profilePhoto: MediaResource | undefined | null,
|
|
114
|
+
practitionerId: string
|
|
115
|
+
): Promise<string | undefined> {
|
|
116
|
+
if (!profilePhoto) {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// If it's already a URL string, return it as is
|
|
121
|
+
if (typeof profilePhoto === "string") {
|
|
122
|
+
return profilePhoto;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// If it's a File or Blob, upload it
|
|
126
|
+
if (profilePhoto instanceof File || profilePhoto instanceof Blob) {
|
|
127
|
+
console.log(
|
|
128
|
+
`[PractitionerService] Uploading profile photo for practitioner ${practitionerId}`
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const mediaMetadata = await this.mediaService.uploadMedia(
|
|
132
|
+
profilePhoto,
|
|
133
|
+
practitionerId, // Using practitionerId as ownerId
|
|
134
|
+
MediaAccessLevel.PUBLIC, // Profile photos should be public
|
|
135
|
+
"practitioner_profile_photos",
|
|
136
|
+
profilePhoto instanceof File
|
|
137
|
+
? profilePhoto.name
|
|
138
|
+
: `profile_photo_${practitionerId}`
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return mediaMetadata.url;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Processes BasicPractitionerInfo to handle profile photo uploads
|
|
149
|
+
* @param basicInfo - The basic info containing potential MediaResource profile photo
|
|
150
|
+
* @param practitionerId - ID of the practitioner
|
|
151
|
+
* @returns Processed basic info with URL string for profileImageUrl
|
|
152
|
+
*/
|
|
153
|
+
private async processBasicInfo(
|
|
154
|
+
basicInfo: PractitionerBasicInfo & {
|
|
155
|
+
profileImageUrl?: MediaResource | null;
|
|
156
|
+
},
|
|
157
|
+
practitionerId: string
|
|
158
|
+
): Promise<PractitionerBasicInfo> {
|
|
159
|
+
const processedBasicInfo = { ...basicInfo };
|
|
160
|
+
|
|
161
|
+
// Handle profile photo upload if needed
|
|
162
|
+
if (basicInfo.profileImageUrl) {
|
|
163
|
+
const uploadedUrl = await this.handleProfilePhotoUpload(
|
|
164
|
+
basicInfo.profileImageUrl,
|
|
165
|
+
practitionerId
|
|
166
|
+
);
|
|
167
|
+
processedBasicInfo.profileImageUrl = uploadedUrl;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return processedBasicInfo;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Creates a new practitioner
|
|
175
|
+
*/
|
|
176
|
+
async createPractitioner(
|
|
177
|
+
data: CreatePractitionerData
|
|
178
|
+
): Promise<Practitioner> {
|
|
179
|
+
try {
|
|
180
|
+
const validData = createPractitionerSchema.parse(data);
|
|
181
|
+
const practitionerId = this.generateId();
|
|
182
|
+
|
|
183
|
+
// Default review info
|
|
184
|
+
const reviewInfo: PractitionerReviewInfo = {
|
|
185
|
+
totalReviews: 0,
|
|
186
|
+
averageRating: 0,
|
|
187
|
+
knowledgeAndExpertise: 0,
|
|
188
|
+
communicationSkills: 0,
|
|
189
|
+
bedSideManner: 0,
|
|
190
|
+
thoroughness: 0,
|
|
191
|
+
trustworthiness: 0,
|
|
192
|
+
recommendationPercentage: 0,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Create practitioner object
|
|
196
|
+
const fullNameLower =
|
|
197
|
+
`${validData.basicInfo.firstName} ${validData.basicInfo.lastName}`.toLowerCase();
|
|
198
|
+
const practitioner: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
199
|
+
createdAt: FieldValue;
|
|
200
|
+
updatedAt: FieldValue;
|
|
201
|
+
} = {
|
|
202
|
+
id: practitionerId,
|
|
203
|
+
userRef: validData.userRef,
|
|
204
|
+
basicInfo: await this.processBasicInfo(
|
|
205
|
+
validData.basicInfo,
|
|
206
|
+
practitionerId
|
|
207
|
+
),
|
|
208
|
+
fullNameLower: fullNameLower, // Ensure this is present
|
|
209
|
+
certification: validData.certification,
|
|
210
|
+
clinics: validData.clinics || [],
|
|
211
|
+
clinicWorkingHours: validData.clinicWorkingHours || [],
|
|
212
|
+
clinicsInfo: [],
|
|
213
|
+
procedures: [],
|
|
214
|
+
proceduresInfo: [],
|
|
215
|
+
reviewInfo,
|
|
216
|
+
isActive: validData.isActive !== undefined ? validData.isActive : true,
|
|
217
|
+
isVerified:
|
|
218
|
+
validData.isVerified !== undefined ? validData.isVerified : false,
|
|
219
|
+
status: validData.status || PractitionerStatus.ACTIVE,
|
|
220
|
+
createdAt: serverTimestamp(),
|
|
221
|
+
updatedAt: serverTimestamp(),
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Validate the entire object
|
|
225
|
+
practitionerSchema.parse({
|
|
226
|
+
...practitioner,
|
|
227
|
+
createdAt: Timestamp.now(),
|
|
228
|
+
updatedAt: Timestamp.now(),
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Create practitioner document
|
|
232
|
+
const practitionerRef = doc(
|
|
233
|
+
this.db,
|
|
234
|
+
PRACTITIONERS_COLLECTION,
|
|
235
|
+
practitionerId
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
await setDoc(practitionerRef, practitioner);
|
|
239
|
+
|
|
240
|
+
// Return the created practitioner
|
|
241
|
+
const createdPractitioner = await this.getPractitioner(practitionerId);
|
|
242
|
+
if (!createdPractitioner) {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Failed to retrieve created practitioner ${practitionerId}`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return createdPractitioner;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error instanceof z.ZodError) {
|
|
250
|
+
throw new Error(`Invalid practitioner data: ${error.message}`);
|
|
251
|
+
}
|
|
252
|
+
console.error("Error creating practitioner:", error);
|
|
253
|
+
throw error;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Kreira novi draft profil zdravstvenog radnika bez povezanog korisnika
|
|
259
|
+
* Koristi se od strane administratora klinike za kreiranje profila i kasnije pozivanje
|
|
260
|
+
* @param data Podaci za kreiranje draft profila
|
|
261
|
+
* @param createdBy ID administratora koji kreira profil
|
|
262
|
+
* @param clinicId ID klinike za koju se kreira profil
|
|
263
|
+
* @returns Objekt koji sadrži kreirani draft profil i token za registraciju
|
|
264
|
+
*/
|
|
265
|
+
async createDraftPractitioner(
|
|
266
|
+
data: CreateDraftPractitionerData,
|
|
267
|
+
createdBy: string,
|
|
268
|
+
clinicId: string
|
|
269
|
+
): Promise<{ practitioner: Practitioner; token: PractitionerToken }> {
|
|
270
|
+
try {
|
|
271
|
+
// Validacija ulaznih podataka
|
|
272
|
+
const validatedData = createDraftPractitionerSchema.parse(data);
|
|
273
|
+
|
|
274
|
+
// Provera da li klinika postoji
|
|
275
|
+
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
276
|
+
if (!clinic) {
|
|
277
|
+
throw new Error(`Clinic ${clinicId} not found`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Make sure the primary clinic (clinicId) is always included
|
|
281
|
+
// Merge the clinics array with the primary clinicId, avoiding duplicates
|
|
282
|
+
const clinicsToAdd = new Set<string>([clinicId]);
|
|
283
|
+
|
|
284
|
+
// Add additional clinics if provided
|
|
285
|
+
if (data.clinics && data.clinics.length > 0) {
|
|
286
|
+
for (const cId of data.clinics) {
|
|
287
|
+
// Verify each additional clinic exists
|
|
288
|
+
if (cId !== clinicId) {
|
|
289
|
+
// Skip checking the primary clinic again
|
|
290
|
+
const otherClinic = await this.getClinicService().getClinic(cId);
|
|
291
|
+
if (!otherClinic) {
|
|
292
|
+
throw new Error(`Clinic ${cId} not found`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
clinicsToAdd.add(cId);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Convert Set to Array
|
|
300
|
+
const clinics = Array.from(clinicsToAdd);
|
|
301
|
+
|
|
302
|
+
// Initialize default review info for new practitioners
|
|
303
|
+
const defaultReviewInfo: PractitionerReviewInfo = {
|
|
304
|
+
totalReviews: 0,
|
|
305
|
+
averageRating: 0,
|
|
306
|
+
knowledgeAndExpertise: 0,
|
|
307
|
+
communicationSkills: 0,
|
|
308
|
+
bedSideManner: 0,
|
|
309
|
+
thoroughness: 0,
|
|
310
|
+
trustworthiness: 0,
|
|
311
|
+
recommendationPercentage: 0,
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Generate ID for the new practitioner
|
|
315
|
+
const practitionerId = this.generateId();
|
|
316
|
+
|
|
317
|
+
// Create clinicsInfo from the merged clinics array
|
|
318
|
+
const clinicsInfo: ClinicInfo[] = [];
|
|
319
|
+
|
|
320
|
+
// Populate clinicsInfo for each clinic
|
|
321
|
+
for (const cId of clinics) {
|
|
322
|
+
const clinicData = await this.getClinicService().getClinic(cId);
|
|
323
|
+
if (clinicData) {
|
|
324
|
+
// Ensure we're creating a ClinicInfo object that matches the interface structure
|
|
325
|
+
clinicsInfo.push({
|
|
326
|
+
id: clinicData.id,
|
|
327
|
+
name: clinicData.name,
|
|
328
|
+
location: clinicData.location,
|
|
329
|
+
contactInfo: clinicData.contactInfo,
|
|
330
|
+
// Make sure we're using the right property for featuredPhoto
|
|
331
|
+
featuredPhoto:
|
|
332
|
+
clinicData.featuredPhotos && clinicData.featuredPhotos.length > 0
|
|
333
|
+
? typeof clinicData.featuredPhotos[0] === "string"
|
|
334
|
+
? clinicData.featuredPhotos[0]
|
|
335
|
+
: ""
|
|
336
|
+
: (typeof clinicData.coverPhoto === "string"
|
|
337
|
+
? clinicData.coverPhoto
|
|
338
|
+
: "") || "",
|
|
339
|
+
description: clinicData.description || null,
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Use provided clinicsInfo if available, otherwise use the ones we just created
|
|
345
|
+
const finalClinicsInfo =
|
|
346
|
+
validatedData.clinicsInfo && validatedData.clinicsInfo.length > 0
|
|
347
|
+
? validatedData.clinicsInfo
|
|
348
|
+
: clinicsInfo;
|
|
349
|
+
|
|
350
|
+
const proceduresInfo: ProcedureSummaryInfo[] = [];
|
|
351
|
+
|
|
352
|
+
// Add fullNameLower for draft
|
|
353
|
+
const fullNameLowerDraft =
|
|
354
|
+
`${validatedData.basicInfo.firstName} ${validatedData.basicInfo.lastName}`.toLowerCase();
|
|
355
|
+
const practitionerData: Omit<Practitioner, "createdAt" | "updatedAt"> & {
|
|
356
|
+
createdAt: ReturnType<typeof serverTimestamp>;
|
|
357
|
+
updatedAt: ReturnType<typeof serverTimestamp>;
|
|
358
|
+
} = {
|
|
359
|
+
id: practitionerId,
|
|
360
|
+
userRef: "", // Prazno - biće popunjeno kada korisnik kreira nalog
|
|
361
|
+
basicInfo: await this.processBasicInfo(
|
|
362
|
+
validatedData.basicInfo,
|
|
363
|
+
practitionerId
|
|
364
|
+
),
|
|
365
|
+
fullNameLower: fullNameLowerDraft, // Ensure this is present
|
|
366
|
+
certification: validatedData.certification,
|
|
367
|
+
clinics: clinics,
|
|
368
|
+
clinicWorkingHours: validatedData.clinicWorkingHours || [],
|
|
369
|
+
clinicsInfo: finalClinicsInfo,
|
|
370
|
+
procedures: [],
|
|
371
|
+
proceduresInfo: proceduresInfo,
|
|
372
|
+
reviewInfo: defaultReviewInfo,
|
|
373
|
+
isActive:
|
|
374
|
+
validatedData.isActive !== undefined ? validatedData.isActive : false,
|
|
375
|
+
isVerified:
|
|
376
|
+
validatedData.isVerified !== undefined
|
|
377
|
+
? validatedData.isVerified
|
|
378
|
+
: false,
|
|
379
|
+
status: PractitionerStatus.DRAFT,
|
|
380
|
+
createdAt: serverTimestamp(),
|
|
381
|
+
updatedAt: serverTimestamp(),
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// Validacija kompletnog objekta
|
|
385
|
+
// Koristimo privremeni userRef za validaciju, biće prazan u bazi
|
|
386
|
+
practitionerSchema.parse({
|
|
387
|
+
...practitionerData,
|
|
388
|
+
userRef: "temp-for-validation",
|
|
389
|
+
createdAt: Timestamp.now(),
|
|
390
|
+
updatedAt: Timestamp.now(),
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Čuvamo u Firestore
|
|
394
|
+
await setDoc(
|
|
395
|
+
doc(this.db, PRACTITIONERS_COLLECTION, practitionerData.id),
|
|
396
|
+
practitionerData
|
|
397
|
+
);
|
|
398
|
+
|
|
399
|
+
const savedPractitioner = await this.getPractitioner(practitionerData.id);
|
|
400
|
+
if (!savedPractitioner) {
|
|
401
|
+
throw new Error("Failed to create draft practitioner profile");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Automatski kreiramo token za registraciju
|
|
405
|
+
const tokenString = this.generateId().slice(0, 6).toUpperCase();
|
|
406
|
+
|
|
407
|
+
// Default expiration is 7 days from now
|
|
408
|
+
const expiration = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
409
|
+
|
|
410
|
+
const token: PractitionerToken = {
|
|
411
|
+
id: this.generateId(),
|
|
412
|
+
token: tokenString,
|
|
413
|
+
practitionerId: practitionerId,
|
|
414
|
+
email: practitionerData.basicInfo.email,
|
|
415
|
+
clinicId: clinicId,
|
|
416
|
+
status: PractitionerTokenStatus.ACTIVE,
|
|
417
|
+
createdBy: createdBy,
|
|
418
|
+
createdAt: Timestamp.now(),
|
|
419
|
+
expiresAt: Timestamp.fromDate(expiration),
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// Validate token object
|
|
423
|
+
practitionerTokenSchema.parse(token);
|
|
424
|
+
|
|
425
|
+
// Store the token in the practitioner document's register_tokens subcollection
|
|
426
|
+
const tokenPath = `${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
|
|
427
|
+
await setDoc(doc(this.db, tokenPath), token);
|
|
428
|
+
|
|
429
|
+
// Ovde bi bilo slanje emaila sa tokenom, ali to ćemo implementirati kasnije
|
|
430
|
+
// TODO: Implement email sending with Cloud Functions
|
|
431
|
+
|
|
432
|
+
return { practitioner: savedPractitioner, token };
|
|
433
|
+
} catch (error) {
|
|
434
|
+
if (error instanceof z.ZodError) {
|
|
435
|
+
throw new Error("Invalid practitioner data: " + error.message);
|
|
436
|
+
}
|
|
437
|
+
throw error;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Creates a token for inviting practitioner to claim their profile
|
|
443
|
+
* @param data Data for creating token
|
|
444
|
+
* @param createdBy ID of the user creating the token
|
|
445
|
+
* @returns Created token
|
|
446
|
+
*/
|
|
447
|
+
async createPractitionerToken(
|
|
448
|
+
data: CreatePractitionerTokenData,
|
|
449
|
+
createdBy: string
|
|
450
|
+
): Promise<PractitionerToken> {
|
|
451
|
+
try {
|
|
452
|
+
// Validate data
|
|
453
|
+
const validatedData = createPractitionerTokenSchema.parse(data);
|
|
454
|
+
|
|
455
|
+
// Check if practitioner exists and is in DRAFT status
|
|
456
|
+
const practitioner = await this.getPractitioner(
|
|
457
|
+
validatedData.practitionerId
|
|
458
|
+
);
|
|
459
|
+
if (!practitioner) {
|
|
460
|
+
throw new Error("Practitioner not found");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
464
|
+
throw new Error(
|
|
465
|
+
"Can only create tokens for practitioners in DRAFT status"
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Check if clinic exists and practitioner belongs to it
|
|
470
|
+
const clinic = await this.getClinicService().getClinic(
|
|
471
|
+
validatedData.clinicId
|
|
472
|
+
);
|
|
473
|
+
if (!clinic) {
|
|
474
|
+
throw new Error(`Clinic ${validatedData.clinicId} not found`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (!practitioner.clinics.includes(validatedData.clinicId)) {
|
|
478
|
+
throw new Error("Practitioner is not associated with this clinic");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Default expiration is 7 days from now if not specified
|
|
482
|
+
const expiration =
|
|
483
|
+
validatedData.expiresAt ||
|
|
484
|
+
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
|
485
|
+
|
|
486
|
+
// Generate a token (6 characters) using generateId from BaseService
|
|
487
|
+
const tokenString = this.generateId().slice(0, 6).toUpperCase();
|
|
488
|
+
|
|
489
|
+
const token: PractitionerToken = {
|
|
490
|
+
id: this.generateId(),
|
|
491
|
+
token: tokenString,
|
|
492
|
+
practitionerId: validatedData.practitionerId,
|
|
493
|
+
email: validatedData.email,
|
|
494
|
+
clinicId: validatedData.clinicId,
|
|
495
|
+
status: PractitionerTokenStatus.ACTIVE,
|
|
496
|
+
createdBy: createdBy,
|
|
497
|
+
createdAt: Timestamp.now(),
|
|
498
|
+
expiresAt: Timestamp.fromDate(expiration),
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
// Validate token object
|
|
502
|
+
practitionerTokenSchema.parse(token);
|
|
503
|
+
|
|
504
|
+
// Store the token in the practitioner document's register_tokens subcollection
|
|
505
|
+
const tokenPath = `${PRACTITIONERS_COLLECTION}/${validatedData.practitionerId}/${REGISTER_TOKENS_COLLECTION}/${token.id}`;
|
|
506
|
+
await setDoc(doc(this.db, tokenPath), token);
|
|
507
|
+
|
|
508
|
+
return token;
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (error instanceof z.ZodError) {
|
|
511
|
+
throw new Error("Invalid token data: " + error.message);
|
|
512
|
+
}
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Gets active tokens for a practitioner
|
|
519
|
+
* @param practitionerId ID of the practitioner
|
|
520
|
+
* @returns Array of active tokens
|
|
521
|
+
*/
|
|
522
|
+
async getPractitionerActiveTokens(
|
|
523
|
+
practitionerId: string
|
|
524
|
+
): Promise<PractitionerToken[]> {
|
|
525
|
+
const tokensRef = collection(
|
|
526
|
+
this.db,
|
|
527
|
+
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
const q = query(
|
|
531
|
+
tokensRef,
|
|
532
|
+
where("status", "==", PractitionerTokenStatus.ACTIVE),
|
|
533
|
+
where("expiresAt", ">", Timestamp.now())
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const querySnapshot = await getDocs(q);
|
|
537
|
+
return querySnapshot.docs.map((doc) => doc.data() as PractitionerToken);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Gets a token by its string value and validates it
|
|
542
|
+
* @param tokenString The token string to find
|
|
543
|
+
* @returns The token if found and valid, null otherwise
|
|
544
|
+
*/
|
|
545
|
+
async validateToken(tokenString: string): Promise<PractitionerToken | null> {
|
|
546
|
+
// We need to search through all practitioners' register_tokens subcollections
|
|
547
|
+
const practitionersRef = collection(this.db, PRACTITIONERS_COLLECTION);
|
|
548
|
+
const practitionersSnapshot = await getDocs(practitionersRef);
|
|
549
|
+
|
|
550
|
+
for (const practitionerDoc of practitionersSnapshot.docs) {
|
|
551
|
+
const practitionerId = practitionerDoc.id;
|
|
552
|
+
const tokensRef = collection(
|
|
553
|
+
this.db,
|
|
554
|
+
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}`
|
|
555
|
+
);
|
|
556
|
+
|
|
557
|
+
console.log(
|
|
558
|
+
`[PRACTITIONER] Validating token for practitioner ${practitionerId}`,
|
|
559
|
+
{
|
|
560
|
+
tokenString,
|
|
561
|
+
timestamp: Timestamp.now().toDate(),
|
|
562
|
+
}
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const q = query(
|
|
566
|
+
tokensRef,
|
|
567
|
+
where("token", "==", tokenString),
|
|
568
|
+
where("status", "==", PractitionerTokenStatus.ACTIVE),
|
|
569
|
+
where("expiresAt", ">", Timestamp.now())
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
try {
|
|
573
|
+
const tokenSnapshot = await getDocs(q);
|
|
574
|
+
console.log(
|
|
575
|
+
`[PRACTITIONER] Token query results for practitioner ${practitionerId}`,
|
|
576
|
+
{
|
|
577
|
+
found: !tokenSnapshot.empty,
|
|
578
|
+
count: tokenSnapshot.size,
|
|
579
|
+
}
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
if (!tokenSnapshot.empty) {
|
|
583
|
+
const tokenData = tokenSnapshot.docs[0].data() as PractitionerToken;
|
|
584
|
+
console.log(`[PRACTITIONER] Valid token found`, {
|
|
585
|
+
tokenId: tokenData.id,
|
|
586
|
+
expiresAt: tokenData.expiresAt.toDate(),
|
|
587
|
+
});
|
|
588
|
+
return tokenData;
|
|
589
|
+
}
|
|
590
|
+
} catch (error) {
|
|
591
|
+
console.error(
|
|
592
|
+
`[PRACTITIONER] Error validating token for practitioner ${practitionerId}:`,
|
|
593
|
+
error
|
|
594
|
+
);
|
|
595
|
+
// Re-throw the error to be handled by the caller
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Marks a token as used
|
|
605
|
+
* @param tokenId ID of the token
|
|
606
|
+
* @param practitionerId ID of the practitioner
|
|
607
|
+
* @param userId ID of the user using the token
|
|
608
|
+
*/
|
|
609
|
+
async markTokenAsUsed(
|
|
610
|
+
tokenId: string,
|
|
611
|
+
practitionerId: string,
|
|
612
|
+
userId: string
|
|
613
|
+
): Promise<void> {
|
|
614
|
+
const tokenRef = doc(
|
|
615
|
+
this.db,
|
|
616
|
+
`${PRACTITIONERS_COLLECTION}/${practitionerId}/${REGISTER_TOKENS_COLLECTION}/${tokenId}`
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
await updateDoc(tokenRef, {
|
|
620
|
+
status: PractitionerTokenStatus.USED,
|
|
621
|
+
usedBy: userId,
|
|
622
|
+
usedAt: Timestamp.now(),
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Dohvata zdravstvenog radnika po ID-u
|
|
628
|
+
*/
|
|
629
|
+
async getPractitioner(practitionerId: string): Promise<Practitioner | null> {
|
|
630
|
+
const practitionerDoc = await getDoc(
|
|
631
|
+
doc(this.db, PRACTITIONERS_COLLECTION, practitionerId)
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
if (!practitionerDoc.exists()) {
|
|
635
|
+
return null;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return practitionerDoc.data() as Practitioner;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Dohvata zdravstvenog radnika po User ID-u
|
|
643
|
+
*/
|
|
644
|
+
async getPractitionerByUserRef(
|
|
645
|
+
userRef: string
|
|
646
|
+
): Promise<Practitioner | null> {
|
|
647
|
+
const q = query(
|
|
648
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
649
|
+
where("userRef", "==", userRef)
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
const querySnapshot = await getDocs(q);
|
|
653
|
+
if (querySnapshot.empty) {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
return querySnapshot.docs[0].data() as Practitioner;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Dohvata sve zdravstvene radnike za određenu kliniku sa statusom ACTIVE
|
|
662
|
+
*/
|
|
663
|
+
async getPractitionersByClinic(clinicId: string): Promise<Practitioner[]> {
|
|
664
|
+
const q = query(
|
|
665
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
666
|
+
where("clinics", "array-contains", clinicId),
|
|
667
|
+
where("isActive", "==", true),
|
|
668
|
+
where("status", "==", PractitionerStatus.ACTIVE)
|
|
669
|
+
);
|
|
670
|
+
|
|
671
|
+
const querySnapshot = await getDocs(q);
|
|
672
|
+
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Dohvata sve zdravstvene radnike za određenu kliniku
|
|
677
|
+
*/
|
|
678
|
+
async getAllPractitionersByClinic(clinicId: string): Promise<Practitioner[]> {
|
|
679
|
+
const q = query(
|
|
680
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
681
|
+
where("clinics", "array-contains", clinicId),
|
|
682
|
+
where("isActive", "==", true)
|
|
683
|
+
);
|
|
684
|
+
|
|
685
|
+
const querySnapshot = await getDocs(q);
|
|
686
|
+
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* Dohvata sve draft zdravstvene radnike za određenu kliniku sa statusom DRAFT
|
|
691
|
+
*/
|
|
692
|
+
async getDraftPractitionersByClinic(
|
|
693
|
+
clinicId: string
|
|
694
|
+
): Promise<Practitioner[]> {
|
|
695
|
+
const q = query(
|
|
696
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
697
|
+
where("clinics", "array-contains", clinicId),
|
|
698
|
+
where("status", "==", PractitionerStatus.DRAFT)
|
|
699
|
+
);
|
|
700
|
+
|
|
701
|
+
const querySnapshot = await getDocs(q);
|
|
702
|
+
return querySnapshot.docs.map((doc) => doc.data() as Practitioner);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Updates a practitioner
|
|
707
|
+
*/
|
|
708
|
+
async updatePractitioner(
|
|
709
|
+
practitionerId: string,
|
|
710
|
+
data: UpdatePractitionerData
|
|
711
|
+
): Promise<Practitioner> {
|
|
712
|
+
try {
|
|
713
|
+
// Validate update data
|
|
714
|
+
const validData = data; // Using the passed data directly as it's already validated by the schema type
|
|
715
|
+
|
|
716
|
+
// Get current practitioner data
|
|
717
|
+
const practitionerRef = doc(
|
|
718
|
+
this.db,
|
|
719
|
+
PRACTITIONERS_COLLECTION,
|
|
720
|
+
practitionerId
|
|
721
|
+
);
|
|
722
|
+
const practitionerDoc = await getDoc(practitionerRef);
|
|
723
|
+
|
|
724
|
+
if (!practitionerDoc.exists()) {
|
|
725
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const currentPractitioner = practitionerDoc.data() as Practitioner;
|
|
729
|
+
|
|
730
|
+
// Process basicInfo if it's being updated to handle profile photo uploads
|
|
731
|
+
let processedData: UpdatePractitionerData & { fullNameLower?: string } = {
|
|
732
|
+
...validData,
|
|
733
|
+
};
|
|
734
|
+
if (validData.basicInfo) {
|
|
735
|
+
processedData.basicInfo = await this.processBasicInfo(
|
|
736
|
+
validData.basicInfo as PractitionerBasicInfo & {
|
|
737
|
+
profileImageUrl?: MediaResource | null;
|
|
738
|
+
},
|
|
739
|
+
practitionerId
|
|
740
|
+
);
|
|
741
|
+
// Always update fullNameLower when basicInfo changes
|
|
742
|
+
processedData.fullNameLower =
|
|
743
|
+
`${processedData.basicInfo.firstName} ${processedData.basicInfo.lastName}`.toLowerCase();
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// Prepare update data
|
|
747
|
+
const updateData: any = {
|
|
748
|
+
...processedData,
|
|
749
|
+
updatedAt: serverTimestamp(),
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// Update practitioner
|
|
753
|
+
await updateDoc(practitionerRef, updateData);
|
|
754
|
+
|
|
755
|
+
// Return updated practitioner
|
|
756
|
+
const updatedPractitioner = await this.getPractitioner(practitionerId);
|
|
757
|
+
if (!updatedPractitioner) {
|
|
758
|
+
throw new Error(
|
|
759
|
+
`Failed to retrieve updated practitioner ${practitionerId}`
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
return updatedPractitioner;
|
|
763
|
+
} catch (error) {
|
|
764
|
+
if (error instanceof z.ZodError) {
|
|
765
|
+
throw new Error(`Invalid practitioner update data: ${error.message}`);
|
|
766
|
+
}
|
|
767
|
+
console.error(`Error updating practitioner ${practitionerId}:`, error);
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Adds a clinic to a practitioner
|
|
774
|
+
*/
|
|
775
|
+
async addClinic(practitionerId: string, clinicId: string): Promise<void> {
|
|
776
|
+
try {
|
|
777
|
+
// Get practitioner
|
|
778
|
+
const practitionerRef = doc(
|
|
779
|
+
this.db,
|
|
780
|
+
PRACTITIONERS_COLLECTION,
|
|
781
|
+
practitionerId
|
|
782
|
+
);
|
|
783
|
+
const practitionerDoc = await getDoc(practitionerRef);
|
|
784
|
+
|
|
785
|
+
if (!practitionerDoc.exists()) {
|
|
786
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const practitioner = practitionerDoc.data() as Practitioner;
|
|
790
|
+
|
|
791
|
+
// Check if clinic already added
|
|
792
|
+
if (practitioner.clinics?.includes(clinicId)) {
|
|
793
|
+
console.log(
|
|
794
|
+
`Clinic ${clinicId} already added to practitioner ${practitionerId}`
|
|
795
|
+
);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Add clinic to clinics array
|
|
800
|
+
await updateDoc(practitionerRef, {
|
|
801
|
+
clinics: arrayUnion(clinicId),
|
|
802
|
+
updatedAt: serverTimestamp(),
|
|
803
|
+
});
|
|
804
|
+
} catch (error) {
|
|
805
|
+
console.error(
|
|
806
|
+
`Error adding clinic ${clinicId} to practitioner ${practitionerId}:`,
|
|
807
|
+
error
|
|
808
|
+
);
|
|
809
|
+
throw error;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
/**
|
|
814
|
+
* Removes a clinic from a practitioner
|
|
815
|
+
*/
|
|
816
|
+
async removeClinic(practitionerId: string, clinicId: string): Promise<void> {
|
|
817
|
+
try {
|
|
818
|
+
// Get practitioner
|
|
819
|
+
const practitionerRef = doc(
|
|
820
|
+
this.db,
|
|
821
|
+
PRACTITIONERS_COLLECTION,
|
|
822
|
+
practitionerId
|
|
823
|
+
);
|
|
824
|
+
const practitionerDoc = await getDoc(practitionerRef);
|
|
825
|
+
|
|
826
|
+
if (!practitionerDoc.exists()) {
|
|
827
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Remove clinic from clinics array
|
|
831
|
+
await updateDoc(practitionerRef, {
|
|
832
|
+
clinics: arrayRemove(clinicId),
|
|
833
|
+
updatedAt: serverTimestamp(),
|
|
834
|
+
});
|
|
835
|
+
} catch (error) {
|
|
836
|
+
console.error(
|
|
837
|
+
`Error removing clinic ${clinicId} from practitioner ${practitionerId}:`,
|
|
838
|
+
error
|
|
839
|
+
);
|
|
840
|
+
throw error;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Deaktivira profil zdravstvenog radnika
|
|
846
|
+
*/
|
|
847
|
+
async deactivatePractitioner(practitionerId: string): Promise<void> {
|
|
848
|
+
await this.updatePractitioner(practitionerId, {
|
|
849
|
+
isActive: false,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Aktivira profil zdravstvenog radnika
|
|
855
|
+
*/
|
|
856
|
+
async activatePractitioner(practitionerId: string): Promise<void> {
|
|
857
|
+
await this.updatePractitioner(practitionerId, {
|
|
858
|
+
isActive: true,
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Briše profil zdravstvenog radnika
|
|
864
|
+
*/
|
|
865
|
+
async deletePractitioner(practitionerId: string): Promise<void> {
|
|
866
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
867
|
+
if (!practitioner) {
|
|
868
|
+
throw new Error("Practitioner not found");
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// TODO: Kada implementiramo subkolekcije, ovde ćemo dodati brisanje povezanih podataka
|
|
872
|
+
|
|
873
|
+
await deleteDoc(doc(this.db, PRACTITIONERS_COLLECTION, practitionerId));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/**
|
|
877
|
+
* Validates a registration token and claims the associated draft practitioner profile
|
|
878
|
+
* @param tokenString The token provided by the practitioner
|
|
879
|
+
* @param userId The ID of the user claiming the profile
|
|
880
|
+
* @returns The claimed practitioner profile or null if token is invalid
|
|
881
|
+
*/
|
|
882
|
+
async validateTokenAndClaimProfile(
|
|
883
|
+
tokenString: string,
|
|
884
|
+
userId: string
|
|
885
|
+
): Promise<Practitioner | null> {
|
|
886
|
+
// Find the token
|
|
887
|
+
console.log("[PRACTITIONER] Validating token for claiming profile", {
|
|
888
|
+
tokenString,
|
|
889
|
+
userId,
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
const token = await this.validateToken(tokenString);
|
|
893
|
+
|
|
894
|
+
if (!token) {
|
|
895
|
+
console.log(
|
|
896
|
+
"[PRACTITIONER] Token validation failed - token not found or not valid",
|
|
897
|
+
{
|
|
898
|
+
tokenString,
|
|
899
|
+
}
|
|
900
|
+
);
|
|
901
|
+
return null; // Token not found or not valid
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
console.log("[PRACTITIONER] Token successfully validated", {
|
|
905
|
+
tokenId: token.id,
|
|
906
|
+
practitionerId: token.practitionerId,
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// Get the practitioner profile
|
|
910
|
+
const practitioner = await this.getPractitioner(token.practitionerId);
|
|
911
|
+
if (!practitioner) {
|
|
912
|
+
console.log("[PRACTITIONER] Practitioner not found", {
|
|
913
|
+
practitionerId: token.practitionerId,
|
|
914
|
+
});
|
|
915
|
+
return null; // Practitioner not found
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Ensure practitioner is in DRAFT status
|
|
919
|
+
if (practitioner.status !== PractitionerStatus.DRAFT) {
|
|
920
|
+
console.log("[PRACTITIONER] Practitioner status is not DRAFT", {
|
|
921
|
+
practitionerId: practitioner.id,
|
|
922
|
+
status: practitioner.status,
|
|
923
|
+
});
|
|
924
|
+
throw new Error("This practitioner profile has already been claimed");
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Check if user already has a practitioner profile
|
|
928
|
+
const existingPractitioner = await this.getPractitionerByUserRef(userId);
|
|
929
|
+
if (existingPractitioner) {
|
|
930
|
+
throw new Error("User already has a practitioner profile");
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Claim the profile by linking it to the user
|
|
934
|
+
const updatedPractitioner = await this.updatePractitioner(practitioner.id, {
|
|
935
|
+
userRef: userId,
|
|
936
|
+
status: PractitionerStatus.ACTIVE,
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// Mark the token as used
|
|
940
|
+
await this.markTokenAsUsed(token.id, token.practitionerId, userId);
|
|
941
|
+
|
|
942
|
+
console.log("[PRACTITIONER] Profile claimed successfully", {
|
|
943
|
+
practitionerId: updatedPractitioner.id,
|
|
944
|
+
userId,
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
return updatedPractitioner;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Retrieves all practitioners with optional pagination and draft inclusion
|
|
952
|
+
*
|
|
953
|
+
* @param options - Search options
|
|
954
|
+
* @param options.pagination - Optional limit for number of results per page
|
|
955
|
+
* @param options.lastDoc - Optional last document for pagination
|
|
956
|
+
* @param options.includeDraftPractitioners - Whether to include draft practitioners
|
|
957
|
+
* @returns Array of practitioners and the last document for pagination
|
|
958
|
+
*/
|
|
959
|
+
async getAllPractitioners(options?: {
|
|
960
|
+
pagination?: number;
|
|
961
|
+
lastDoc?: any;
|
|
962
|
+
includeDraftPractitioners?: boolean;
|
|
963
|
+
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
964
|
+
try {
|
|
965
|
+
const constraints = [];
|
|
966
|
+
|
|
967
|
+
// Filter by status if not including drafts
|
|
968
|
+
if (!options?.includeDraftPractitioners) {
|
|
969
|
+
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Add ordering for consistent pagination
|
|
973
|
+
constraints.push(orderBy("basicInfo.lastName", "asc"));
|
|
974
|
+
constraints.push(orderBy("basicInfo.firstName", "asc"));
|
|
975
|
+
|
|
976
|
+
// Add pagination if specified
|
|
977
|
+
if (options?.pagination && options.pagination > 0) {
|
|
978
|
+
if (options.lastDoc) {
|
|
979
|
+
constraints.push(startAfter(options.lastDoc));
|
|
980
|
+
}
|
|
981
|
+
constraints.push(limit(options.pagination));
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const q = query(
|
|
985
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
986
|
+
...constraints
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
const querySnapshot = await getDocs(q);
|
|
990
|
+
|
|
991
|
+
const practitioners = querySnapshot.docs.map(
|
|
992
|
+
(doc) => doc.data() as Practitioner
|
|
993
|
+
);
|
|
994
|
+
|
|
995
|
+
// Get last document for pagination
|
|
996
|
+
const lastDoc =
|
|
997
|
+
querySnapshot.docs.length > 0
|
|
998
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
999
|
+
: null;
|
|
1000
|
+
|
|
1001
|
+
return {
|
|
1002
|
+
practitioners,
|
|
1003
|
+
lastDoc,
|
|
1004
|
+
};
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
console.error(
|
|
1007
|
+
"[PRACTITIONER_SERVICE] Error getting all practitioners:",
|
|
1008
|
+
error
|
|
1009
|
+
);
|
|
1010
|
+
throw error;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* Searches and filters practitioners based on multiple criteria
|
|
1016
|
+
*
|
|
1017
|
+
* @param filters - Various filters to apply
|
|
1018
|
+
* @param filters.nameSearch - Optional search text for first/last name
|
|
1019
|
+
* @param filters.certifications - Optional array of certifications to filter by
|
|
1020
|
+
* @param filters.specialties - Optional array of specialties to filter by
|
|
1021
|
+
* @param filters.procedureFamily - Optional procedure family practitioners provide
|
|
1022
|
+
* @param filters.procedureCategory - Optional procedure category practitioners provide
|
|
1023
|
+
* @param filters.procedureSubcategory - Optional procedure subcategory practitioners provide
|
|
1024
|
+
* @param filters.procedureTechnology - Optional procedure technology practitioners provide
|
|
1025
|
+
* @param filters.location - Optional location for distance-based search
|
|
1026
|
+
* @param filters.radiusInKm - Optional radius in kilometers (required if location is provided)
|
|
1027
|
+
* @param filters.minRating - Optional minimum rating (0-5)
|
|
1028
|
+
* @param filters.maxRating - Optional maximum rating (0-5)
|
|
1029
|
+
* @param filters.pagination - Optional number of results per page
|
|
1030
|
+
* @param filters.lastDoc - Optional last document for pagination
|
|
1031
|
+
* @param filters.includeDraftPractitioners - Whether to include draft practitioners
|
|
1032
|
+
* @returns Filtered practitioners and the last document for pagination
|
|
1033
|
+
*/
|
|
1034
|
+
async getPractitionersByFilters(filters: {
|
|
1035
|
+
nameSearch?: string;
|
|
1036
|
+
certifications?: string[];
|
|
1037
|
+
specialties?: CertificationSpecialty[];
|
|
1038
|
+
procedureFamily?: string;
|
|
1039
|
+
procedureCategory?: string;
|
|
1040
|
+
procedureSubcategory?: string;
|
|
1041
|
+
procedureTechnology?: string;
|
|
1042
|
+
location?: { latitude: number; longitude: number };
|
|
1043
|
+
radiusInKm?: number;
|
|
1044
|
+
minRating?: number;
|
|
1045
|
+
maxRating?: number;
|
|
1046
|
+
pagination?: number;
|
|
1047
|
+
lastDoc?: any;
|
|
1048
|
+
includeDraftPractitioners?: boolean;
|
|
1049
|
+
}): Promise<{ practitioners: Practitioner[]; lastDoc: any }> {
|
|
1050
|
+
try {
|
|
1051
|
+
console.log(
|
|
1052
|
+
"[PRACTITIONER_SERVICE] Starting practitioner filtering with fallback strategies"
|
|
1053
|
+
);
|
|
1054
|
+
|
|
1055
|
+
// Geo query debug i validacija
|
|
1056
|
+
if (filters.location && filters.radiusInKm) {
|
|
1057
|
+
console.log("[PRACTITIONER_SERVICE] Executing geo query:", {
|
|
1058
|
+
location: filters.location,
|
|
1059
|
+
radius: filters.radiusInKm,
|
|
1060
|
+
serviceName: "PractitionerService",
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// Validacija location podataka
|
|
1064
|
+
if (!filters.location.latitude || !filters.location.longitude) {
|
|
1065
|
+
console.warn(
|
|
1066
|
+
"[PRACTITIONER_SERVICE] Invalid location data:",
|
|
1067
|
+
filters.location
|
|
1068
|
+
);
|
|
1069
|
+
filters.location = undefined;
|
|
1070
|
+
filters.radiusInKm = undefined;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Strategy 1: Try fullNameLower search if nameSearch exists
|
|
1075
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1076
|
+
try {
|
|
1077
|
+
console.log(
|
|
1078
|
+
"[PRACTITIONER_SERVICE] Strategy 1: Trying fullNameLower search"
|
|
1079
|
+
);
|
|
1080
|
+
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1081
|
+
const constraints: any[] = [];
|
|
1082
|
+
|
|
1083
|
+
if (!filters.includeDraftPractitioners) {
|
|
1084
|
+
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1085
|
+
}
|
|
1086
|
+
constraints.push(where("isActive", "==", true));
|
|
1087
|
+
constraints.push(where("fullNameLower", ">=", searchTerm));
|
|
1088
|
+
constraints.push(where("fullNameLower", "<=", searchTerm + "\uf8ff"));
|
|
1089
|
+
constraints.push(orderBy("fullNameLower"));
|
|
1090
|
+
|
|
1091
|
+
if (filters.lastDoc) {
|
|
1092
|
+
if (typeof filters.lastDoc.data === "function") {
|
|
1093
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1094
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
1095
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
1096
|
+
} else {
|
|
1097
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
constraints.push(limit(filters.pagination || 10));
|
|
1101
|
+
|
|
1102
|
+
const q = query(
|
|
1103
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1104
|
+
...constraints
|
|
1105
|
+
);
|
|
1106
|
+
const querySnapshot = await getDocs(q);
|
|
1107
|
+
const practitioners = querySnapshot.docs.map(
|
|
1108
|
+
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1109
|
+
);
|
|
1110
|
+
const lastDoc =
|
|
1111
|
+
querySnapshot.docs.length > 0
|
|
1112
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1113
|
+
: null;
|
|
1114
|
+
|
|
1115
|
+
console.log(
|
|
1116
|
+
`[PRACTITIONER_SERVICE] Strategy 1 success: ${practitioners.length} practitioners`
|
|
1117
|
+
);
|
|
1118
|
+
|
|
1119
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1120
|
+
if (practitioners.length < (filters.pagination || 10)) {
|
|
1121
|
+
return { practitioners, lastDoc: null };
|
|
1122
|
+
}
|
|
1123
|
+
return { practitioners, lastDoc };
|
|
1124
|
+
} catch (error) {
|
|
1125
|
+
console.log("[PRACTITIONER_SERVICE] Strategy 1 failed:", error);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// Strategy 2: Basic query with createdAt ordering (no name search)
|
|
1130
|
+
try {
|
|
1131
|
+
console.log(
|
|
1132
|
+
"[PRACTITIONER_SERVICE] Strategy 2: Basic query with createdAt ordering"
|
|
1133
|
+
);
|
|
1134
|
+
const constraints: any[] = [];
|
|
1135
|
+
|
|
1136
|
+
if (!filters.includeDraftPractitioners) {
|
|
1137
|
+
constraints.push(where("status", "==", PractitionerStatus.ACTIVE));
|
|
1138
|
+
}
|
|
1139
|
+
constraints.push(where("isActive", "==", true));
|
|
1140
|
+
|
|
1141
|
+
// Add other filters that work well with Firestore
|
|
1142
|
+
if (filters.certifications && filters.certifications.length > 0) {
|
|
1143
|
+
const certificationsToMatch =
|
|
1144
|
+
filters.certifications as CertificationSpecialty[];
|
|
1145
|
+
constraints.push(
|
|
1146
|
+
where(
|
|
1147
|
+
"certification.specialties",
|
|
1148
|
+
"array-contains-any",
|
|
1149
|
+
certificationsToMatch
|
|
1150
|
+
)
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (filters.minRating !== undefined) {
|
|
1155
|
+
constraints.push(
|
|
1156
|
+
where("reviewInfo.averageRating", ">=", filters.minRating)
|
|
1157
|
+
);
|
|
1158
|
+
}
|
|
1159
|
+
if (filters.maxRating !== undefined) {
|
|
1160
|
+
constraints.push(
|
|
1161
|
+
where("reviewInfo.averageRating", "<=", filters.maxRating)
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
constraints.push(orderBy("createdAt", "desc"));
|
|
1166
|
+
|
|
1167
|
+
// Pagination sa createdAt - poboljšano za geo queries
|
|
1168
|
+
if (filters.location && filters.radiusInKm) {
|
|
1169
|
+
// Ne koristiti lastDoc za geo queries, već preuzmi više rezultata
|
|
1170
|
+
constraints.push(limit((filters.pagination || 10) * 2)); // Dvostruko više za geo filter
|
|
1171
|
+
} else {
|
|
1172
|
+
if (filters.lastDoc) {
|
|
1173
|
+
if (typeof filters.lastDoc.data === "function") {
|
|
1174
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1175
|
+
} else if (Array.isArray(filters.lastDoc)) {
|
|
1176
|
+
constraints.push(startAfter(...filters.lastDoc));
|
|
1177
|
+
} else {
|
|
1178
|
+
constraints.push(startAfter(filters.lastDoc));
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
constraints.push(limit(filters.pagination || 10));
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const q = query(
|
|
1185
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1186
|
+
...constraints
|
|
1187
|
+
);
|
|
1188
|
+
const querySnapshot = await getDocs(q);
|
|
1189
|
+
let practitioners = querySnapshot.docs.map(
|
|
1190
|
+
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1191
|
+
);
|
|
1192
|
+
|
|
1193
|
+
// Apply geo filter if needed (this is the only in-memory filter we keep)
|
|
1194
|
+
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1195
|
+
const location = filters.location;
|
|
1196
|
+
const radiusInKm = filters.radiusInKm;
|
|
1197
|
+
practitioners = practitioners.filter((practitioner) => {
|
|
1198
|
+
const clinics = practitioner.clinicsInfo || [];
|
|
1199
|
+
return clinics.some((clinic) => {
|
|
1200
|
+
const distance = distanceBetween(
|
|
1201
|
+
[location.latitude, location.longitude],
|
|
1202
|
+
[clinic.location.latitude, clinic.location.longitude]
|
|
1203
|
+
);
|
|
1204
|
+
const distanceInKm = distance / 1000;
|
|
1205
|
+
return distanceInKm <= radiusInKm;
|
|
1206
|
+
});
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
// Ograniči na pagination broj nakon geo filtera
|
|
1210
|
+
practitioners = practitioners.slice(0, filters.pagination || 10);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Apply all remaining client-side filters using centralized function
|
|
1214
|
+
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1215
|
+
|
|
1216
|
+
const lastDoc =
|
|
1217
|
+
querySnapshot.docs.length > 0
|
|
1218
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1219
|
+
: null;
|
|
1220
|
+
console.log(
|
|
1221
|
+
`[PRACTITIONER_SERVICE] Strategy 2 success: ${practitioners.length} practitioners`
|
|
1222
|
+
);
|
|
1223
|
+
|
|
1224
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1225
|
+
if (practitioners.length < (filters.pagination || 10)) {
|
|
1226
|
+
return { practitioners, lastDoc: null };
|
|
1227
|
+
}
|
|
1228
|
+
return { practitioners, lastDoc };
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
console.log("[PRACTITIONER_SERVICE] Strategy 2 failed:", error);
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
// Strategy 3: Minimal query fallback
|
|
1234
|
+
try {
|
|
1235
|
+
console.log(
|
|
1236
|
+
"[PRACTITIONER_SERVICE] Strategy 3: Minimal query fallback"
|
|
1237
|
+
);
|
|
1238
|
+
const constraints: any[] = [
|
|
1239
|
+
where("isActive", "==", true),
|
|
1240
|
+
orderBy("createdAt", "desc"),
|
|
1241
|
+
limit(filters.pagination || 10),
|
|
1242
|
+
];
|
|
1243
|
+
|
|
1244
|
+
const q = query(
|
|
1245
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1246
|
+
...constraints
|
|
1247
|
+
);
|
|
1248
|
+
const querySnapshot = await getDocs(q);
|
|
1249
|
+
let practitioners = querySnapshot.docs.map(
|
|
1250
|
+
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1251
|
+
);
|
|
1252
|
+
|
|
1253
|
+
// Apply all client-side filters using centralized function
|
|
1254
|
+
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1255
|
+
|
|
1256
|
+
const lastDoc =
|
|
1257
|
+
querySnapshot.docs.length > 0
|
|
1258
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1259
|
+
: null;
|
|
1260
|
+
console.log(
|
|
1261
|
+
`[PRACTITIONER_SERVICE] Strategy 3 success: ${practitioners.length} practitioners`
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1265
|
+
if (practitioners.length < (filters.pagination || 10)) {
|
|
1266
|
+
return { practitioners, lastDoc: null };
|
|
1267
|
+
}
|
|
1268
|
+
return { practitioners, lastDoc };
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
console.log("[PRACTITIONER_SERVICE] Strategy 3 failed:", error);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
// Strategy 4: Client-side filtering fallback (kao u procedure/clinic services)
|
|
1274
|
+
try {
|
|
1275
|
+
console.log(
|
|
1276
|
+
"[PRACTITIONER_SERVICE] Strategy 4: Client-side filtering fallback"
|
|
1277
|
+
);
|
|
1278
|
+
|
|
1279
|
+
const constraints: any[] = [
|
|
1280
|
+
where("isActive", "==", true),
|
|
1281
|
+
where("status", "==", PractitionerStatus.ACTIVE),
|
|
1282
|
+
orderBy("createdAt", "desc"),
|
|
1283
|
+
limit(filters.pagination || 10),
|
|
1284
|
+
];
|
|
1285
|
+
|
|
1286
|
+
const q = query(
|
|
1287
|
+
collection(this.db, PRACTITIONERS_COLLECTION),
|
|
1288
|
+
...constraints
|
|
1289
|
+
);
|
|
1290
|
+
const querySnapshot = await getDocs(q);
|
|
1291
|
+
let practitioners = querySnapshot.docs.map(
|
|
1292
|
+
(doc) => ({ ...doc.data(), id: doc.id } as Practitioner)
|
|
1293
|
+
);
|
|
1294
|
+
|
|
1295
|
+
// Apply all client-side filters using centralized function
|
|
1296
|
+
practitioners = this.applyInMemoryFilters(practitioners, filters);
|
|
1297
|
+
|
|
1298
|
+
const lastDoc =
|
|
1299
|
+
querySnapshot.docs.length > 0
|
|
1300
|
+
? querySnapshot.docs[querySnapshot.docs.length - 1]
|
|
1301
|
+
: null;
|
|
1302
|
+
console.log(
|
|
1303
|
+
`[PRACTITIONER_SERVICE] Strategy 4 success: ${practitioners.length} practitioners`
|
|
1304
|
+
);
|
|
1305
|
+
|
|
1306
|
+
// Fix Load More - ako je broj rezultata manji od pagination, nema više
|
|
1307
|
+
if (practitioners.length < (filters.pagination || 10)) {
|
|
1308
|
+
return { practitioners, lastDoc: null };
|
|
1309
|
+
}
|
|
1310
|
+
return { practitioners, lastDoc };
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
console.log("[PRACTITIONER_SERVICE] Strategy 4 failed:", error);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
// All strategies failed
|
|
1316
|
+
console.log(
|
|
1317
|
+
"[PRACTITIONER_SERVICE] All strategies failed, returning empty result"
|
|
1318
|
+
);
|
|
1319
|
+
return { practitioners: [], lastDoc: null };
|
|
1320
|
+
} catch (error) {
|
|
1321
|
+
console.error(
|
|
1322
|
+
"[PRACTITIONER_SERVICE] Error filtering practitioners:",
|
|
1323
|
+
error
|
|
1324
|
+
);
|
|
1325
|
+
return { practitioners: [], lastDoc: null };
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/**
|
|
1330
|
+
* Applies in-memory filters to practitioners array
|
|
1331
|
+
* Used when Firestore queries fail or for complex filtering
|
|
1332
|
+
*/
|
|
1333
|
+
private applyInMemoryFilters(
|
|
1334
|
+
practitioners: Practitioner[],
|
|
1335
|
+
filters: any
|
|
1336
|
+
): Practitioner[] {
|
|
1337
|
+
let filteredPractitioners = [...practitioners]; // Create copy to avoid mutating original
|
|
1338
|
+
|
|
1339
|
+
// Name search filter
|
|
1340
|
+
if (filters.nameSearch && filters.nameSearch.trim()) {
|
|
1341
|
+
const searchTerm = filters.nameSearch.trim().toLowerCase();
|
|
1342
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1343
|
+
const firstName = (
|
|
1344
|
+
practitioner.basicInfo?.firstName || ""
|
|
1345
|
+
).toLowerCase();
|
|
1346
|
+
const lastName = (practitioner.basicInfo?.lastName || "").toLowerCase();
|
|
1347
|
+
const fullName = `${firstName} ${lastName}`.trim();
|
|
1348
|
+
const fullNameLower = practitioner.fullNameLower || "";
|
|
1349
|
+
|
|
1350
|
+
return (
|
|
1351
|
+
firstName.includes(searchTerm) ||
|
|
1352
|
+
lastName.includes(searchTerm) ||
|
|
1353
|
+
fullName.includes(searchTerm) ||
|
|
1354
|
+
fullNameLower.includes(searchTerm)
|
|
1355
|
+
);
|
|
1356
|
+
});
|
|
1357
|
+
console.log(
|
|
1358
|
+
`[PRACTITIONER_SERVICE] Applied name filter, results: ${filteredPractitioners.length}`
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Certifications filtering
|
|
1363
|
+
if (filters.certifications && filters.certifications.length > 0) {
|
|
1364
|
+
const certificationsToMatch = filters.certifications;
|
|
1365
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1366
|
+
const practitionerCerts = practitioner.certification?.specialties || [];
|
|
1367
|
+
return certificationsToMatch.some((cert: any) =>
|
|
1368
|
+
practitionerCerts.includes(cert as CertificationSpecialty)
|
|
1369
|
+
);
|
|
1370
|
+
});
|
|
1371
|
+
console.log(
|
|
1372
|
+
`[PRACTITIONER_SERVICE] Applied certifications filter, results: ${filteredPractitioners.length}`
|
|
1373
|
+
);
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
// Specialties filtering
|
|
1377
|
+
if (filters.specialties && filters.specialties.length > 0) {
|
|
1378
|
+
const specialtiesToMatch = filters.specialties;
|
|
1379
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1380
|
+
const practitionerSpecs = practitioner.certification?.specialties || [];
|
|
1381
|
+
return specialtiesToMatch.some((spec: any) =>
|
|
1382
|
+
practitionerSpecs.includes(spec)
|
|
1383
|
+
);
|
|
1384
|
+
});
|
|
1385
|
+
console.log(
|
|
1386
|
+
`[PRACTITIONER_SERVICE] Applied specialties filter, results: ${filteredPractitioners.length}`
|
|
1387
|
+
);
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
// Rating filtering
|
|
1391
|
+
if (filters.minRating !== undefined || filters.maxRating !== undefined) {
|
|
1392
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1393
|
+
const rating = practitioner.reviewInfo?.averageRating || 0;
|
|
1394
|
+
if (filters.minRating !== undefined && rating < filters.minRating)
|
|
1395
|
+
return false;
|
|
1396
|
+
if (filters.maxRating !== undefined && rating > filters.maxRating)
|
|
1397
|
+
return false;
|
|
1398
|
+
return true;
|
|
1399
|
+
});
|
|
1400
|
+
console.log(
|
|
1401
|
+
`[PRACTITIONER_SERVICE] Applied rating filter, results: ${filteredPractitioners.length}`
|
|
1402
|
+
);
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// Procedure family filtering
|
|
1406
|
+
if (filters.procedureFamily) {
|
|
1407
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1408
|
+
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1409
|
+
return proceduresInfo.some(
|
|
1410
|
+
(proc) => proc.family === filters.procedureFamily
|
|
1411
|
+
);
|
|
1412
|
+
});
|
|
1413
|
+
console.log(
|
|
1414
|
+
`[PRACTITIONER_SERVICE] Applied procedure family filter, results: ${filteredPractitioners.length}`
|
|
1415
|
+
);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
// Procedure category filtering
|
|
1419
|
+
if (filters.procedureCategory) {
|
|
1420
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1421
|
+
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1422
|
+
return proceduresInfo.some(
|
|
1423
|
+
(proc) => proc.categoryName === filters.procedureCategory
|
|
1424
|
+
);
|
|
1425
|
+
});
|
|
1426
|
+
console.log(
|
|
1427
|
+
`[PRACTITIONER_SERVICE] Applied procedure category filter, results: ${filteredPractitioners.length}`
|
|
1428
|
+
);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
// Procedure subcategory filtering
|
|
1432
|
+
if (filters.procedureSubcategory) {
|
|
1433
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1434
|
+
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1435
|
+
return proceduresInfo.some(
|
|
1436
|
+
(proc) => proc.subcategoryName === filters.procedureSubcategory
|
|
1437
|
+
);
|
|
1438
|
+
});
|
|
1439
|
+
console.log(
|
|
1440
|
+
`[PRACTITIONER_SERVICE] Applied procedure subcategory filter, results: ${filteredPractitioners.length}`
|
|
1441
|
+
);
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Procedure technology filtering
|
|
1445
|
+
if (filters.procedureTechnology) {
|
|
1446
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1447
|
+
const proceduresInfo = practitioner.proceduresInfo || [];
|
|
1448
|
+
return proceduresInfo.some(
|
|
1449
|
+
(proc) => proc.technologyName === filters.procedureTechnology
|
|
1450
|
+
);
|
|
1451
|
+
});
|
|
1452
|
+
console.log(
|
|
1453
|
+
`[PRACTITIONER_SERVICE] Applied procedure technology filter, results: ${filteredPractitioners.length}`
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// Geo-radius filter
|
|
1458
|
+
if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
|
|
1459
|
+
const location = filters.location;
|
|
1460
|
+
const radiusInKm = filters.radiusInKm;
|
|
1461
|
+
filteredPractitioners = filteredPractitioners.filter((practitioner) => {
|
|
1462
|
+
const clinics = practitioner.clinicsInfo || [];
|
|
1463
|
+
return clinics.some((clinic) => {
|
|
1464
|
+
const distance = distanceBetween(
|
|
1465
|
+
[location.latitude, location.longitude],
|
|
1466
|
+
[clinic.location.latitude, clinic.location.longitude]
|
|
1467
|
+
);
|
|
1468
|
+
const distanceInKm = distance / 1000;
|
|
1469
|
+
return distanceInKm <= radiusInKm;
|
|
1470
|
+
});
|
|
1471
|
+
});
|
|
1472
|
+
console.log(
|
|
1473
|
+
`[PRACTITIONER_SERVICE] Applied geo filter, results: ${filteredPractitioners.length}`
|
|
1474
|
+
);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return filteredPractitioners;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Enables free consultation for a practitioner in a specific clinic
|
|
1482
|
+
* Creates a free consultation procedure with hardcoded parameters
|
|
1483
|
+
* @param practitionerId - ID of the practitioner
|
|
1484
|
+
* @param clinicId - ID of the clinic
|
|
1485
|
+
* @returns The created consultation procedure
|
|
1486
|
+
*/
|
|
1487
|
+
async EnableFreeConsultation(
|
|
1488
|
+
practitionerId: string,
|
|
1489
|
+
clinicId: string
|
|
1490
|
+
): Promise<void> {
|
|
1491
|
+
try {
|
|
1492
|
+
// First, ensure the free consultation infrastructure exists
|
|
1493
|
+
await this.ensureFreeConsultationInfrastructure();
|
|
1494
|
+
|
|
1495
|
+
// Validate that practitioner exists and is active
|
|
1496
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
1497
|
+
if (!practitioner) {
|
|
1498
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
// No need to check for is practitioner active
|
|
1502
|
+
// if (!practitioner.isActive) {
|
|
1503
|
+
// throw new Error(`Practitioner ${practitionerId} is not active`);
|
|
1504
|
+
// }
|
|
1505
|
+
|
|
1506
|
+
// Validate that clinic exists
|
|
1507
|
+
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
1508
|
+
if (!clinic) {
|
|
1509
|
+
throw new Error(`Clinic ${clinicId} not found`);
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Check if practitioner is associated with this clinic
|
|
1513
|
+
if (!practitioner.clinics.includes(clinicId)) {
|
|
1514
|
+
throw new Error(
|
|
1515
|
+
`Practitioner ${practitionerId} is not associated with clinic ${clinicId}`
|
|
1516
|
+
);
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// Get all procedures for this practitioner (including inactive ones)
|
|
1520
|
+
const [activeProcedures, inactiveProcedures] = await Promise.all([
|
|
1521
|
+
this.getProcedureService().getProceduresByPractitioner(practitionerId),
|
|
1522
|
+
this.getProcedureService().getInactiveProceduresByPractitioner(
|
|
1523
|
+
practitionerId
|
|
1524
|
+
),
|
|
1525
|
+
]);
|
|
1526
|
+
|
|
1527
|
+
// Combine active and inactive procedures
|
|
1528
|
+
const allProcedures = [...activeProcedures, ...inactiveProcedures];
|
|
1529
|
+
|
|
1530
|
+
// Check if free consultation already exists (active or inactive)
|
|
1531
|
+
const existingConsultation = allProcedures.find(
|
|
1532
|
+
(procedure) =>
|
|
1533
|
+
procedure.technology.id === "free-consultation-tech" &&
|
|
1534
|
+
procedure.clinicBranchId === clinicId
|
|
1535
|
+
);
|
|
1536
|
+
|
|
1537
|
+
if (existingConsultation) {
|
|
1538
|
+
if (existingConsultation.isActive) {
|
|
1539
|
+
console.log(
|
|
1540
|
+
`Free consultation already active for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1541
|
+
);
|
|
1542
|
+
return;
|
|
1543
|
+
} else {
|
|
1544
|
+
// Reactivate the existing disabled consultation
|
|
1545
|
+
await this.getProcedureService().updateProcedure(
|
|
1546
|
+
existingConsultation.id,
|
|
1547
|
+
{ isActive: true }
|
|
1548
|
+
);
|
|
1549
|
+
console.log(
|
|
1550
|
+
`Reactivated existing free consultation for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1551
|
+
);
|
|
1552
|
+
return;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// Create procedure data for free consultation (without productId or productsMetadata)
|
|
1557
|
+
const consultationData: Omit<CreateProcedureData, "productId"> = {
|
|
1558
|
+
name: "Free Consultation",
|
|
1559
|
+
nameLower: "free consultation",
|
|
1560
|
+
description:
|
|
1561
|
+
"Free initial consultation to discuss treatment options and assess patient needs.",
|
|
1562
|
+
family: ProcedureFamily.AESTHETICS,
|
|
1563
|
+
categoryId: "consultation",
|
|
1564
|
+
subcategoryId: "free-consultation",
|
|
1565
|
+
technologyId: "free-consultation-tech",
|
|
1566
|
+
price: 0,
|
|
1567
|
+
currency: Currency.EUR,
|
|
1568
|
+
pricingMeasure: PricingMeasure.PER_SESSION,
|
|
1569
|
+
// productsMetadata omitted - no products needed for consultations
|
|
1570
|
+
duration: 30, // 30 minutes consultation
|
|
1571
|
+
practitionerId: practitionerId,
|
|
1572
|
+
clinicBranchId: clinicId,
|
|
1573
|
+
photos: [], // No photos for consultation
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
// Create the consultation procedure using the special method
|
|
1577
|
+
await this.getProcedureService().createConsultationProcedure(
|
|
1578
|
+
consultationData
|
|
1579
|
+
);
|
|
1580
|
+
|
|
1581
|
+
console.log(
|
|
1582
|
+
`Free consultation enabled for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1583
|
+
);
|
|
1584
|
+
} catch (error) {
|
|
1585
|
+
console.error(
|
|
1586
|
+
`Error enabling free consultation for practitioner ${practitionerId} in clinic ${clinicId}:`,
|
|
1587
|
+
error
|
|
1588
|
+
);
|
|
1589
|
+
throw error;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Ensures that the free consultation infrastructure exists by calling the Cloud Function
|
|
1595
|
+
* @returns Promise<boolean> - True if infrastructure exists or was created successfully
|
|
1596
|
+
*/
|
|
1597
|
+
async ensureFreeConsultationInfrastructure(): Promise<boolean> {
|
|
1598
|
+
try {
|
|
1599
|
+
console.log(
|
|
1600
|
+
"[PRACTITIONER_SERVICE] Ensuring free consultation infrastructure via HTTP"
|
|
1601
|
+
);
|
|
1602
|
+
|
|
1603
|
+
// Check if user is authenticated
|
|
1604
|
+
const currentUser = this.auth.currentUser;
|
|
1605
|
+
if (!currentUser) {
|
|
1606
|
+
throw new Error(
|
|
1607
|
+
"User must be authenticated to ensure free consultation infrastructure"
|
|
1608
|
+
);
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
// Construct the function URL for the Express app endpoint
|
|
1612
|
+
const functionUrl = `https://europe-west6-metaestetics.cloudfunctions.net/bookingApi/ensureFreeConsultationInfrastructure`;
|
|
1613
|
+
|
|
1614
|
+
// Get the authenticated user's ID token
|
|
1615
|
+
const idToken = await currentUser.getIdToken();
|
|
1616
|
+
|
|
1617
|
+
console.log(
|
|
1618
|
+
`[PRACTITIONER_SERVICE] Making fetch request to ${functionUrl}`
|
|
1619
|
+
);
|
|
1620
|
+
|
|
1621
|
+
// Make the HTTP request
|
|
1622
|
+
const response = await fetch(functionUrl, {
|
|
1623
|
+
method: "POST",
|
|
1624
|
+
mode: "cors",
|
|
1625
|
+
cache: "no-cache",
|
|
1626
|
+
credentials: "omit",
|
|
1627
|
+
headers: {
|
|
1628
|
+
"Content-Type": "application/json",
|
|
1629
|
+
Authorization: `Bearer ${idToken}`,
|
|
1630
|
+
},
|
|
1631
|
+
redirect: "follow",
|
|
1632
|
+
referrerPolicy: "no-referrer",
|
|
1633
|
+
body: JSON.stringify({}), // Empty body as no parameters needed
|
|
1634
|
+
});
|
|
1635
|
+
|
|
1636
|
+
console.log(
|
|
1637
|
+
`[PRACTITIONER_SERVICE] Received response ${response.status}: ${response.statusText}`
|
|
1638
|
+
);
|
|
1639
|
+
|
|
1640
|
+
// Check if the request was successful
|
|
1641
|
+
if (!response.ok) {
|
|
1642
|
+
const errorText = await response.text();
|
|
1643
|
+
console.error(
|
|
1644
|
+
`[PRACTITIONER_SERVICE] Error response details: ${errorText}`
|
|
1645
|
+
);
|
|
1646
|
+
throw new Error(
|
|
1647
|
+
`Failed to ensure free consultation infrastructure: ${response.status} ${response.statusText} - ${errorText}`
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// Parse the response
|
|
1652
|
+
const result = await response.json();
|
|
1653
|
+
console.log(
|
|
1654
|
+
`[PRACTITIONER_SERVICE] Infrastructure check response:`,
|
|
1655
|
+
result
|
|
1656
|
+
);
|
|
1657
|
+
|
|
1658
|
+
if (!result.success) {
|
|
1659
|
+
throw new Error(
|
|
1660
|
+
result.error || "Failed to ensure free consultation infrastructure"
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
console.log(
|
|
1665
|
+
`[PRACTITIONER_SERVICE] Free consultation infrastructure ensured successfully`
|
|
1666
|
+
);
|
|
1667
|
+
|
|
1668
|
+
return result.infrastructureExists;
|
|
1669
|
+
} catch (error) {
|
|
1670
|
+
console.error(
|
|
1671
|
+
"[PRACTITIONER_SERVICE] Error ensuring free consultation infrastructure:",
|
|
1672
|
+
error
|
|
1673
|
+
);
|
|
1674
|
+
throw error;
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Disables free consultation for a practitioner in a specific clinic
|
|
1680
|
+
* Finds and deactivates the existing free consultation procedure
|
|
1681
|
+
* @param practitionerId - ID of the practitioner
|
|
1682
|
+
* @param clinicId - ID of the clinic
|
|
1683
|
+
*/
|
|
1684
|
+
async DisableFreeConsultation(
|
|
1685
|
+
practitionerId: string,
|
|
1686
|
+
clinicId: string
|
|
1687
|
+
): Promise<void> {
|
|
1688
|
+
try {
|
|
1689
|
+
// Validate that practitioner exists
|
|
1690
|
+
const practitioner = await this.getPractitioner(practitionerId);
|
|
1691
|
+
if (!practitioner) {
|
|
1692
|
+
throw new Error(`Practitioner ${practitionerId} not found`);
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
// Validate that clinic exists
|
|
1696
|
+
const clinic = await this.getClinicService().getClinic(clinicId);
|
|
1697
|
+
if (!clinic) {
|
|
1698
|
+
throw new Error(`Clinic ${clinicId} not found`);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// Check if practitioner is associated with this clinic
|
|
1702
|
+
if (!practitioner.clinics.includes(clinicId)) {
|
|
1703
|
+
throw new Error(
|
|
1704
|
+
`Practitioner ${practitionerId} is not associated with clinic ${clinicId}`
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
// Find the free consultation procedure for this practitioner in this clinic
|
|
1709
|
+
// Use the more specific search by technology ID instead of name
|
|
1710
|
+
const existingProcedures =
|
|
1711
|
+
await this.getProcedureService().getProceduresByPractitioner(
|
|
1712
|
+
practitionerId
|
|
1713
|
+
);
|
|
1714
|
+
const freeConsultation = existingProcedures.find(
|
|
1715
|
+
(procedure) =>
|
|
1716
|
+
procedure.technology.id === "free-consultation-tech" &&
|
|
1717
|
+
procedure.clinicBranchId === clinicId &&
|
|
1718
|
+
procedure.isActive
|
|
1719
|
+
);
|
|
1720
|
+
|
|
1721
|
+
if (!freeConsultation) {
|
|
1722
|
+
console.log(
|
|
1723
|
+
`No active free consultation found for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1724
|
+
);
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// Deactivate the consultation procedure
|
|
1729
|
+
await this.getProcedureService().deactivateProcedure(freeConsultation.id);
|
|
1730
|
+
|
|
1731
|
+
console.log(
|
|
1732
|
+
`Free consultation disabled for practitioner ${practitionerId} in clinic ${clinicId}`
|
|
1733
|
+
);
|
|
1734
|
+
} catch (error) {
|
|
1735
|
+
console.error(
|
|
1736
|
+
`Error disabling free consultation for practitioner ${practitionerId} in clinic ${clinicId}:`,
|
|
1737
|
+
error
|
|
1738
|
+
);
|
|
1739
|
+
throw error;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
}
|