@blackcode_sa/metaestetics-api 1.13.5 → 1.13.6

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.
Files changed (291) hide show
  1. package/dist/admin/index.d.mts +20 -1
  2. package/dist/admin/index.d.ts +20 -1
  3. package/dist/admin/index.js +217 -1
  4. package/dist/admin/index.mjs +217 -1
  5. package/package.json +121 -121
  6. package/src/__mocks__/firstore.ts +10 -10
  7. package/src/admin/aggregation/README.md +79 -79
  8. package/src/admin/aggregation/appointment/README.md +128 -128
  9. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
  10. package/src/admin/aggregation/appointment/index.ts +1 -1
  11. package/src/admin/aggregation/clinic/README.md +52 -52
  12. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +966 -703
  13. package/src/admin/aggregation/clinic/index.ts +1 -1
  14. package/src/admin/aggregation/forms/README.md +13 -13
  15. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  16. package/src/admin/aggregation/forms/index.ts +1 -1
  17. package/src/admin/aggregation/index.ts +8 -8
  18. package/src/admin/aggregation/patient/README.md +27 -27
  19. package/src/admin/aggregation/patient/index.ts +1 -1
  20. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  21. package/src/admin/aggregation/practitioner/README.md +42 -42
  22. package/src/admin/aggregation/practitioner/index.ts +1 -1
  23. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  24. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  25. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  26. package/src/admin/aggregation/procedure/README.md +43 -43
  27. package/src/admin/aggregation/procedure/index.ts +1 -1
  28. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  29. package/src/admin/aggregation/reviews/index.ts +1 -1
  30. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
  31. package/src/admin/analytics/analytics.admin.service.ts +278 -278
  32. package/src/admin/analytics/index.ts +2 -2
  33. package/src/admin/booking/README.md +125 -125
  34. package/src/admin/booking/booking.admin.ts +1037 -1037
  35. package/src/admin/booking/booking.calculator.ts +712 -712
  36. package/src/admin/booking/booking.types.ts +59 -59
  37. package/src/admin/booking/index.ts +3 -3
  38. package/src/admin/booking/timezones-problem.md +185 -185
  39. package/src/admin/calendar/README.md +7 -7
  40. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  41. package/src/admin/calendar/index.ts +1 -1
  42. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  43. package/src/admin/documentation-templates/index.ts +1 -1
  44. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  45. package/src/admin/free-consultation/index.ts +1 -1
  46. package/src/admin/index.ts +81 -81
  47. package/src/admin/logger/index.ts +78 -78
  48. package/src/admin/mailing/README.md +95 -95
  49. package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
  50. package/src/admin/mailing/appointment/index.ts +1 -1
  51. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  52. package/src/admin/mailing/base.mailing.service.ts +208 -208
  53. package/src/admin/mailing/index.ts +3 -3
  54. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  55. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  56. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  57. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  58. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  59. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  60. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  61. package/src/admin/notifications/index.ts +1 -1
  62. package/src/admin/notifications/notifications.admin.ts +710 -710
  63. package/src/admin/requirements/README.md +128 -128
  64. package/src/admin/requirements/index.ts +1 -1
  65. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  66. package/src/admin/users/index.ts +1 -1
  67. package/src/admin/users/user-profile.admin.ts +405 -405
  68. package/src/backoffice/constants/certification.constants.ts +13 -13
  69. package/src/backoffice/constants/index.ts +1 -1
  70. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  71. package/src/backoffice/errors/index.ts +1 -1
  72. package/src/backoffice/expo-safe/README.md +26 -26
  73. package/src/backoffice/expo-safe/index.ts +41 -41
  74. package/src/backoffice/index.ts +5 -5
  75. package/src/backoffice/services/FIXES_README.md +102 -102
  76. package/src/backoffice/services/README.md +57 -57
  77. package/src/backoffice/services/analytics.service.proposal.md +863 -863
  78. package/src/backoffice/services/analytics.service.summary.md +143 -143
  79. package/src/backoffice/services/brand.service.ts +256 -256
  80. package/src/backoffice/services/category.service.ts +384 -384
  81. package/src/backoffice/services/constants.service.ts +385 -385
  82. package/src/backoffice/services/documentation-template.service.ts +202 -202
  83. package/src/backoffice/services/index.ts +10 -10
  84. package/src/backoffice/services/migrate-products.ts +116 -116
  85. package/src/backoffice/services/product.service.ts +553 -553
  86. package/src/backoffice/services/requirement.service.ts +235 -235
  87. package/src/backoffice/services/subcategory.service.ts +461 -461
  88. package/src/backoffice/services/technology.service.ts +1151 -1151
  89. package/src/backoffice/types/README.md +12 -12
  90. package/src/backoffice/types/admin-constants.types.ts +69 -69
  91. package/src/backoffice/types/brand.types.ts +29 -29
  92. package/src/backoffice/types/category.types.ts +67 -67
  93. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  94. package/src/backoffice/types/index.ts +10 -10
  95. package/src/backoffice/types/procedure-product.types.ts +38 -38
  96. package/src/backoffice/types/product.types.ts +240 -240
  97. package/src/backoffice/types/requirement.types.ts +63 -63
  98. package/src/backoffice/types/static/README.md +18 -18
  99. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  100. package/src/backoffice/types/static/certification.types.ts +37 -37
  101. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  102. package/src/backoffice/types/static/index.ts +6 -6
  103. package/src/backoffice/types/static/pricing.types.ts +16 -16
  104. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  105. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  106. package/src/backoffice/types/subcategory.types.ts +34 -34
  107. package/src/backoffice/types/technology.types.ts +168 -168
  108. package/src/backoffice/validations/index.ts +1 -1
  109. package/src/backoffice/validations/schemas.ts +164 -164
  110. package/src/config/__mocks__/firebase.ts +99 -99
  111. package/src/config/firebase.ts +78 -78
  112. package/src/config/index.ts +9 -9
  113. package/src/errors/auth.error.ts +6 -6
  114. package/src/errors/auth.errors.ts +200 -200
  115. package/src/errors/clinic.errors.ts +32 -32
  116. package/src/errors/firebase.errors.ts +47 -47
  117. package/src/errors/user.errors.ts +99 -99
  118. package/src/index.backup.ts +407 -407
  119. package/src/index.ts +6 -6
  120. package/src/locales/en.ts +31 -31
  121. package/src/recommender/admin/index.ts +1 -1
  122. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  123. package/src/recommender/front/index.ts +1 -1
  124. package/src/recommender/front/services/onboarding.service.ts +5 -5
  125. package/src/recommender/front/services/recommender.service.ts +3 -3
  126. package/src/recommender/index.ts +1 -1
  127. package/src/services/PATIENTAUTH.MD +197 -197
  128. package/src/services/README.md +106 -106
  129. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  130. package/src/services/__tests__/auth/auth.setup.ts +293 -293
  131. package/src/services/__tests__/auth.service.test.ts +346 -346
  132. package/src/services/__tests__/base.service.test.ts +77 -77
  133. package/src/services/__tests__/user.service.test.ts +528 -528
  134. package/src/services/analytics/ARCHITECTURE.md +199 -199
  135. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
  136. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
  137. package/src/services/analytics/QUICK_START.md +393 -393
  138. package/src/services/analytics/README.md +304 -304
  139. package/src/services/analytics/SUMMARY.md +141 -141
  140. package/src/services/analytics/TRENDS.md +380 -380
  141. package/src/services/analytics/USAGE_GUIDE.md +518 -518
  142. package/src/services/analytics/analytics-cloud.service.ts +222 -222
  143. package/src/services/analytics/analytics.service.ts +2142 -2142
  144. package/src/services/analytics/index.ts +4 -4
  145. package/src/services/analytics/review-analytics.service.ts +941 -941
  146. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
  147. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
  148. package/src/services/analytics/utils/grouping.utils.ts +434 -434
  149. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
  150. package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
  151. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
  152. package/src/services/appointment/README.md +17 -17
  153. package/src/services/appointment/appointment.service.ts +2558 -2558
  154. package/src/services/appointment/index.ts +1 -1
  155. package/src/services/appointment/utils/appointment.utils.ts +552 -552
  156. package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
  157. package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
  158. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  159. package/src/services/appointment/utils/zone-management.utils.ts +353 -353
  160. package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
  161. package/src/services/auth/auth.service.ts +989 -989
  162. package/src/services/auth/auth.v2.service.ts +961 -961
  163. package/src/services/auth/index.ts +7 -7
  164. package/src/services/auth/utils/error.utils.ts +90 -90
  165. package/src/services/auth/utils/firebase.utils.ts +49 -49
  166. package/src/services/auth/utils/index.ts +21 -21
  167. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  168. package/src/services/base.service.ts +41 -41
  169. package/src/services/calendar/calendar.service.ts +1077 -1077
  170. package/src/services/calendar/calendar.v2.service.ts +1683 -1683
  171. package/src/services/calendar/calendar.v3.service.ts +313 -313
  172. package/src/services/calendar/externalCalendar.service.ts +178 -178
  173. package/src/services/calendar/index.ts +5 -5
  174. package/src/services/calendar/synced-calendars.service.ts +743 -743
  175. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  176. package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
  177. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  178. package/src/services/calendar/utils/docs.utils.ts +157 -157
  179. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  180. package/src/services/calendar/utils/index.ts +8 -8
  181. package/src/services/calendar/utils/patient.utils.ts +198 -198
  182. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  183. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  184. package/src/services/clinic/README.md +204 -204
  185. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
  186. package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
  187. package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
  188. package/src/services/clinic/billing-transactions.service.ts +217 -217
  189. package/src/services/clinic/clinic-admin.service.ts +202 -202
  190. package/src/services/clinic/clinic-group.service.ts +310 -310
  191. package/src/services/clinic/clinic.service.ts +708 -708
  192. package/src/services/clinic/index.ts +5 -5
  193. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  194. package/src/services/clinic/utils/admin.utils.ts +551 -551
  195. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  196. package/src/services/clinic/utils/clinic.utils.ts +949 -949
  197. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  198. package/src/services/clinic/utils/filter.utils.ts +446 -446
  199. package/src/services/clinic/utils/index.ts +11 -11
  200. package/src/services/clinic/utils/photos.utils.ts +188 -188
  201. package/src/services/clinic/utils/search.utils.ts +84 -84
  202. package/src/services/clinic/utils/tag.utils.ts +124 -124
  203. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  204. package/src/services/documentation-templates/filled-document.service.ts +587 -587
  205. package/src/services/documentation-templates/index.ts +2 -2
  206. package/src/services/index.ts +14 -14
  207. package/src/services/media/index.ts +1 -1
  208. package/src/services/media/media.service.ts +418 -418
  209. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  210. package/src/services/notifications/index.ts +1 -1
  211. package/src/services/notifications/notification.service.ts +215 -215
  212. package/src/services/patient/README.md +48 -48
  213. package/src/services/patient/To-Do.md +43 -43
  214. package/src/services/patient/__tests__/patient.service.test.ts +294 -294
  215. package/src/services/patient/index.ts +2 -2
  216. package/src/services/patient/patient.service.ts +883 -883
  217. package/src/services/patient/patientRequirements.service.ts +285 -285
  218. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  219. package/src/services/patient/utils/clinic.utils.ts +80 -80
  220. package/src/services/patient/utils/docs.utils.ts +142 -142
  221. package/src/services/patient/utils/index.ts +9 -9
  222. package/src/services/patient/utils/location.utils.ts +126 -126
  223. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  224. package/src/services/patient/utils/medical.utils.ts +458 -458
  225. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  226. package/src/services/patient/utils/profile.utils.ts +510 -510
  227. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  228. package/src/services/patient/utils/token.utils.ts +211 -211
  229. package/src/services/practitioner/README.md +145 -145
  230. package/src/services/practitioner/index.ts +1 -1
  231. package/src/services/practitioner/practitioner.service.ts +1742 -1742
  232. package/src/services/procedure/README.md +163 -163
  233. package/src/services/procedure/index.ts +1 -1
  234. package/src/services/procedure/procedure.service.ts +2200 -2200
  235. package/src/services/reviews/index.ts +1 -1
  236. package/src/services/reviews/reviews.service.ts +734 -734
  237. package/src/services/user/index.ts +1 -1
  238. package/src/services/user/user.service.ts +489 -489
  239. package/src/services/user/user.v2.service.ts +466 -466
  240. package/src/types/analytics/analytics.types.ts +597 -597
  241. package/src/types/analytics/grouped-analytics.types.ts +173 -173
  242. package/src/types/analytics/index.ts +4 -4
  243. package/src/types/analytics/stored-analytics.types.ts +137 -137
  244. package/src/types/appointment/index.ts +480 -480
  245. package/src/types/calendar/index.ts +258 -258
  246. package/src/types/calendar/synced-calendar.types.ts +66 -66
  247. package/src/types/clinic/index.ts +498 -498
  248. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  249. package/src/types/clinic/preferences.types.ts +159 -159
  250. package/src/types/clinic/to-do +3 -3
  251. package/src/types/documentation-templates/index.ts +308 -308
  252. package/src/types/index.ts +47 -47
  253. package/src/types/notifications/README.md +77 -77
  254. package/src/types/notifications/index.ts +286 -286
  255. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  256. package/src/types/patient/allergies.ts +58 -58
  257. package/src/types/patient/index.ts +275 -275
  258. package/src/types/patient/medical-info.types.ts +152 -152
  259. package/src/types/patient/patient-requirements.ts +92 -92
  260. package/src/types/patient/token.types.ts +61 -61
  261. package/src/types/practitioner/index.ts +206 -206
  262. package/src/types/procedure/index.ts +181 -181
  263. package/src/types/profile/index.ts +39 -39
  264. package/src/types/reviews/index.ts +132 -132
  265. package/src/types/tz-lookup.d.ts +4 -4
  266. package/src/types/user/index.ts +38 -38
  267. package/src/utils/TIMESTAMPS.md +176 -176
  268. package/src/utils/TimestampUtils.ts +241 -241
  269. package/src/utils/index.ts +1 -1
  270. package/src/validations/appointment.schema.ts +574 -574
  271. package/src/validations/calendar.schema.ts +225 -225
  272. package/src/validations/clinic.schema.ts +494 -494
  273. package/src/validations/common.schema.ts +25 -25
  274. package/src/validations/documentation-templates/index.ts +1 -1
  275. package/src/validations/documentation-templates/template.schema.ts +220 -220
  276. package/src/validations/documentation-templates.schema.ts +10 -10
  277. package/src/validations/index.ts +20 -20
  278. package/src/validations/media.schema.ts +10 -10
  279. package/src/validations/notification.schema.ts +90 -90
  280. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  281. package/src/validations/patient/medical-info.schema.ts +125 -125
  282. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  283. package/src/validations/patient/token.schema.ts +29 -29
  284. package/src/validations/patient.schema.ts +217 -217
  285. package/src/validations/practitioner.schema.ts +222 -222
  286. package/src/validations/procedure-product.schema.ts +41 -41
  287. package/src/validations/procedure.schema.ts +124 -124
  288. package/src/validations/profile-info.schema.ts +41 -41
  289. package/src/validations/reviews.schema.ts +195 -195
  290. package/src/validations/schemas.ts +104 -104
  291. 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
+ }