@blackcode_sa/metaestetics-api 1.13.5 → 1.13.8

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