@blackcode_sa/metaestetics-api 1.15.14 → 1.15.17-staging.0

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