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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/dist/admin/index.d.mts +377 -222
  2. package/dist/admin/index.d.ts +377 -222
  3. package/dist/admin/index.js +625 -206
  4. package/dist/admin/index.mjs +624 -206
  5. package/dist/backoffice/index.d.mts +24 -0
  6. package/dist/backoffice/index.d.ts +24 -0
  7. package/dist/index.d.mts +292 -4
  8. package/dist/index.d.ts +292 -4
  9. package/dist/index.js +1142 -630
  10. package/dist/index.mjs +1137 -617
  11. package/package.json +2 -1
  12. package/src/__mocks__/firstore.ts +10 -10
  13. package/src/admin/aggregation/README.md +79 -79
  14. package/src/admin/aggregation/appointment/README.md +151 -129
  15. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +2137 -2091
  16. package/src/admin/aggregation/appointment/index.ts +1 -1
  17. package/src/admin/aggregation/clinic/README.md +52 -52
  18. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +966 -966
  19. package/src/admin/aggregation/clinic/index.ts +1 -1
  20. package/src/admin/aggregation/forms/README.md +13 -13
  21. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  22. package/src/admin/aggregation/forms/index.ts +1 -1
  23. package/src/admin/aggregation/index.ts +8 -8
  24. package/src/admin/aggregation/patient/README.md +27 -27
  25. package/src/admin/aggregation/patient/index.ts +1 -1
  26. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  27. package/src/admin/aggregation/practitioner/README.md +42 -42
  28. package/src/admin/aggregation/practitioner/index.ts +1 -1
  29. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  30. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  31. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  32. package/src/admin/aggregation/procedure/README.md +43 -43
  33. package/src/admin/aggregation/procedure/index.ts +1 -1
  34. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  35. package/src/admin/aggregation/reviews/index.ts +1 -1
  36. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
  37. package/src/admin/analytics/analytics.admin.service.ts +278 -278
  38. package/src/admin/analytics/index.ts +2 -2
  39. package/src/admin/booking/README.md +184 -125
  40. package/src/admin/booking/booking.admin.ts +1330 -1073
  41. package/src/admin/booking/booking.calculator.ts +850 -712
  42. package/src/admin/booking/booking.types.ts +76 -59
  43. package/src/admin/booking/index.ts +3 -3
  44. package/src/admin/booking/timezones-problem.md +185 -185
  45. package/src/admin/calendar/README.md +62 -7
  46. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  47. package/src/admin/calendar/index.ts +2 -1
  48. package/src/admin/calendar/resource-calendar.admin.ts +198 -0
  49. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  50. package/src/admin/documentation-templates/index.ts +1 -1
  51. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  52. package/src/admin/free-consultation/index.ts +1 -1
  53. package/src/admin/index.ts +83 -83
  54. package/src/admin/logger/index.ts +78 -78
  55. package/src/admin/mailing/README.md +139 -139
  56. package/src/admin/mailing/appointment/appointment.mailing.service.ts +1253 -1253
  57. package/src/admin/mailing/appointment/index.ts +1 -1
  58. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  59. package/src/admin/mailing/base.mailing.service.ts +208 -208
  60. package/src/admin/mailing/clinicWelcome/clinicWelcome.mailing.ts +292 -292
  61. package/src/admin/mailing/clinicWelcome/index.ts +1 -1
  62. package/src/admin/mailing/clinicWelcome/templates/welcome.template.ts +225 -225
  63. package/src/admin/mailing/index.ts +5 -5
  64. package/src/admin/mailing/patientInvite/index.ts +2 -2
  65. package/src/admin/mailing/patientInvite/patientInvite.mailing.ts +415 -415
  66. package/src/admin/mailing/patientInvite/templates/invitation.template.ts +105 -105
  67. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  68. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  69. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  70. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  71. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  72. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  73. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  74. package/src/admin/notifications/index.ts +1 -1
  75. package/src/admin/notifications/notifications.admin.ts +818 -818
  76. package/src/admin/requirements/README.md +128 -128
  77. package/src/admin/requirements/index.ts +1 -1
  78. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  79. package/src/admin/users/index.ts +1 -1
  80. package/src/admin/users/user-profile.admin.ts +405 -405
  81. package/src/backoffice/constants/certification.constants.ts +13 -13
  82. package/src/backoffice/constants/index.ts +1 -1
  83. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  84. package/src/backoffice/errors/index.ts +1 -1
  85. package/src/backoffice/expo-safe/README.md +26 -26
  86. package/src/backoffice/expo-safe/index.ts +41 -41
  87. package/src/backoffice/index.ts +5 -5
  88. package/src/backoffice/services/FIXES_README.md +102 -102
  89. package/src/backoffice/services/README.md +57 -57
  90. package/src/backoffice/services/analytics.service.proposal.md +863 -863
  91. package/src/backoffice/services/analytics.service.summary.md +143 -143
  92. package/src/backoffice/services/brand.service.ts +260 -260
  93. package/src/backoffice/services/category.service.ts +384 -384
  94. package/src/backoffice/services/constants.service.ts +385 -385
  95. package/src/backoffice/services/documentation-template.service.ts +202 -202
  96. package/src/backoffice/services/index.ts +10 -10
  97. package/src/backoffice/services/migrate-products.ts +116 -116
  98. package/src/backoffice/services/product.service.ts +557 -557
  99. package/src/backoffice/services/requirement.service.ts +235 -235
  100. package/src/backoffice/services/subcategory.service.ts +461 -461
  101. package/src/backoffice/services/technology.service.ts +1153 -1153
  102. package/src/backoffice/types/README.md +12 -12
  103. package/src/backoffice/types/admin-constants.types.ts +69 -69
  104. package/src/backoffice/types/brand.types.ts +29 -29
  105. package/src/backoffice/types/category.types.ts +67 -67
  106. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  107. package/src/backoffice/types/index.ts +10 -10
  108. package/src/backoffice/types/procedure-product.types.ts +38 -38
  109. package/src/backoffice/types/product.types.ts +239 -239
  110. package/src/backoffice/types/requirement.types.ts +63 -63
  111. package/src/backoffice/types/static/README.md +18 -18
  112. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  113. package/src/backoffice/types/static/certification.types.ts +37 -37
  114. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  115. package/src/backoffice/types/static/index.ts +6 -6
  116. package/src/backoffice/types/static/pricing.types.ts +16 -16
  117. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  118. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  119. package/src/backoffice/types/subcategory.types.ts +34 -34
  120. package/src/backoffice/types/technology.types.ts +168 -168
  121. package/src/backoffice/validations/index.ts +1 -1
  122. package/src/backoffice/validations/schemas.ts +164 -164
  123. package/src/config/__mocks__/firebase.ts +99 -99
  124. package/src/config/firebase.ts +78 -78
  125. package/src/config/index.ts +17 -17
  126. package/src/config/tiers.config.ts +255 -229
  127. package/src/errors/auth.error.ts +6 -6
  128. package/src/errors/auth.errors.ts +211 -211
  129. package/src/errors/clinic.errors.ts +32 -32
  130. package/src/errors/firebase.errors.ts +47 -47
  131. package/src/errors/user.errors.ts +99 -99
  132. package/src/index.backup.ts +407 -407
  133. package/src/index.ts +6 -6
  134. package/src/locales/en.ts +31 -31
  135. package/src/recommender/admin/index.ts +1 -1
  136. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  137. package/src/recommender/front/index.ts +1 -1
  138. package/src/recommender/front/services/onboarding.service.ts +5 -5
  139. package/src/recommender/front/services/recommender.service.ts +3 -3
  140. package/src/recommender/index.ts +1 -1
  141. package/src/services/PATIENTAUTH.MD +197 -197
  142. package/src/services/README.md +106 -106
  143. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  144. package/src/services/__tests__/auth/auth.setup.ts +298 -298
  145. package/src/services/__tests__/auth.service.test.ts +310 -310
  146. package/src/services/__tests__/base.service.test.ts +36 -36
  147. package/src/services/__tests__/user.service.test.ts +530 -530
  148. package/src/services/analytics/ARCHITECTURE.md +199 -199
  149. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
  150. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
  151. package/src/services/analytics/QUICK_START.md +393 -393
  152. package/src/services/analytics/README.md +304 -304
  153. package/src/services/analytics/SUMMARY.md +141 -141
  154. package/src/services/analytics/TRENDS.md +380 -380
  155. package/src/services/analytics/USAGE_GUIDE.md +518 -518
  156. package/src/services/analytics/analytics-cloud.service.ts +222 -222
  157. package/src/services/analytics/analytics.service.ts +2148 -2148
  158. package/src/services/analytics/index.ts +4 -4
  159. package/src/services/analytics/review-analytics.service.ts +941 -941
  160. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
  161. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
  162. package/src/services/analytics/utils/grouping.utils.ts +434 -434
  163. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
  164. package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
  165. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
  166. package/src/services/appointment/README.md +17 -17
  167. package/src/services/appointment/appointment.service.ts +2943 -2941
  168. package/src/services/appointment/index.ts +1 -1
  169. package/src/services/appointment/utils/appointment.utils.ts +620 -620
  170. package/src/services/appointment/utils/extended-procedure.utils.ts +354 -354
  171. package/src/services/appointment/utils/form-initialization.utils.ts +516 -516
  172. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  173. package/src/services/appointment/utils/zone-management.utils.ts +468 -468
  174. package/src/services/appointment/utils/zone-photo.utils.ts +302 -302
  175. package/src/services/auth/auth.service.ts +1435 -1435
  176. package/src/services/auth/auth.v2.service.ts +961 -961
  177. package/src/services/auth/index.ts +7 -7
  178. package/src/services/auth/utils/error.utils.ts +90 -90
  179. package/src/services/auth/utils/firebase.utils.ts +49 -49
  180. package/src/services/auth/utils/index.ts +21 -21
  181. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  182. package/src/services/base.service.ts +41 -41
  183. package/src/services/calendar/calendar.service.ts +1077 -1077
  184. package/src/services/calendar/calendar.v2.service.ts +1693 -1693
  185. package/src/services/calendar/calendar.v3.service.ts +313 -313
  186. package/src/services/calendar/externalCalendar.service.ts +178 -178
  187. package/src/services/calendar/index.ts +5 -5
  188. package/src/services/calendar/synced-calendars.service.ts +743 -743
  189. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  190. package/src/services/calendar/utils/calendar-event.utils.ts +676 -676
  191. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  192. package/src/services/calendar/utils/docs.utils.ts +157 -157
  193. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  194. package/src/services/calendar/utils/index.ts +8 -8
  195. package/src/services/calendar/utils/patient.utils.ts +198 -198
  196. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  197. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  198. package/src/services/clinic/README.md +204 -204
  199. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +265 -265
  200. package/src/services/clinic/__tests__/clinic-group.service.test.ts +222 -222
  201. package/src/services/clinic/__tests__/clinic.service.test.ts +302 -302
  202. package/src/services/clinic/billing-transactions.service.ts +217 -217
  203. package/src/services/clinic/clinic-admin.service.ts +202 -202
  204. package/src/services/clinic/clinic-group.service.ts +310 -310
  205. package/src/services/clinic/clinic.service.ts +720 -720
  206. package/src/services/clinic/index.ts +5 -5
  207. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  208. package/src/services/clinic/utils/admin.utils.ts +551 -551
  209. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  210. package/src/services/clinic/utils/clinic.utils.ts +1023 -1023
  211. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  212. package/src/services/clinic/utils/filter.utils.ts +462 -462
  213. package/src/services/clinic/utils/index.ts +10 -10
  214. package/src/services/clinic/utils/photos.utils.ts +188 -188
  215. package/src/services/clinic/utils/search.utils.ts +83 -83
  216. package/src/services/clinic/utils/tag.utils.ts +124 -124
  217. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  218. package/src/services/documentation-templates/filled-document.service.ts +597 -597
  219. package/src/services/documentation-templates/index.ts +2 -2
  220. package/src/services/index.ts +16 -15
  221. package/src/services/media/index.ts +1 -1
  222. package/src/services/media/media.service.ts +418 -418
  223. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  224. package/src/services/notifications/index.ts +1 -1
  225. package/src/services/notifications/notification.service.ts +215 -215
  226. package/src/services/patient/README.md +48 -48
  227. package/src/services/patient/To-Do.md +43 -43
  228. package/src/services/patient/__tests__/patient.service.test.ts +286 -286
  229. package/src/services/patient/index.ts +2 -2
  230. package/src/services/patient/patient.service.ts +1021 -1021
  231. package/src/services/patient/patientRequirements.service.ts +309 -309
  232. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  233. package/src/services/patient/utils/body-assessment.utils.ts +159 -159
  234. package/src/services/patient/utils/clinic.utils.ts +159 -159
  235. package/src/services/patient/utils/docs.utils.ts +142 -142
  236. package/src/services/patient/utils/hair-scalp-assessment.utils.ts +158 -158
  237. package/src/services/patient/utils/index.ts +9 -9
  238. package/src/services/patient/utils/location.utils.ts +126 -126
  239. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  240. package/src/services/patient/utils/medical.utils.ts +458 -458
  241. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  242. package/src/services/patient/utils/pre-surgical-assessment.utils.ts +161 -161
  243. package/src/services/patient/utils/profile.utils.ts +510 -510
  244. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  245. package/src/services/patient/utils/skin-quality-assessment.utils.ts +160 -160
  246. package/src/services/patient/utils/token.utils.ts +211 -211
  247. package/src/services/practitioner/README.md +145 -145
  248. package/src/services/practitioner/index.ts +1 -1
  249. package/src/services/practitioner/practitioner.service.ts +2355 -2354
  250. package/src/services/procedure/README.md +163 -163
  251. package/src/services/procedure/index.ts +1 -1
  252. package/src/services/procedure/procedure.service.ts +2521 -2521
  253. package/src/services/resource/README.md +119 -0
  254. package/src/services/resource/index.ts +1 -0
  255. package/src/services/resource/resource.service.ts +555 -0
  256. package/src/services/reviews/index.ts +1 -1
  257. package/src/services/reviews/reviews.service.ts +745 -745
  258. package/src/services/tier-enforcement.ts +240 -240
  259. package/src/services/user/index.ts +1 -1
  260. package/src/services/user/user.service.ts +533 -533
  261. package/src/services/user/user.v2.service.ts +467 -467
  262. package/src/types/analytics/analytics.types.ts +597 -597
  263. package/src/types/analytics/grouped-analytics.types.ts +173 -173
  264. package/src/types/analytics/index.ts +4 -4
  265. package/src/types/analytics/stored-analytics.types.ts +137 -137
  266. package/src/types/appointment/index.ts +524 -517
  267. package/src/types/calendar/index.ts +261 -260
  268. package/src/types/calendar/synced-calendar.types.ts +66 -66
  269. package/src/types/clinic/index.ts +530 -529
  270. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  271. package/src/types/clinic/preferences.types.ts +159 -159
  272. package/src/types/clinic/rbac.types.ts +64 -63
  273. package/src/types/clinic/to-do +3 -3
  274. package/src/types/documentation-templates/index.ts +308 -308
  275. package/src/types/index.ts +50 -47
  276. package/src/types/notifications/README.md +77 -77
  277. package/src/types/notifications/index.ts +300 -300
  278. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  279. package/src/types/patient/allergies.ts +58 -58
  280. package/src/types/patient/body-assessment.types.ts +93 -93
  281. package/src/types/patient/hair-scalp-assessment.types.ts +98 -98
  282. package/src/types/patient/index.ts +279 -279
  283. package/src/types/patient/medical-info.types.ts +152 -152
  284. package/src/types/patient/patient-requirements.ts +92 -92
  285. package/src/types/patient/pre-surgical-assessment.types.ts +95 -95
  286. package/src/types/patient/skin-quality-assessment.types.ts +105 -105
  287. package/src/types/patient/token.types.ts +61 -61
  288. package/src/types/practitioner/index.ts +208 -208
  289. package/src/types/procedure/index.ts +189 -183
  290. package/src/types/profile/index.ts +39 -39
  291. package/src/types/resource/README.md +153 -0
  292. package/src/types/resource/index.ts +199 -0
  293. package/src/types/reviews/index.ts +132 -132
  294. package/src/types/tz-lookup.d.ts +4 -4
  295. package/src/types/user/index.ts +60 -60
  296. package/src/utils/TIMESTAMPS.md +176 -176
  297. package/src/utils/TimestampUtils.ts +241 -241
  298. package/src/utils/index.ts +1 -1
  299. package/src/validations/README.md +94 -0
  300. package/src/validations/appointment.schema.ts +589 -589
  301. package/src/validations/calendar.schema.ts +225 -225
  302. package/src/validations/clinic.schema.ts +494 -494
  303. package/src/validations/common.schema.ts +25 -25
  304. package/src/validations/documentation-templates/index.ts +1 -1
  305. package/src/validations/documentation-templates/template.schema.ts +220 -220
  306. package/src/validations/documentation-templates.schema.ts +10 -10
  307. package/src/validations/index.ts +21 -20
  308. package/src/validations/media.schema.ts +10 -10
  309. package/src/validations/notification.schema.ts +90 -90
  310. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  311. package/src/validations/patient/body-assessment.schema.ts +82 -82
  312. package/src/validations/patient/hair-scalp-assessment.schema.ts +70 -70
  313. package/src/validations/patient/medical-info.schema.ts +177 -177
  314. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  315. package/src/validations/patient/pre-surgical-assessment.schema.ts +78 -78
  316. package/src/validations/patient/skin-quality-assessment.schema.ts +70 -70
  317. package/src/validations/patient/token.schema.ts +29 -29
  318. package/src/validations/patient.schema.ts +217 -217
  319. package/src/validations/practitioner.schema.ts +224 -224
  320. package/src/validations/procedure-product.schema.ts +41 -41
  321. package/src/validations/procedure.schema.ts +136 -124
  322. package/src/validations/profile-info.schema.ts +41 -41
  323. package/src/validations/resource.schema.ts +57 -0
  324. package/src/validations/reviews.schema.ts +195 -195
  325. package/src/validations/schemas.ts +109 -109
  326. package/src/validations/shared.schema.ts +78 -78
@@ -1,2521 +1,2521 @@
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
- DocumentData,
14
- writeBatch,
15
- arrayUnion,
16
- arrayRemove,
17
- FieldValue,
18
- orderBy,
19
- limit,
20
- startAfter,
21
- QueryConstraint,
22
- documentId,
23
- } from 'firebase/firestore';
24
- import { BaseService } from '../base.service';
25
- import { enforceProcedureLimit } from '../tier-enforcement';
26
- import {
27
- Procedure,
28
- CreateProcedureData,
29
- UpdateProcedureData,
30
- PROCEDURES_COLLECTION,
31
- ProcedureSummaryInfo,
32
- } from '../../types/procedure';
33
- import { createProcedureSchema, updateProcedureSchema } from '../../validations/procedure.schema';
34
- import { z } from 'zod';
35
- import { Auth } from 'firebase/auth';
36
- import { Firestore } from 'firebase/firestore';
37
- import { FirebaseApp } from 'firebase/app';
38
- import { Category, CATEGORIES_COLLECTION } from '../../backoffice/types/category.types';
39
- import { Subcategory, SUBCATEGORIES_COLLECTION } from '../../backoffice/types/subcategory.types';
40
- import { Technology, TECHNOLOGIES_COLLECTION } from '../../backoffice/types/technology.types';
41
- import { Product, PRODUCTS_COLLECTION } from '../../backoffice/types/product.types';
42
- import { CategoryService } from '../../backoffice/services/category.service';
43
- import { SubcategoryService } from '../../backoffice/services/subcategory.service';
44
- import { TechnologyService } from '../../backoffice/services/technology.service';
45
- import { ProductService } from '../../backoffice/services/product.service';
46
- import { Practitioner, PRACTITIONERS_COLLECTION } from '../../types/practitioner';
47
- import {
48
- CertificationLevel,
49
- CertificationSpecialty,
50
- ProcedureFamily,
51
- type TreatmentBenefitDynamic,
52
- } from '../../backoffice/types';
53
- import { Currency, PricingMeasure } from '../../backoffice/types/static/pricing.types';
54
- import { Clinic, CLINICS_COLLECTION } from '../../types/clinic';
55
- import { ProcedureReviewInfo } from '../../types/reviews';
56
- import { distanceBetween, geohashQueryBounds } from 'geofire-common';
57
- import { MediaService, MediaAccessLevel } from '../media/media.service';
58
- import type { ProcedureProduct } from '../../backoffice/types/procedure-product.types';
59
-
60
- import { PractitionerService } from '../practitioner/practitioner.service';
61
- import { PractitionerStatus } from '../../types/practitioner';
62
-
63
- export class ProcedureService extends BaseService {
64
- private categoryService: CategoryService;
65
- private subcategoryService: SubcategoryService;
66
- private technologyService: TechnologyService;
67
- private productService: ProductService;
68
- private mediaService: MediaService;
69
- private practitionerService?: PractitionerService;
70
-
71
- constructor(
72
- db: Firestore,
73
- auth: Auth,
74
- app: FirebaseApp,
75
- categoryService: CategoryService,
76
- subcategoryService: SubcategoryService,
77
- technologyService: TechnologyService,
78
- productService: ProductService,
79
- mediaService: MediaService,
80
- ) {
81
- super(db, auth, app);
82
- this.categoryService = categoryService;
83
- this.subcategoryService = subcategoryService;
84
- this.technologyService = technologyService;
85
- this.productService = productService;
86
- this.mediaService = mediaService;
87
- }
88
-
89
- setPractitionerService(practitionerService: PractitionerService) {
90
- this.practitionerService = practitionerService;
91
- }
92
-
93
- /**
94
- * Filters out procedures that should not be visible to patients:
95
- * 1. Procedures with no practitioner (missing practitionerId)
96
- * 2. Procedures where practitioner doesn't exist
97
- * 3. Procedures from draft practitioners
98
- * 4. Procedures from inactive practitioners (isActive === false)
99
- *
100
- * Note: Each procedure has ONE practitionerId. If that practitioner is inactive/draft/missing,
101
- * the procedure is filtered out.
102
- *
103
- * @param procedures - Array of procedures to filter
104
- * @returns Filtered array of procedures (excluding invalid/inactive/draft practitioners)
105
- */
106
- private async filterDraftPractitionerProcedures(
107
- procedures: Procedure[]
108
- ): Promise<Procedure[]> {
109
- console.log(`[ProcedureService] filterDraftPractitionerProcedures called with ${procedures.length} procedures, practitionerService available: ${!!this.practitionerService}`);
110
- if (!this.practitionerService || procedures.length === 0) {
111
- if (!this.practitionerService) {
112
- console.warn('[ProcedureService] practitionerService not available - skipping filtering');
113
- }
114
- return procedures;
115
- }
116
-
117
- try {
118
- // First, filter out procedures with no practitionerId
119
- const proceduresWithPractitioner = procedures.filter((p) =>
120
- p.practitionerId && p.practitionerId.trim() !== ''
121
- );
122
-
123
- if (proceduresWithPractitioner.length === 0) {
124
- console.log(
125
- `[ProcedureService] All ${procedures.length} procedures have no practitionerId - filtering out`
126
- );
127
- return [];
128
- }
129
-
130
- // Get unique practitioner IDs from procedures
131
- const practitionerIds = Array.from(
132
- new Set(proceduresWithPractitioner.map((p) => p.practitionerId).filter(Boolean))
133
- );
134
-
135
- if (practitionerIds.length === 0) {
136
- return [];
137
- }
138
-
139
- // Fetch all practitioners in parallel
140
- const practitionerPromises = practitionerIds.map((id) =>
141
- this.practitionerService!.getPractitioner(id).catch(() => null)
142
- );
143
- const practitioners = await Promise.all(practitionerPromises);
144
-
145
- // Create a map of practitioner ID to practitioner data
146
- const practitionerMap = new Map<string, Practitioner>();
147
- practitioners.forEach((practitioner, index) => {
148
- if (practitioner) {
149
- practitionerMap.set(practitionerIds[index], practitioner);
150
- }
151
- });
152
-
153
- // Filter out procedures that:
154
- // 1. Have no practitionerId (already filtered above, but double-check)
155
- // 2. Have a practitioner that doesn't exist
156
- // 3. Have a practitioner with DRAFT status
157
- // 4. Have a practitioner that is not active (isActive === false)
158
- const filteredProcedures = proceduresWithPractitioner.filter((procedure) => {
159
- // Check if practitioner exists
160
- const practitioner = practitionerMap.get(procedure.practitionerId);
161
-
162
- if (!practitioner) {
163
- console.log(
164
- `[ProcedureService] Filtering out procedure ${procedure.id} - practitioner ${procedure.practitionerId} not found`
165
- );
166
- return false;
167
- }
168
-
169
- // Check if practitioner is DRAFT
170
- if (practitioner.status === PractitionerStatus.DRAFT) {
171
- console.log(
172
- `[ProcedureService] Filtering out procedure ${procedure.id} - practitioner ${procedure.practitionerId} is DRAFT`
173
- );
174
- return false;
175
- }
176
-
177
- // Check if practitioner is active (must be true to show procedure)
178
- if (!practitioner.isActive) {
179
- console.log(
180
- `[ProcedureService] Filtering out procedure ${procedure.id} - practitioner ${procedure.practitionerId} is not active`
181
- );
182
- return false;
183
- }
184
-
185
- return true;
186
- });
187
-
188
- const filteredCount = procedures.length - filteredProcedures.length;
189
- if (filteredCount > 0) {
190
- const noPractitionerCount = procedures.length - proceduresWithPractitioner.length;
191
- const invalidPractitionerCount = proceduresWithPractitioner.length - filteredProcedures.length;
192
- console.log(
193
- `[ProcedureService] Filtered out ${filteredCount} procedures: ` +
194
- `${noPractitionerCount} with no practitionerId, ` +
195
- `${invalidPractitionerCount} with missing/draft/inactive practitioners`
196
- );
197
- }
198
-
199
- return filteredProcedures;
200
- } catch (error) {
201
- console.error(
202
- '[ProcedureService] Error filtering practitioner procedures:',
203
- error
204
- );
205
- // On error, return original procedures to avoid breaking functionality
206
- return procedures;
207
- }
208
- }
209
-
210
- /**
211
- * Process media resource (string URL or File object)
212
- * @param media String URL or File object
213
- * @param ownerId Owner ID for the media (usually procedureId)
214
- * @param collectionName Collection name for organizing files
215
- * @returns URL string after processing
216
- */
217
- private async processMedia(
218
- media: string | File | Blob | null | undefined,
219
- ownerId: string,
220
- collectionName: string,
221
- ): Promise<string | null> {
222
- if (!media) return null;
223
-
224
- // If already a string URL, return it directly
225
- if (typeof media === 'string') {
226
- return media;
227
- }
228
-
229
- // If it's a File, upload it using MediaService
230
- if (media instanceof File || media instanceof Blob) {
231
- console.log(`[ProcedureService] Uploading ${collectionName} media for ${ownerId}`);
232
- const metadata = await this.mediaService.uploadMedia(
233
- media,
234
- ownerId,
235
- MediaAccessLevel.PUBLIC,
236
- collectionName,
237
- );
238
- return metadata.url;
239
- }
240
-
241
- return null;
242
- }
243
-
244
- /**
245
- * Process array of media resources (strings or Files)
246
- * @param mediaArray Array of string URLs or File objects
247
- * @param ownerId Owner ID for the media
248
- * @param collectionName Collection name for organizing files
249
- * @returns Array of URL strings after processing
250
- */
251
- private async processMediaArray(
252
- mediaArray: (string | File | Blob)[] | undefined,
253
- ownerId: string,
254
- collectionName: string,
255
- ): Promise<string[]> {
256
- if (!mediaArray || mediaArray.length === 0) return [];
257
-
258
- const result: string[] = [];
259
-
260
- for (const media of mediaArray) {
261
- const processedUrl = await this.processMedia(media, ownerId, collectionName);
262
- if (processedUrl) {
263
- result.push(processedUrl);
264
- }
265
- }
266
-
267
- return result;
268
- }
269
-
270
- /**
271
- * Transforms validated procedure product data (with productId) to ProcedureProduct objects (with full product)
272
- * @param productsMetadata Array of validated procedure product data (optional)
273
- * @param technologyId Technology ID to fetch products from
274
- * @returns Array of ProcedureProduct objects with full product information
275
- */
276
- private async transformProductsMetadata(
277
- productsMetadata: {
278
- productId: string;
279
- price: number;
280
- currency: Currency;
281
- pricingMeasure: PricingMeasure;
282
- isDefault?: boolean;
283
- }[] | undefined,
284
- technologyId: string,
285
- ): Promise<ProcedureProduct[]> {
286
- // Return empty array if no products metadata provided (for product-free procedures like consultations)
287
- if (!productsMetadata || productsMetadata.length === 0) {
288
- return [];
289
- }
290
-
291
- const transformedProducts: ProcedureProduct[] = [];
292
-
293
- for (const productData of productsMetadata) {
294
- // Fetch the full product object
295
- const product = await this.productService.getById(technologyId, productData.productId);
296
- if (!product) {
297
- throw new Error(
298
- `Product with ID ${productData.productId} not found for technology ${technologyId}`,
299
- );
300
- }
301
-
302
- // Transform to ProcedureProduct
303
- transformedProducts.push({
304
- product,
305
- price: productData.price,
306
- currency: productData.currency,
307
- pricingMeasure: productData.pricingMeasure,
308
- isDefault: productData.isDefault,
309
- });
310
- }
311
-
312
- return transformedProducts;
313
- }
314
-
315
- /**
316
- * Creates a new procedure
317
- * @param data - The data for creating a new procedure
318
- * @returns The created procedure
319
- */
320
- async createProcedure(data: CreateProcedureData): Promise<Procedure> {
321
- const validatedData = createProcedureSchema.parse(data);
322
-
323
- // Check if this is a product-free procedure (e.g., free consultation)
324
- const isProductFree = !validatedData.productId;
325
-
326
- // Generate procedure ID first so we can use it for media uploads
327
- const procedureId = this.generateId();
328
-
329
- // Get references to related entities (Category, Subcategory, Technology, and optionally Product)
330
- const baseEntitiesPromises: Promise<Category | Subcategory | Technology | Product | null>[] = [
331
- this.categoryService.getById(validatedData.categoryId),
332
- this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
333
- this.technologyService.getById(validatedData.technologyId),
334
- ];
335
-
336
- // Only fetch product if productId is provided
337
- if (!isProductFree) {
338
- baseEntitiesPromises.push(
339
- this.productService.getById(validatedData.technologyId, validatedData.productId!)
340
- );
341
- }
342
-
343
- const results = await Promise.all(baseEntitiesPromises);
344
- const category = results[0] as Category | null;
345
- const subcategory = results[1] as Subcategory | null;
346
- const technology = results[2] as Technology | null;
347
- const product = isProductFree ? undefined : ((results[3] as Product | null) || undefined);
348
-
349
- if (!category || !subcategory || !technology) {
350
- throw new Error('One or more required base entities not found');
351
- }
352
-
353
- // For regular procedures, validate product exists
354
- if (!isProductFree && !product) {
355
- throw new Error('Product not found for regular procedure');
356
- }
357
-
358
- // Get clinic and practitioner information for aggregation
359
- const clinicRef = doc(this.db, CLINICS_COLLECTION, validatedData.clinicBranchId);
360
- const clinicSnapshot = await getDoc(clinicRef);
361
- if (!clinicSnapshot.exists()) {
362
- throw new Error(`Clinic with ID ${validatedData.clinicBranchId} not found`);
363
- }
364
- const clinic = clinicSnapshot.data() as Clinic; // Assert type
365
-
366
- // Enforce tier limit before creating procedure (per-provider, per-branch)
367
- await enforceProcedureLimit(this.db, clinic.clinicGroupId, validatedData.clinicBranchId, validatedData.practitionerId);
368
-
369
- const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, validatedData.practitionerId);
370
- const practitionerSnapshot = await getDoc(practitionerRef);
371
- if (!practitionerSnapshot.exists()) {
372
- throw new Error(`Practitioner with ID ${validatedData.practitionerId} not found`);
373
- }
374
- const practitioner = practitionerSnapshot.data() as Practitioner; // Assert type
375
-
376
- // Check if practitioner already has a procedure with the same technology ID in this clinic branch
377
- const existingProceduresQuery = query(
378
- collection(this.db, PROCEDURES_COLLECTION),
379
- where('practitionerId', '==', validatedData.practitionerId),
380
- where('clinicBranchId', '==', validatedData.clinicBranchId),
381
- where('isActive', '==', true)
382
- );
383
- const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
384
- const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
385
-
386
- const hasSameTechnology = existingProcedures.some(
387
- proc => proc.technology?.id === validatedData.technologyId
388
- );
389
- if (hasSameTechnology) {
390
- throw new Error(
391
- `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} already has a procedure with technology "${technology?.name || validatedData.technologyId}" in this clinic branch`
392
- );
393
- }
394
-
395
- // Process photos if provided
396
- let processedPhotos: string[] = [];
397
- if (validatedData.photos && validatedData.photos.length > 0) {
398
- processedPhotos = await this.processMediaArray(
399
- validatedData.photos,
400
- procedureId,
401
- 'procedure-photos',
402
- );
403
- }
404
-
405
- // If no photos provided and technology has a photoTemplate, use it as default photo
406
- if (processedPhotos.length === 0 && technology.photoTemplate) {
407
- console.log(`[ProcedureService] Using technology photoTemplate as default photo: ${technology.photoTemplate}`);
408
- const photoTemplateUrl = await this.mediaService.getMediaDownloadUrl(technology.photoTemplate);
409
- if (photoTemplateUrl) {
410
- processedPhotos.push(photoTemplateUrl);
411
- } else {
412
- console.warn(`[ProcedureService] Could not fetch photoTemplate URL for media ID: ${technology.photoTemplate}`);
413
- }
414
- }
415
-
416
- // Transform productsMetadata from validation format to ProcedureProduct format
417
- const transformedProductsMetadata = await this.transformProductsMetadata(
418
- validatedData.productsMetadata,
419
- validatedData.technologyId,
420
- );
421
-
422
- // Create aggregated clinic info for the procedure document
423
- const clinicInfo = {
424
- id: clinicSnapshot.id,
425
- name: clinic.name,
426
- description: clinic.description || '',
427
- featuredPhoto:
428
- clinic.featuredPhotos && clinic.featuredPhotos.length > 0
429
- ? typeof clinic.featuredPhotos[0] === 'string'
430
- ? clinic.featuredPhotos[0]
431
- : ''
432
- : typeof clinic.coverPhoto === 'string'
433
- ? clinic.coverPhoto
434
- : '',
435
- location: clinic.location,
436
- contactInfo: clinic.contactInfo,
437
- };
438
-
439
- // Create aggregated doctor info for the procedure document
440
- const doctorInfo = {
441
- id: practitionerSnapshot.id,
442
- name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
443
- description: practitioner.basicInfo.bio || '',
444
- photo:
445
- typeof practitioner.basicInfo.profileImageUrl === 'string'
446
- ? practitioner.basicInfo.profileImageUrl
447
- : '', // Default to empty string if not a processed URL
448
- rating: practitioner.reviewInfo?.averageRating || 0,
449
- services: practitioner.procedures || [],
450
- };
451
-
452
- // Create the procedure object
453
- const { productsMetadata: _, productId: __, photos: ___, ...validatedDataWithoutProductsMetadata } = validatedData;
454
- const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
455
- id: procedureId,
456
- ...validatedDataWithoutProductsMetadata,
457
- // Ensure nameLower is always set even if omitted by client
458
- nameLower: (validatedData as any).nameLower || validatedData.name.toLowerCase(),
459
- photos: processedPhotos,
460
- category, // Embed full objects
461
- subcategory,
462
- technology,
463
- ...(product && { product }), // Only include product field if it exists (Firestore doesn't allow undefined)
464
- productsMetadata: transformedProductsMetadata, // Use transformed data, not original
465
- blockingConditions: technology.blockingConditions,
466
- contraindications: technology.contraindications || [],
467
- contraindicationIds: technology.contraindications?.map(c => c.id) || [],
468
- treatmentBenefits: technology.benefits,
469
- treatmentBenefitIds: Array.isArray(technology.benefits)
470
- ? technology.benefits.map(b => typeof b === 'string' ? b : b.id)
471
- : [],
472
- preRequirements: technology.requirements.pre,
473
- postRequirements: technology.requirements.post,
474
- certificationRequirement: technology.certificationRequirement,
475
- documentationTemplates: technology?.documentationTemplates || [],
476
- clinicInfo, // Embed aggregated info
477
- doctorInfo, // Embed aggregated info
478
- reviewInfo: {
479
- // Default empty reviews
480
- totalReviews: 0,
481
- averageRating: 0,
482
- effectivenessOfTreatment: 0,
483
- outcomeExplanation: 0,
484
- painManagement: 0,
485
- followUpCare: 0,
486
- valueForMoney: 0,
487
- recommendationPercentage: 0,
488
- },
489
- isActive: true, // Default to active
490
- };
491
-
492
- // 🔥 DEBUG: Find undefined fields before writing to Firestore
493
- console.log('🔥🔥🔥 CREATE PROCEDURE - Processing procedure:', procedureId);
494
- console.log('🔥🔥🔥 FULL PROCEDURE OBJECT:', JSON.stringify(newProcedure, null, 2));
495
-
496
- const undefinedFields: string[] = [];
497
- Object.entries(newProcedure).forEach(([key, value]) => {
498
- if (value === undefined) {
499
- undefinedFields.push(key);
500
- }
501
- });
502
- if (undefinedFields.length > 0) {
503
- console.error('🔥🔥🔥 UNDEFINED FIELDS DETECTED:', undefinedFields);
504
- throw new Error(`Cannot write procedure with undefined fields: ${undefinedFields.join(', ')}`);
505
- }
506
- console.log('🔥🔥🔥 NO UNDEFINED FIELDS - Proceeding with setDoc');
507
-
508
- // Create the procedure document
509
- const procedureRef = doc(this.db, PROCEDURES_COLLECTION, procedureId);
510
- await setDoc(procedureRef, {
511
- ...newProcedure,
512
- createdAt: serverTimestamp(),
513
- updatedAt: serverTimestamp(),
514
- });
515
-
516
- // Return the created procedure (fetch again to get server timestamps)
517
- const savedDoc = await getDoc(procedureRef);
518
- return savedDoc.data() as Procedure;
519
- }
520
-
521
- /**
522
- * Validates if a practitioner can perform a procedure based on certification requirements.
523
- *
524
- * @param procedure - The procedure to check
525
- * @param practitioner - The practitioner to validate
526
- * @returns true if practitioner can perform the procedure, false otherwise
527
- */
528
- canPractitionerPerformProcedure(procedure: Procedure, practitioner: Practitioner): boolean {
529
- if (!practitioner.certification) {
530
- return false;
531
- }
532
-
533
- const requiredCert = procedure.certificationRequirement;
534
- const practitionerCert = practitioner.certification;
535
-
536
- // Check certification level
537
- const levelOrder = [
538
- 'aesthetician',
539
- 'nurse_assistant',
540
- 'nurse',
541
- 'nurse_practitioner',
542
- 'physician_assistant',
543
- 'doctor',
544
- 'specialist',
545
- 'plastic_surgeon',
546
- ];
547
-
548
- const practitionerLevelIndex = levelOrder.indexOf(practitionerCert.level);
549
- const requiredLevelIndex = levelOrder.indexOf(requiredCert.minimumLevel);
550
-
551
- if (practitionerLevelIndex < requiredLevelIndex) {
552
- return false;
553
- }
554
-
555
- // Check required specialties
556
- const requiredSpecialties = requiredCert.requiredSpecialties || [];
557
- if (requiredSpecialties.length > 0) {
558
- const practitionerSpecialties = practitionerCert.specialties || [];
559
- const hasAllRequired = requiredSpecialties.every(specialty =>
560
- practitionerSpecialties.includes(specialty)
561
- );
562
- if (!hasAllRequired) {
563
- return false;
564
- }
565
- }
566
-
567
- return true;
568
- }
569
-
570
- /**
571
- * Clones an existing procedure for a target practitioner.
572
- * This creates a new procedure document with the same data as the source procedure,
573
- * but linked to the target practitioner.
574
- *
575
- * @param sourceProcedureId - The ID of the procedure to clone
576
- * @param targetPractitionerId - The ID of the practitioner to assign the cloned procedure to
577
- * @param overrides - Optional overrides for the new procedure (e.g. price, duration, isActive)
578
- * @returns The newly created procedure
579
- */
580
- async cloneProcedureForPractitioner(
581
- sourceProcedureId: string,
582
- targetPractitionerId: string,
583
- overrides?: Partial<CreateProcedureData> & { isActive?: boolean }
584
- ): Promise<Procedure> {
585
- // 1. Fetch source procedure
586
- const sourceProcedure = await this.getProcedure(sourceProcedureId);
587
- if (!sourceProcedure) {
588
- throw new Error(`Source procedure with ID ${sourceProcedureId} not found`);
589
- }
590
-
591
- // 2. Fetch target practitioner
592
- const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, targetPractitionerId);
593
- const practitionerSnapshot = await getDoc(practitionerRef);
594
- if (!practitionerSnapshot.exists()) {
595
- throw new Error(`Target practitioner with ID ${targetPractitionerId} not found`);
596
- }
597
- const practitioner = practitionerSnapshot.data() as Practitioner;
598
-
599
- // 3. Validate certification
600
- if (!this.canPractitionerPerformProcedure(sourceProcedure, practitioner)) {
601
- throw new Error(
602
- `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} does not meet the certification requirements for this procedure`
603
- );
604
- }
605
-
606
- // 4. Check if practitioner already has a procedure with the same technology ID in this clinic branch
607
- const existingProceduresQuery = query(
608
- collection(this.db, PROCEDURES_COLLECTION),
609
- where('practitionerId', '==', targetPractitionerId),
610
- where('clinicBranchId', '==', sourceProcedure.clinicBranchId),
611
- where('isActive', '==', true)
612
- );
613
- const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
614
- const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
615
-
616
- const hasSameTechnology = existingProcedures.some(
617
- proc => proc.technology?.id === sourceProcedure.technology?.id
618
- );
619
- if (hasSameTechnology) {
620
- throw new Error(
621
- `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} already has a procedure with technology "${sourceProcedure.technology?.name || sourceProcedure.technology?.id}" in this clinic branch`
622
- );
623
- }
624
-
625
- // 5. Prepare data for new procedure
626
- const newProcedureId = this.generateId();
627
-
628
- // Create aggregated doctor info for the new procedure
629
- const doctorInfo = {
630
- id: practitioner.id,
631
- name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
632
- description: practitioner.basicInfo.bio || '',
633
- photo:
634
- typeof practitioner.basicInfo.profileImageUrl === 'string'
635
- ? practitioner.basicInfo.profileImageUrl
636
- : '',
637
- rating: practitioner.reviewInfo?.averageRating || 0,
638
- services: practitioner.procedures || [],
639
- };
640
-
641
- // Construct the new procedure object
642
- // We copy everything from source, but override specific fields
643
- const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
644
- ...sourceProcedure,
645
- id: newProcedureId,
646
- practitionerId: targetPractitionerId,
647
- doctorInfo, // Link to new doctor
648
-
649
- // Reset review info for the new procedure
650
- reviewInfo: {
651
- totalReviews: 0,
652
- averageRating: 0,
653
- effectivenessOfTreatment: 0,
654
- outcomeExplanation: 0,
655
- painManagement: 0,
656
- followUpCare: 0,
657
- valueForMoney: 0,
658
- recommendationPercentage: 0,
659
- },
660
-
661
- // Apply any overrides if provided
662
- ...(overrides?.price !== undefined && { price: overrides.price }),
663
- ...(overrides?.duration !== undefined && { duration: overrides.duration }),
664
- ...(overrides?.description !== undefined && { description: overrides.description }),
665
-
666
- // Ensure it's active by default unless specified otherwise
667
- isActive: overrides?.isActive !== undefined ? overrides.isActive : true,
668
- };
669
-
670
- // 6. Save to Firestore
671
- const procedureRef = doc(this.db, PROCEDURES_COLLECTION, newProcedureId);
672
- await setDoc(procedureRef, {
673
- ...newProcedure,
674
- createdAt: serverTimestamp(),
675
- updatedAt: serverTimestamp(),
676
- });
677
-
678
- // 7. Return the new procedure
679
- const savedDoc = await getDoc(procedureRef);
680
- return savedDoc.data() as Procedure;
681
- }
682
-
683
- /**
684
- * Clones an existing procedure for multiple target practitioners.
685
- * This creates new procedure documents with the same data as the source procedure,
686
- * but linked to each target practitioner.
687
- *
688
- * @param sourceProcedureId - The ID of the procedure to clone
689
- * @param targetPractitionerIds - Array of practitioner IDs to assign the cloned procedure to
690
- * @param overrides - Optional overrides for the new procedures (e.g. price, duration, isActive)
691
- * @returns Array of newly created procedures
692
- */
693
- async bulkCloneProcedureForPractitioners(
694
- sourceProcedureId: string,
695
- targetPractitionerIds: string[],
696
- overrides?: Partial<CreateProcedureData> & { isActive?: boolean }
697
- ): Promise<Procedure[]> {
698
- if (!targetPractitionerIds || targetPractitionerIds.length === 0) {
699
- throw new Error('At least one target practitioner ID is required');
700
- }
701
-
702
- // 1. Fetch source procedure
703
- const sourceProcedure = await this.getProcedure(sourceProcedureId);
704
- if (!sourceProcedure) {
705
- throw new Error(`Source procedure with ID ${sourceProcedureId} not found`);
706
- }
707
-
708
- // 2. Fetch all target practitioners
709
- const practitionerPromises = targetPractitionerIds.map(id =>
710
- getDoc(doc(this.db, PRACTITIONERS_COLLECTION, id))
711
- );
712
- const practitionerSnapshots = await Promise.all(practitionerPromises);
713
-
714
- // 3. Validate all practitioners exist, can perform the procedure, and don't already have the same technology
715
- const practitioners: Practitioner[] = [];
716
- const sourceTechnologyId = sourceProcedure.technology?.id;
717
-
718
- for (let i = 0; i < practitionerSnapshots.length; i++) {
719
- const snapshot = practitionerSnapshots[i];
720
- if (!snapshot.exists()) {
721
- throw new Error(`Target practitioner with ID ${targetPractitionerIds[i]} not found`);
722
- }
723
- const practitioner = snapshot.data() as Practitioner;
724
-
725
- if (!this.canPractitionerPerformProcedure(sourceProcedure, practitioner)) {
726
- throw new Error(
727
- `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} does not meet the certification requirements for this procedure`
728
- );
729
- }
730
-
731
- // Check if practitioner already has a procedure with the same technology ID in this clinic branch
732
- const existingProceduresQuery = query(
733
- collection(this.db, PROCEDURES_COLLECTION),
734
- where('practitionerId', '==', practitioner.id),
735
- where('clinicBranchId', '==', sourceProcedure.clinicBranchId),
736
- where('isActive', '==', true)
737
- );
738
- const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
739
- const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
740
-
741
- const hasSameTechnology = existingProcedures.some(
742
- proc => proc.technology?.id === sourceTechnologyId
743
- );
744
- if (hasSameTechnology) {
745
- throw new Error(
746
- `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} already has a procedure with technology "${sourceProcedure.technology?.name || sourceTechnologyId}" in this clinic branch`
747
- );
748
- }
749
-
750
- practitioners.push(practitioner);
751
- }
752
-
753
- // 4. Create procedures in batch
754
- const batch = writeBatch(this.db);
755
- const newProcedures: Omit<Procedure, 'createdAt' | 'updatedAt'>[] = [];
756
-
757
- for (const practitioner of practitioners) {
758
- const newProcedureId = this.generateId();
759
-
760
- // Create aggregated doctor info for the new procedure
761
- const doctorInfo = {
762
- id: practitioner.id,
763
- name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
764
- description: practitioner.basicInfo.bio || '',
765
- photo:
766
- typeof practitioner.basicInfo.profileImageUrl === 'string'
767
- ? practitioner.basicInfo.profileImageUrl
768
- : '',
769
- rating: practitioner.reviewInfo?.averageRating || 0,
770
- services: practitioner.procedures || [],
771
- };
772
-
773
- // Construct the new procedure object
774
- const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
775
- ...sourceProcedure,
776
- id: newProcedureId,
777
- practitionerId: practitioner.id,
778
- doctorInfo,
779
-
780
- // Reset review info for the new procedure
781
- reviewInfo: {
782
- totalReviews: 0,
783
- averageRating: 0,
784
- effectivenessOfTreatment: 0,
785
- outcomeExplanation: 0,
786
- painManagement: 0,
787
- followUpCare: 0,
788
- valueForMoney: 0,
789
- recommendationPercentage: 0,
790
- },
791
-
792
- // Apply any overrides if provided
793
- ...(overrides?.price !== undefined && { price: overrides.price }),
794
- ...(overrides?.duration !== undefined && { duration: overrides.duration }),
795
- ...(overrides?.description !== undefined && { description: overrides.description }),
796
-
797
- // Ensure it's active by default unless specified otherwise
798
- isActive: overrides?.isActive !== undefined ? overrides.isActive : true,
799
- };
800
-
801
- newProcedures.push(newProcedure);
802
-
803
- // Add to batch
804
- const procedureRef = doc(this.db, PROCEDURES_COLLECTION, newProcedureId);
805
- batch.set(procedureRef, {
806
- ...newProcedure,
807
- createdAt: serverTimestamp(),
808
- updatedAt: serverTimestamp(),
809
- });
810
- }
811
-
812
- // 5. Commit batch
813
- await batch.commit();
814
-
815
- // 6. Fetch and return the created procedures
816
- const createdProcedures = await Promise.all(
817
- newProcedures.map(p => this.getProcedure(p.id))
818
- );
819
-
820
- return createdProcedures.filter((p): p is Procedure => p !== null);
821
- }
822
-
823
- /**
824
- * Creates multiple procedures for a list of practitioners based on common data.
825
- * This method is optimized for bulk creation to reduce database reads and writes.
826
- *
827
- * @param baseData - The base data for the procedures to be created, omitting the practitionerId.
828
- * @param practitionerIds - An array of practitioner IDs for whom the procedures will be created.
829
- * @returns A promise that resolves to an array of the newly created procedures.
830
- */
831
- async bulkCreateProcedures(
832
- baseData: Omit<CreateProcedureData, 'practitionerId'>,
833
- practitionerIds: string[],
834
- ): Promise<Procedure[]> {
835
- // 1. Validation
836
- if (!practitionerIds || practitionerIds.length === 0) {
837
- throw new Error('Practitioner IDs array cannot be empty.');
838
- }
839
-
840
- // Check if this is a product-free procedure
841
- const isProductFree = !baseData.productId;
842
-
843
- // Add a dummy practitionerId for the validation schema to pass
844
- const validationData = { ...baseData, practitionerId: practitionerIds[0] };
845
- const validatedData = createProcedureSchema.parse(validationData);
846
-
847
- // 2. Fetch common data once to avoid redundant reads
848
- const baseEntitiesPromises: Promise<Category | Subcategory | Technology | Product | null>[] = [
849
- this.categoryService.getById(validatedData.categoryId),
850
- this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
851
- this.technologyService.getById(validatedData.technologyId),
852
- ];
853
-
854
- // Only fetch product if productId is provided
855
- if (!isProductFree) {
856
- baseEntitiesPromises.push(
857
- this.productService.getById(validatedData.technologyId, validatedData.productId!)
858
- );
859
- }
860
-
861
- // Fetch clinic separately to maintain type safety
862
- const clinicSnapshotPromise = getDoc(doc(this.db, CLINICS_COLLECTION, validatedData.clinicBranchId));
863
-
864
- const [baseResults, clinicSnapshot] = await Promise.all([
865
- Promise.all(baseEntitiesPromises),
866
- clinicSnapshotPromise
867
- ]);
868
-
869
- const category = baseResults[0] as Category | null;
870
- const subcategory = baseResults[1] as Subcategory | null;
871
- const technology = baseResults[2] as Technology | null;
872
- const product = isProductFree ? undefined : ((baseResults[3] as Product | null) || undefined);
873
-
874
- if (!category || !subcategory || !technology) {
875
- throw new Error('One or more required base entities not found');
876
- }
877
-
878
- // For regular procedures, validate product exists
879
- if (!isProductFree && !product) {
880
- throw new Error('Product not found for regular procedure');
881
- }
882
-
883
- if (!clinicSnapshot || !clinicSnapshot.exists()) {
884
- throw new Error(`Clinic with ID ${validatedData.clinicBranchId} not found`);
885
- }
886
- const clinic = clinicSnapshot.data() as Clinic;
887
-
888
- // Enforce tier limit for each practitioner (per-provider, per-branch)
889
- for (const practitionerId of practitionerIds) {
890
- await enforceProcedureLimit(this.db, clinic.clinicGroupId, validatedData.clinicBranchId, practitionerId);
891
- }
892
-
893
- // 3. Handle media uploads once for efficiency
894
- let processedPhotos: string[] = [];
895
- if (validatedData.photos && validatedData.photos.length > 0) {
896
- const batchId = this.generateId(); // Use a single ID for all media in this batch
897
- processedPhotos = await this.processMediaArray(
898
- validatedData.photos,
899
- batchId,
900
- 'procedure-photos-batch',
901
- );
902
- }
903
-
904
- // If no photos provided and technology has a photoTemplate, use it as default photo
905
- if (processedPhotos.length === 0 && technology.photoTemplate) {
906
- console.log(`[ProcedureService] Using technology photoTemplate as default photo for bulk create: ${technology.photoTemplate}`);
907
- const photoTemplateUrl = await this.mediaService.getMediaDownloadUrl(technology.photoTemplate);
908
- if (photoTemplateUrl) {
909
- processedPhotos.push(photoTemplateUrl);
910
- } else {
911
- console.warn(`[ProcedureService] Could not fetch photoTemplate URL for media ID: ${technology.photoTemplate}`);
912
- }
913
- }
914
-
915
- // Transform productsMetadata from validation format to ProcedureProduct format
916
- const transformedProductsMetadata = await this.transformProductsMetadata(
917
- validatedData.productsMetadata,
918
- validatedData.technologyId,
919
- );
920
-
921
- // 4. Fetch all practitioner data efficiently
922
- const practitionersMap = new Map<string, Practitioner>();
923
- // Use 'in' query in chunks of 30, as this is the Firestore limit
924
- for (let i = 0; i < practitionerIds.length; i += 30) {
925
- const chunk = practitionerIds.slice(i, i + 30);
926
- const practitionersQuery = query(
927
- collection(this.db, PRACTITIONERS_COLLECTION),
928
- where(documentId(), 'in', chunk),
929
- );
930
- const practitionersSnapshot = await getDocs(practitionersQuery);
931
- practitionersSnapshot.docs.forEach(doc => {
932
- practitionersMap.set(doc.id, doc.data() as Practitioner);
933
- });
934
- }
935
-
936
- // Verify all practitioners were found
937
- if (practitionersMap.size !== practitionerIds.length) {
938
- const foundIds = Array.from(practitionersMap.keys());
939
- const notFoundIds = practitionerIds.filter(id => !foundIds.includes(id));
940
- throw new Error(`The following practitioners were not found: ${notFoundIds.join(', ')}`);
941
- }
942
-
943
- // 5. Check for duplicates across all practitioners before creating any procedures
944
- const duplicatePractitioners: string[] = [];
945
- const duplicateChecks = await Promise.all(
946
- practitionerIds.map(async (practitionerId) => {
947
- const existingProceduresQuery = query(
948
- collection(this.db, PROCEDURES_COLLECTION),
949
- where('practitionerId', '==', practitionerId),
950
- where('clinicBranchId', '==', validatedData.clinicBranchId),
951
- where('isActive', '==', true)
952
- );
953
- const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
954
- const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
955
-
956
- const hasSameTechnology = existingProcedures.some(
957
- proc => proc.technology?.id === validatedData.technologyId
958
- );
959
-
960
- return { practitionerId, hasSameTechnology };
961
- })
962
- );
963
-
964
- // Collect all practitioners with duplicates
965
- duplicateChecks.forEach(({ practitionerId, hasSameTechnology }) => {
966
- if (hasSameTechnology) {
967
- duplicatePractitioners.push(practitionerId);
968
- }
969
- });
970
-
971
- // If any duplicates found, throw error listing all of them
972
- if (duplicatePractitioners.length > 0) {
973
- const duplicateNames = duplicatePractitioners
974
- .map(id => {
975
- const practitioner = practitionersMap.get(id)!;
976
- return `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`;
977
- })
978
- .join(', ');
979
-
980
- throw new Error(
981
- `The following practitioner(s) already have a procedure with technology "${technology?.name || validatedData.technologyId}" in this clinic branch: ${duplicateNames}. Please remove them from the selection and try again.`
982
- );
983
- }
984
-
985
- // 6. Use a Firestore batch for atomic creation
986
- const batch = writeBatch(this.db);
987
- const createdProcedureIds: string[] = [];
988
- const clinicInfo = {
989
- id: clinicSnapshot.id,
990
- name: clinic.name,
991
- description: clinic.description || '',
992
- featuredPhoto:
993
- clinic.featuredPhotos && clinic.featuredPhotos.length > 0
994
- ? typeof clinic.featuredPhotos[0] === 'string'
995
- ? clinic.featuredPhotos[0]
996
- : ''
997
- : typeof clinic.coverPhoto === 'string'
998
- ? clinic.coverPhoto
999
- : '',
1000
- location: clinic.location,
1001
- contactInfo: clinic.contactInfo,
1002
- };
1003
-
1004
- for (const practitionerId of practitionerIds) {
1005
- const practitioner = practitionersMap.get(practitionerId)!;
1006
-
1007
- const doctorInfo = {
1008
- id: practitioner.id,
1009
- name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
1010
- description: practitioner.basicInfo.bio || '',
1011
- photo:
1012
- typeof practitioner.basicInfo.profileImageUrl === 'string'
1013
- ? practitioner.basicInfo.profileImageUrl
1014
- : '',
1015
- rating: practitioner.reviewInfo?.averageRating || 0,
1016
- services: practitioner.procedures || [],
1017
- };
1018
-
1019
- const procedureId = this.generateId();
1020
- createdProcedureIds.push(procedureId);
1021
- const procedureRef = doc(this.db, PROCEDURES_COLLECTION, procedureId);
1022
-
1023
- // Construct the new procedure, reusing common data
1024
- const { productsMetadata: _, productId: __, photos: ___, ...validatedDataWithoutProductsMetadata } = validatedData;
1025
- const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
1026
- id: procedureId,
1027
- ...validatedDataWithoutProductsMetadata,
1028
- nameLower: (validatedData as any).nameLower || validatedData.name.toLowerCase(),
1029
- practitionerId: practitionerId, // Override practitionerId with the correct one
1030
- photos: processedPhotos,
1031
- category,
1032
- subcategory,
1033
- technology,
1034
- ...(product && { product }), // Only include product field if it exists (Firestore doesn't allow undefined)
1035
- productsMetadata: transformedProductsMetadata, // Use transformed data, not original
1036
- blockingConditions: technology.blockingConditions,
1037
- contraindications: technology.contraindications || [],
1038
- contraindicationIds: technology.contraindications?.map(c => c.id) || [],
1039
- treatmentBenefits: technology.benefits,
1040
- treatmentBenefitIds: Array.isArray(technology.benefits)
1041
- ? technology.benefits.map(b => typeof b === 'string' ? b : b.id)
1042
- : [],
1043
- preRequirements: technology.requirements.pre,
1044
- postRequirements: technology.requirements.post,
1045
- certificationRequirement: technology.certificationRequirement,
1046
- documentationTemplates: technology?.documentationTemplates || [],
1047
- clinicInfo,
1048
- doctorInfo, // Set specific doctor info
1049
- reviewInfo: {
1050
- totalReviews: 0,
1051
- averageRating: 0,
1052
- effectivenessOfTreatment: 0,
1053
- outcomeExplanation: 0,
1054
- painManagement: 0,
1055
- followUpCare: 0,
1056
- valueForMoney: 0,
1057
- recommendationPercentage: 0,
1058
- },
1059
- isActive: true,
1060
- };
1061
-
1062
- // 🔥 DEBUG: Find undefined fields before writing to Firestore
1063
- console.log('🔥🔥🔥 BULK CREATE - Processing procedure:', procedureId, 'for practitioner:', practitionerId);
1064
- console.log('🔥🔥🔥 FULL PROCEDURE OBJECT:', JSON.stringify(newProcedure, null, 2));
1065
-
1066
- const undefinedFields: string[] = [];
1067
- Object.entries(newProcedure).forEach(([key, value]) => {
1068
- if (value === undefined) {
1069
- undefinedFields.push(key);
1070
- }
1071
- });
1072
- if (undefinedFields.length > 0) {
1073
- console.error('🔥🔥🔥 UNDEFINED FIELDS DETECTED:', undefinedFields);
1074
- throw new Error(`Cannot write procedure with undefined fields: ${undefinedFields.join(', ')}`);
1075
- }
1076
- console.log('🔥🔥🔥 NO UNDEFINED FIELDS - Proceeding with batch.set');
1077
-
1078
- batch.set(procedureRef, {
1079
- ...newProcedure,
1080
- createdAt: serverTimestamp(),
1081
- updatedAt: serverTimestamp(),
1082
- });
1083
- }
1084
-
1085
- // 7. Commit the atomic batch write
1086
- await batch.commit();
1087
-
1088
- // 8. Fetch and return the newly created procedures
1089
- const fetchedProcedures: Procedure[] = [];
1090
- for (let i = 0; i < createdProcedureIds.length; i += 30) {
1091
- const chunk = createdProcedureIds.slice(i, i + 30);
1092
- const q = query(collection(this.db, PROCEDURES_COLLECTION), where(documentId(), 'in', chunk));
1093
- const snapshot = await getDocs(q);
1094
- snapshot.forEach(doc => {
1095
- fetchedProcedures.push(doc.data() as Procedure);
1096
- });
1097
- }
1098
-
1099
- return fetchedProcedures;
1100
- }
1101
-
1102
- /**
1103
- * Gets a procedure by ID
1104
- * @param id - The ID of the procedure to get
1105
- * @returns The procedure if found, null otherwise
1106
- */
1107
- async getProcedure(id: string): Promise<Procedure | null> {
1108
- const docRef = doc(this.db, PROCEDURES_COLLECTION, id);
1109
- const docSnap = await getDoc(docRef);
1110
-
1111
- if (!docSnap.exists()) {
1112
- return null;
1113
- }
1114
-
1115
- return docSnap.data() as Procedure;
1116
- }
1117
-
1118
- /**
1119
- * Gets all procedures for a clinic branch
1120
- * @param clinicBranchId - The ID of the clinic branch
1121
- * @param excludeDraftPractitioners - Whether to exclude procedures if the practitioner is in DRAFT status (default: false for admin views)
1122
- * @returns List of procedures
1123
- */
1124
- async getProceduresByClinicBranch(
1125
- clinicBranchId: string,
1126
- excludeDraftPractitioners: boolean = false
1127
- ): Promise<Procedure[]> {
1128
- const q = query(
1129
- collection(this.db, PROCEDURES_COLLECTION),
1130
- where('clinicBranchId', '==', clinicBranchId),
1131
- where('isActive', '==', true),
1132
- );
1133
- const snapshot = await getDocs(q);
1134
- const procedures = snapshot.docs.map(doc => doc.data() as Procedure);
1135
-
1136
- // Filter out procedures from draft practitioners only if explicitly requested (for patient-facing apps)
1137
- if (excludeDraftPractitioners) {
1138
- return await this.filterDraftPractitionerProcedures(procedures);
1139
- }
1140
-
1141
- return procedures;
1142
- }
1143
-
1144
- /**
1145
- * Gets all procedures for a practitioner
1146
- * @param practitionerId - The ID of the practitioner
1147
- * @param clinicBranchId - Optional clinic branch ID to filter by
1148
- * @param excludeDraftPractitioners - Whether to exclude procedures if the practitioner is in DRAFT status
1149
- * @returns List of procedures
1150
- */
1151
- async getProceduresByPractitioner(
1152
- practitionerId: string,
1153
- clinicBranchId?: string,
1154
- excludeDraftPractitioners: boolean = true
1155
- ): Promise<Procedure[]> {
1156
- const constraints: QueryConstraint[] = [
1157
- where('practitionerId', '==', practitionerId),
1158
- where('isActive', '==', true),
1159
- ];
1160
-
1161
- if (clinicBranchId) {
1162
- constraints.push(where('clinicBranchId', '==', clinicBranchId));
1163
- }
1164
-
1165
- const q = query(
1166
- collection(this.db, PROCEDURES_COLLECTION),
1167
- ...constraints
1168
- );
1169
- const snapshot = await getDocs(q);
1170
- const procedures = snapshot.docs.map(doc => doc.data() as Procedure);
1171
-
1172
- // If we need to exclude draft practitioners and have the service available
1173
- if (excludeDraftPractitioners && this.practitionerService) {
1174
- try {
1175
- const practitioner = await this.practitionerService.getPractitioner(practitionerId);
1176
- if (practitioner && practitioner.status === PractitionerStatus.DRAFT) {
1177
- console.log(`[ProcedureService] Excluding procedures for draft practitioner ${practitionerId}`);
1178
- return [];
1179
- }
1180
- } catch (error) {
1181
- console.error(`[ProcedureService] Error checking practitioner status for ${practitionerId}:`, error);
1182
- // On error, default to returning procedures to avoid breaking UI
1183
- }
1184
- }
1185
-
1186
- return procedures;
1187
- }
1188
-
1189
- /**
1190
- * Gets all inactive procedures for a practitioner
1191
- * @param practitionerId - The ID of the practitioner
1192
- * @returns List of inactive procedures
1193
- */
1194
- async getInactiveProceduresByPractitioner(practitionerId: string): Promise<Procedure[]> {
1195
- const q = query(
1196
- collection(this.db, PROCEDURES_COLLECTION),
1197
- where('practitionerId', '==', practitionerId),
1198
- where('isActive', '==', false),
1199
- );
1200
- const snapshot = await getDocs(q);
1201
- return snapshot.docs.map(doc => doc.data() as Procedure);
1202
- }
1203
-
1204
- /**
1205
- * Updates a procedure
1206
- * @param id - The ID of the procedure to update
1207
- * @param data - The data to update the procedure with
1208
- * @returns The updated procedure
1209
- */
1210
- async updateProcedure(id: string, data: UpdateProcedureData): Promise<Procedure> {
1211
- const validatedData = updateProcedureSchema.parse(data);
1212
- const procedureRef = doc(this.db, PROCEDURES_COLLECTION, id);
1213
- const procedureSnapshot = await getDoc(procedureRef);
1214
-
1215
- if (!procedureSnapshot.exists()) {
1216
- throw new Error(`Procedure with ID ${id} not found`);
1217
- }
1218
-
1219
- const existingProcedure = procedureSnapshot.data() as Procedure;
1220
- let updatedProcedureData: Partial<Procedure> = {};
1221
-
1222
- // Copy validated simple fields
1223
- if (validatedData.name !== undefined) updatedProcedureData.name = validatedData.name;
1224
- if (validatedData.description !== undefined)
1225
- updatedProcedureData.description = validatedData.description;
1226
- if (validatedData.price !== undefined) updatedProcedureData.price = validatedData.price;
1227
- if (validatedData.currency !== undefined)
1228
- updatedProcedureData.currency = validatedData.currency;
1229
- if (validatedData.pricingMeasure !== undefined)
1230
- updatedProcedureData.pricingMeasure = validatedData.pricingMeasure;
1231
- if (validatedData.duration !== undefined)
1232
- updatedProcedureData.duration = validatedData.duration;
1233
- if (validatedData.isActive !== undefined)
1234
- updatedProcedureData.isActive = validatedData.isActive;
1235
-
1236
- let practitionerChanged = false;
1237
- let clinicChanged = false;
1238
- const oldPractitionerId = existingProcedure.practitionerId;
1239
- const oldClinicId = existingProcedure.clinicBranchId;
1240
- let newPractitioner: Practitioner | null = null;
1241
- let newClinic: Clinic | null = null;
1242
-
1243
- // Process photos if provided
1244
- if (validatedData.photos !== undefined) {
1245
- updatedProcedureData.photos = await this.processMediaArray(
1246
- validatedData.photos,
1247
- id,
1248
- 'procedure-photos',
1249
- );
1250
- }
1251
-
1252
- // Transform productsMetadata if provided
1253
- if (validatedData.productsMetadata !== undefined) {
1254
- const technologyId = validatedData.technologyId ?? existingProcedure.technology.id;
1255
- if (!technologyId) {
1256
- throw new Error('Technology ID is required for updating products metadata');
1257
- }
1258
- updatedProcedureData.productsMetadata = await this.transformProductsMetadata(
1259
- validatedData.productsMetadata,
1260
- technologyId,
1261
- );
1262
- }
1263
-
1264
- // --- Prepare updates and fetch new related data if IDs change ---
1265
-
1266
- // Handle Practitioner Change
1267
- if (validatedData.practitionerId && validatedData.practitionerId !== oldPractitionerId) {
1268
- practitionerChanged = true;
1269
- const newPractitionerRef = doc(
1270
- this.db,
1271
- PRACTITIONERS_COLLECTION,
1272
- validatedData.practitionerId,
1273
- );
1274
- const newPractitionerSnap = await getDoc(newPractitionerRef);
1275
- if (!newPractitionerSnap.exists())
1276
- throw new Error(`New Practitioner ${validatedData.practitionerId} not found`);
1277
- newPractitioner = newPractitionerSnap.data() as Practitioner;
1278
- // Update doctorInfo within the procedure document
1279
- updatedProcedureData.doctorInfo = {
1280
- id: newPractitioner.id,
1281
- name: `${newPractitioner.basicInfo.firstName} ${newPractitioner.basicInfo.lastName}`,
1282
- description: newPractitioner.basicInfo.bio || '',
1283
- photo:
1284
- typeof newPractitioner.basicInfo.profileImageUrl === 'string'
1285
- ? newPractitioner.basicInfo.profileImageUrl
1286
- : '', // Default to empty string if not a processed URL
1287
- rating: newPractitioner.reviewInfo?.averageRating || 0,
1288
- services: newPractitioner.procedures || [],
1289
- };
1290
- }
1291
-
1292
- // Handle Clinic Change
1293
- if (validatedData.clinicBranchId && validatedData.clinicBranchId !== oldClinicId) {
1294
- clinicChanged = true;
1295
- const newClinicRef = doc(this.db, CLINICS_COLLECTION, validatedData.clinicBranchId);
1296
- const newClinicSnap = await getDoc(newClinicRef);
1297
- if (!newClinicSnap.exists())
1298
- throw new Error(`New Clinic ${validatedData.clinicBranchId} not found`);
1299
- newClinic = newClinicSnap.data() as Clinic;
1300
- // Update clinicInfo within the procedure document
1301
- updatedProcedureData.clinicInfo = {
1302
- id: newClinic.id,
1303
- name: newClinic.name,
1304
- description: newClinic.description || '',
1305
- featuredPhoto:
1306
- newClinic.featuredPhotos && newClinic.featuredPhotos.length > 0
1307
- ? typeof newClinic.featuredPhotos[0] === 'string'
1308
- ? newClinic.featuredPhotos[0]
1309
- : ''
1310
- : typeof newClinic.coverPhoto === 'string'
1311
- ? newClinic.coverPhoto
1312
- : '',
1313
- location: newClinic.location,
1314
- contactInfo: newClinic.contactInfo,
1315
- };
1316
- }
1317
-
1318
- // Handle Category/Subcategory/Technology/Product Changes
1319
- let finalCategoryId = existingProcedure.category.id;
1320
- if (validatedData.name) {
1321
- updatedProcedureData.nameLower = validatedData.name.toLowerCase();
1322
- }
1323
- if (validatedData.categoryId) {
1324
- const category = await this.categoryService.getById(validatedData.categoryId);
1325
- if (!category) throw new Error(`Category ${validatedData.categoryId} not found`);
1326
- updatedProcedureData.category = category;
1327
- finalCategoryId = category.id; // Update finalCategoryId if category changed
1328
- }
1329
-
1330
- // Only fetch subcategory if its ID is provided AND we have a valid finalCategoryId
1331
- if (validatedData.subcategoryId && finalCategoryId) {
1332
- const subcategory = await this.subcategoryService.getById(
1333
- finalCategoryId,
1334
- validatedData.subcategoryId,
1335
- );
1336
- if (!subcategory)
1337
- throw new Error(
1338
- `Subcategory ${validatedData.subcategoryId} not found for category ${finalCategoryId}`,
1339
- );
1340
- updatedProcedureData.subcategory = subcategory;
1341
- } else if (validatedData.subcategoryId) {
1342
- console.warn('Attempted to update subcategory without a valid categoryId');
1343
- }
1344
-
1345
- let finalTechnologyId = existingProcedure.technology.id;
1346
- if (validatedData.technologyId) {
1347
- const technology = await this.technologyService.getById(validatedData.technologyId);
1348
- if (!technology) throw new Error(`Technology ${validatedData.technologyId} not found`);
1349
- updatedProcedureData.technology = technology;
1350
- finalTechnologyId = technology.id; // Update finalTechnologyId if technology changed
1351
- // Update related fields derived from technology
1352
- updatedProcedureData.blockingConditions = technology.blockingConditions;
1353
- updatedProcedureData.contraindications = technology.contraindications || [];
1354
- updatedProcedureData.contraindicationIds = technology.contraindications?.map(c => c.id) || [];
1355
- updatedProcedureData.treatmentBenefits = technology.benefits;
1356
- updatedProcedureData.treatmentBenefitIds = Array.isArray(technology.benefits)
1357
- ? technology.benefits.map(b => typeof b === 'string' ? b : b.id)
1358
- : [];
1359
- updatedProcedureData.preRequirements = technology.requirements.pre;
1360
- updatedProcedureData.postRequirements = technology.requirements.post;
1361
- updatedProcedureData.certificationRequirement = technology.certificationRequirement;
1362
- updatedProcedureData.documentationTemplates = technology.documentationTemplates || [];
1363
- }
1364
-
1365
- // Only fetch product if its ID is provided AND we have a valid finalTechnologyId
1366
- if (validatedData.productId && finalTechnologyId) {
1367
- const product = await this.productService.getById(finalTechnologyId, validatedData.productId);
1368
- if (!product)
1369
- throw new Error(
1370
- `Product ${validatedData.productId} not found for technology ${finalTechnologyId}`,
1371
- );
1372
- updatedProcedureData.product = product;
1373
- } else if (validatedData.productId) {
1374
- console.warn('Attempted to update product without a valid technologyId');
1375
- }
1376
-
1377
- // Update the procedure document
1378
- await updateDoc(procedureRef, {
1379
- ...updatedProcedureData,
1380
- updatedAt: serverTimestamp(),
1381
- });
1382
-
1383
- // Return the updated procedure
1384
- const updatedSnapshot = await getDoc(procedureRef);
1385
- return updatedSnapshot.data() as Procedure;
1386
- }
1387
-
1388
- /**
1389
- * Deactivates a procedure (soft delete)
1390
- * @param id - The ID of the procedure to deactivate
1391
- */
1392
- async deactivateProcedure(id: string): Promise<void> {
1393
- const procedureRef = doc(this.db, PROCEDURES_COLLECTION, id);
1394
- const procedureSnap = await getDoc(procedureRef);
1395
- if (!procedureSnap.exists()) {
1396
- console.warn(`Procedure ${id} not found for deactivation.`);
1397
- return;
1398
- }
1399
-
1400
- // Mark procedure as inactive
1401
- await updateDoc(procedureRef, {
1402
- isActive: false,
1403
- updatedAt: serverTimestamp(),
1404
- });
1405
- }
1406
-
1407
- /**
1408
- * Deletes a procedure permanently
1409
- * @param id - The ID of the procedure to delete
1410
- * @returns A boolean indicating if the deletion was successful
1411
- */
1412
- async deleteProcedure(id: string): Promise<boolean> {
1413
- const procedureRef = doc(this.db, PROCEDURES_COLLECTION, id);
1414
- const procedureSnapshot = await getDoc(procedureRef);
1415
-
1416
- if (!procedureSnapshot.exists()) {
1417
- // Already deleted or never existed
1418
- return false;
1419
- }
1420
-
1421
- // Delete the procedure document
1422
- await deleteDoc(procedureRef);
1423
- return true;
1424
- }
1425
-
1426
- /**
1427
- * Gets all procedures that a practitioner is certified to perform
1428
- * @param practitioner - The practitioner's profile
1429
- * @returns Object containing allowed technologies, families, categories, subcategories
1430
- */
1431
- async getAllowedTechnologies(practitioner: Practitioner): Promise<{
1432
- technologies: Technology[];
1433
- families: ProcedureFamily[];
1434
- categories: string[];
1435
- subcategories: string[];
1436
- }> {
1437
- // This logic depends on TechnologyService and remains valid
1438
- const { technologies, families, categories, subcategories } =
1439
- await this.technologyService.getAllowedTechnologies(practitioner);
1440
-
1441
- return {
1442
- technologies,
1443
- families,
1444
- categories,
1445
- subcategories,
1446
- };
1447
- }
1448
-
1449
- /**
1450
- * Gets all procedures with optional pagination
1451
- *
1452
- * @param pagination - Optional number of procedures per page (0 or undefined returns all)
1453
- * @param lastDoc - Optional last document for pagination (if continuing from a previous page)
1454
- * @param excludeDraftPractitioners - Whether to exclude procedures from draft practitioners (default: true)
1455
- * @returns Object containing procedures array and the last document for pagination
1456
- */
1457
- async getAllProcedures(
1458
- pagination?: number,
1459
- lastDoc?: any,
1460
- excludeDraftPractitioners: boolean = true,
1461
- ): Promise<{ procedures: Procedure[]; lastDoc: any }> {
1462
- try {
1463
- const proceduresCollection = collection(this.db, PROCEDURES_COLLECTION);
1464
- let proceduresQuery = query(proceduresCollection);
1465
-
1466
- // Apply pagination if specified
1467
- if (pagination && pagination > 0) {
1468
- const { limit, startAfter } = await import('firebase/firestore'); // Use dynamic import if needed top-level
1469
-
1470
- if (lastDoc) {
1471
- proceduresQuery = query(
1472
- proceduresCollection,
1473
- orderBy('name'), // Use imported orderBy
1474
- startAfter(lastDoc),
1475
- limit(pagination),
1476
- );
1477
- } else {
1478
- proceduresQuery = query(proceduresCollection, orderBy('name'), limit(pagination)); // Use imported orderBy
1479
- }
1480
- } else {
1481
- proceduresQuery = query(proceduresCollection, orderBy('name')); // Use imported orderBy
1482
- }
1483
-
1484
- const proceduresSnapshot = await getDocs(proceduresQuery);
1485
-
1486
- let procedures = proceduresSnapshot.docs.map(doc => {
1487
- const data = doc.data() as Procedure;
1488
- return {
1489
- ...data,
1490
- id: doc.id, // Ensure ID is present
1491
- };
1492
- });
1493
-
1494
- // Filter out procedures from draft practitioners if requested
1495
- if (excludeDraftPractitioners) {
1496
- procedures = await this.filterDraftPractitionerProcedures(procedures);
1497
- }
1498
-
1499
- // Fix lastDoc - if we got fewer documents from Firestore than requested, no more pages
1500
- const lastDocForPagination =
1501
- pagination && pagination > 0 && proceduresSnapshot.docs.length < pagination
1502
- ? null
1503
- : proceduresSnapshot.docs.length > 0
1504
- ? proceduresSnapshot.docs[proceduresSnapshot.docs.length - 1]
1505
- : null;
1506
-
1507
- return {
1508
- procedures,
1509
- lastDoc: lastDocForPagination,
1510
- };
1511
- } catch (error) {
1512
- console.error('[PROCEDURE_SERVICE] Error getting all procedures:', error);
1513
- throw error;
1514
- }
1515
- }
1516
-
1517
- /**
1518
- * Creates a serializable cursor from a DocumentSnapshot or returns the cursor values.
1519
- * This format can be passed through React Native state/Redux without losing data.
1520
- *
1521
- * @param doc - The Firestore DocumentSnapshot
1522
- * @param orderByField - The field used in orderBy clause
1523
- * @returns Serializable cursor object with values needed for startAfter
1524
- */
1525
- private createSerializableCursor(
1526
- doc: any,
1527
- orderByField: string = 'createdAt',
1528
- ): { __cursor: true; values: any[]; id: string; orderByField: string } | null {
1529
- if (!doc) return null;
1530
-
1531
- const data = typeof doc.data === 'function' ? doc.data() : doc;
1532
- const docId = doc.id || data?.id;
1533
-
1534
- if (!docId) return null;
1535
-
1536
- // Get the value of the orderBy field
1537
- let orderByValue = data?.[orderByField];
1538
-
1539
- // Handle Firestore Timestamp
1540
- if (orderByValue && typeof orderByValue.toDate === 'function') {
1541
- orderByValue = orderByValue.toMillis();
1542
- } else if (orderByValue && orderByValue.seconds) {
1543
- // Serialized Timestamp
1544
- orderByValue = orderByValue.seconds * 1000 + (orderByValue.nanoseconds || 0) / 1000000;
1545
- }
1546
-
1547
- return {
1548
- __cursor: true,
1549
- values: [orderByValue],
1550
- id: docId,
1551
- orderByField,
1552
- };
1553
- }
1554
-
1555
- /**
1556
- * Converts a serializable cursor back to values for startAfter.
1557
- * Handles both native DocumentSnapshots and serialized cursor objects.
1558
- *
1559
- * @param lastDoc - Either a DocumentSnapshot or a serializable cursor object
1560
- * @param orderByField - The field used in orderBy clause (for validation)
1561
- * @returns Values to spread into startAfter, or null if invalid
1562
- */
1563
- private getCursorValuesForStartAfter(
1564
- lastDoc: any,
1565
- orderByField: string = 'createdAt',
1566
- ): any[] | null {
1567
- if (!lastDoc) return null;
1568
-
1569
- // If it's a native DocumentSnapshot with data() method
1570
- if (typeof lastDoc.data === 'function') {
1571
- return [lastDoc];
1572
- }
1573
-
1574
- // If it's our serializable cursor format
1575
- if (lastDoc.__cursor && Array.isArray(lastDoc.values)) {
1576
- // Reconstruct Timestamp if needed for createdAt
1577
- if (orderByField === 'createdAt' && typeof lastDoc.values[0] === 'number') {
1578
- const timestamp = Timestamp.fromMillis(lastDoc.values[0]);
1579
- return [timestamp];
1580
- }
1581
- return lastDoc.values;
1582
- }
1583
-
1584
- // If it's an array of values directly
1585
- if (Array.isArray(lastDoc)) {
1586
- return lastDoc;
1587
- }
1588
-
1589
- // Fallback: try to use the object's orderByField value
1590
- if (lastDoc[orderByField]) {
1591
- let value = lastDoc[orderByField];
1592
- if (typeof value === 'number' && orderByField === 'createdAt') {
1593
- value = Timestamp.fromMillis(value);
1594
- } else if (value.seconds && orderByField === 'createdAt') {
1595
- value = new Timestamp(value.seconds, value.nanoseconds || 0);
1596
- }
1597
- return [value];
1598
- }
1599
-
1600
- console.warn('[PROCEDURE_SERVICE] Could not parse lastDoc cursor:', typeof lastDoc);
1601
- return null;
1602
- }
1603
-
1604
- /**
1605
- * Searches and filters procedures based on multiple criteria
1606
- *
1607
- * @note Frontend can now send either a DocumentSnapshot or a serializable cursor object.
1608
- * The serializable cursor format is: { __cursor: true, values: [...], id: string, orderByField: string }
1609
- *
1610
- * @param filters - Various filters to apply
1611
- * @param filters.nameSearch - Optional search text for procedure name
1612
- * @param filters.treatmentBenefitIds - Optional array of treatment benefits to filter by
1613
- * @param filters.procedureFamily - Optional procedure family to filter by
1614
- * @param filters.procedureCategory - Optional procedure category to filter by
1615
- * @param filters.procedureSubcategory - Optional procedure subcategory to filter by
1616
- * @param filters.procedureTechnology - Optional procedure technology to filter by
1617
- * @param filters.location - Optional location for distance-based search
1618
- * @param filters.radiusInKm - Optional radius in kilometers (required if location is provided)
1619
- * @param filters.minPrice - Optional minimum price
1620
- * @param filters.maxPrice - Optional maximum price
1621
- * @param filters.minRating - Optional minimum rating (0-5)
1622
- * @param filters.maxRating - Optional maximum rating (0-5)
1623
- * @param filters.pagination - Optional number of results per page
1624
- * @param filters.lastDoc - Optional last document for pagination
1625
- * @param filters.isActive - Optional filter for active procedures only
1626
- * @returns Filtered procedures and the last document for pagination
1627
- */
1628
- async getProceduresByFilters(filters: {
1629
- nameSearch?: string;
1630
- treatmentBenefits?: string[];
1631
- procedureFamily?: ProcedureFamily;
1632
- procedureCategory?: string;
1633
- procedureSubcategory?: string;
1634
- procedureTechnology?: string;
1635
- location?: { latitude: number; longitude: number };
1636
- radiusInKm?: number;
1637
- minPrice?: number;
1638
- maxPrice?: number;
1639
- minRating?: number;
1640
- maxRating?: number;
1641
- pagination?: number;
1642
- lastDoc?: any;
1643
- isActive?: boolean;
1644
- practitionerId?: string;
1645
- clinicId?: string;
1646
- excludeDraftPractitioners?: boolean;
1647
- }): Promise<{
1648
- procedures: (Procedure & { distance?: number })[];
1649
- lastDoc: any;
1650
- }> {
1651
- try {
1652
- console.log('[PROCEDURE_SERVICE] Starting procedure filtering with multiple strategies');
1653
- console.log("excludeDraftPractitioners is : ", filters.excludeDraftPractitioners)
1654
-
1655
- // Geo query debug i validacija
1656
- if (filters.location && filters.radiusInKm) {
1657
- console.log('[PROCEDURE_SERVICE] Executing geo query:', {
1658
- location: filters.location,
1659
- radius: filters.radiusInKm,
1660
- serviceName: 'ProcedureService',
1661
- });
1662
-
1663
- // Validacija location podataka
1664
- if (!filters.location.latitude || !filters.location.longitude) {
1665
- console.warn('[PROCEDURE_SERVICE] Invalid location data:', filters.location);
1666
- filters.location = undefined;
1667
- filters.radiusInKm = undefined;
1668
- }
1669
- }
1670
-
1671
- // Handle geo queries separately (they work differently)
1672
- const isGeoQuery = filters.location && filters.radiusInKm && filters.radiusInKm > 0;
1673
- if (isGeoQuery) {
1674
- return this.handleGeoQuery(filters);
1675
- }
1676
-
1677
- // Base constraints (used in all strategies)
1678
- const getBaseConstraints = () => {
1679
- const constraints: QueryConstraint[] = [];
1680
-
1681
- // Active status filter
1682
- if (filters.isActive !== undefined) {
1683
- constraints.push(where('isActive', '==', filters.isActive));
1684
- } else {
1685
- constraints.push(where('isActive', '==', true));
1686
- }
1687
-
1688
- // Filter constraints
1689
- if (filters.procedureFamily) {
1690
- constraints.push(where('family', '==', filters.procedureFamily));
1691
- }
1692
- if (filters.procedureCategory) {
1693
- constraints.push(where('category.id', '==', filters.procedureCategory));
1694
- }
1695
- if (filters.procedureSubcategory) {
1696
- constraints.push(where('subcategory.id', '==', filters.procedureSubcategory));
1697
- }
1698
- if (filters.procedureTechnology) {
1699
- constraints.push(where('technology.id', '==', filters.procedureTechnology));
1700
- }
1701
- if (filters.practitionerId) {
1702
- constraints.push(where('practitionerId', '==', filters.practitionerId));
1703
- }
1704
- if (filters.clinicId) {
1705
- constraints.push(where('clinicBranchId', '==', filters.clinicId));
1706
- }
1707
- if (filters.minPrice !== undefined) {
1708
- constraints.push(where('price', '>=', filters.minPrice));
1709
- }
1710
- if (filters.maxPrice !== undefined) {
1711
- constraints.push(where('price', '<=', filters.maxPrice));
1712
- }
1713
- if (filters.minRating !== undefined) {
1714
- constraints.push(where('reviewInfo.averageRating', '>=', filters.minRating));
1715
- }
1716
- if (filters.maxRating !== undefined) {
1717
- constraints.push(where('reviewInfo.averageRating', '<=', filters.maxRating));
1718
- }
1719
- if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
1720
- const benefitIdsToMatch = filters.treatmentBenefits;
1721
- constraints.push(where('treatmentBenefitIds', 'array-contains-any', benefitIdsToMatch));
1722
- }
1723
-
1724
- return constraints;
1725
- };
1726
-
1727
- // Strategy 1: Try nameLower search if nameSearch exists
1728
- if (filters.nameSearch && filters.nameSearch.trim()) {
1729
- try {
1730
- console.log('[PROCEDURE_SERVICE] Strategy 1: Trying nameLower search');
1731
- const searchTerm = filters.nameSearch.trim().toLowerCase();
1732
- const constraints = getBaseConstraints();
1733
-
1734
- // Check if we have nested field filters that might conflict with orderBy
1735
- const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1736
-
1737
- if (hasNestedFilters) {
1738
- console.log('[PROCEDURE_SERVICE] Strategy 1: Has nested filters, will apply client-side after query');
1739
- }
1740
-
1741
- constraints.push(where('nameLower', '>=', searchTerm));
1742
- constraints.push(where('nameLower', '<=', searchTerm + '\uf8ff'));
1743
- constraints.push(orderBy('nameLower'));
1744
-
1745
- // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1746
- if (filters.lastDoc) {
1747
- const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'nameLower');
1748
- if (cursorValues) {
1749
- constraints.push(startAfter(...cursorValues));
1750
- console.log('[PROCEDURE_SERVICE] Strategy 1: Using cursor for pagination');
1751
- }
1752
- }
1753
- constraints.push(limit(filters.pagination || 10));
1754
-
1755
- const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1756
- const querySnapshot = await getDocs(q);
1757
- let procedures = querySnapshot.docs.map(
1758
- doc => ({ ...doc.data(), id: doc.id } as Procedure),
1759
- );
1760
-
1761
- // Apply client-side filters for nested fields if needed
1762
- if (hasNestedFilters) {
1763
- procedures = this.applyInMemoryFilters(procedures, filters);
1764
- }
1765
-
1766
- // Filter out procedures from draft practitioners if requested
1767
- if (filters.excludeDraftPractitioners) {
1768
- procedures = await this.filterDraftPractitionerProcedures(procedures);
1769
- }
1770
-
1771
- console.log(`[PROCEDURE_SERVICE] Strategy 1 success: ${procedures.length} procedures (${querySnapshot.docs.length} from query)`);
1772
-
1773
- // Fix Load More - if we got fewer documents from Firestore than requested, no more pages
1774
- if (querySnapshot.docs.length < (filters.pagination || 10)) {
1775
- return { procedures, lastDoc: null };
1776
- }
1777
-
1778
- // Return serializable cursor for pagination
1779
- const lastDocSnapshot = querySnapshot.docs.length > 0
1780
- ? querySnapshot.docs[querySnapshot.docs.length - 1]
1781
- : null;
1782
- const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'nameLower');
1783
-
1784
- return { procedures, lastDoc: serializableCursor };
1785
- } catch (error) {
1786
- console.log('[PROCEDURE_SERVICE] Strategy 1 failed:', error);
1787
- }
1788
- }
1789
-
1790
- // Strategy 2: Try name field search as fallback
1791
- if (filters.nameSearch && filters.nameSearch.trim()) {
1792
- try {
1793
- console.log('[PROCEDURE_SERVICE] Strategy 2: Trying name field search');
1794
- const searchTerm = filters.nameSearch.trim().toLowerCase();
1795
- const constraints = getBaseConstraints();
1796
-
1797
- // Check if we have nested field filters that might conflict with orderBy
1798
- const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1799
-
1800
- if (hasNestedFilters) {
1801
- console.log('[PROCEDURE_SERVICE] Strategy 2: Has nested filters, will apply client-side after query');
1802
- }
1803
-
1804
- constraints.push(where('name', '>=', searchTerm));
1805
- constraints.push(where('name', '<=', searchTerm + '\uf8ff'));
1806
- constraints.push(orderBy('name'));
1807
-
1808
- // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1809
- if (filters.lastDoc) {
1810
- const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'name');
1811
- if (cursorValues) {
1812
- constraints.push(startAfter(...cursorValues));
1813
- console.log('[PROCEDURE_SERVICE] Strategy 2: Using cursor for pagination');
1814
- }
1815
- }
1816
- constraints.push(limit(filters.pagination || 10));
1817
-
1818
- const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1819
- const querySnapshot = await getDocs(q);
1820
- let procedures = querySnapshot.docs.map(
1821
- doc => ({ ...doc.data(), id: doc.id } as Procedure),
1822
- );
1823
-
1824
- // Apply client-side filters for nested fields if needed
1825
- if (hasNestedFilters) {
1826
- procedures = this.applyInMemoryFilters(procedures, filters);
1827
- }
1828
-
1829
- // Filter out procedures from draft practitioners
1830
- if(filters.excludeDraftPractitioners){
1831
- procedures = await this.filterDraftPractitionerProcedures(procedures);
1832
- }
1833
-
1834
- console.log(`[PROCEDURE_SERVICE] Strategy 2 success: ${procedures.length} procedures (${querySnapshot.docs.length} from query)`);
1835
-
1836
- // Fix Load More - if we got fewer documents from Firestore than requested, no more pages
1837
- if (querySnapshot.docs.length < (filters.pagination || 10)) {
1838
- return { procedures, lastDoc: null };
1839
- }
1840
-
1841
- // Return serializable cursor for pagination
1842
- const lastDocSnapshot = querySnapshot.docs.length > 0
1843
- ? querySnapshot.docs[querySnapshot.docs.length - 1]
1844
- : null;
1845
- const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'name');
1846
-
1847
- return { procedures, lastDoc: serializableCursor };
1848
- } catch (error) {
1849
- console.log('[PROCEDURE_SERVICE] Strategy 2 failed:', error);
1850
- }
1851
- }
1852
-
1853
- // Strategy 3: orderBy createdAt with client-side filtering
1854
- // NOTE: This strategy excludes nested field filters (technology.id, category.id, subcategory.id)
1855
- // from Firestore query because Firestore doesn't support orderBy on different field
1856
- // when using where on nested fields without a composite index.
1857
- // These filters are applied client-side instead.
1858
- try {
1859
- console.log(
1860
- '[PROCEDURE_SERVICE] Strategy 3: Using createdAt orderBy with client-side filtering',
1861
- {
1862
- procedureTechnology: filters.procedureTechnology,
1863
- hasTechnologyFilter: !!filters.procedureTechnology,
1864
- },
1865
- );
1866
-
1867
- // Build constraints WITHOUT nested field filters (these will be applied client-side)
1868
- const constraints: QueryConstraint[] = [];
1869
-
1870
- // Active status filter
1871
- if (filters.isActive !== undefined) {
1872
- constraints.push(where('isActive', '==', filters.isActive));
1873
- } else {
1874
- constraints.push(where('isActive', '==', true));
1875
- }
1876
-
1877
- // Only include non-nested field filters in Firestore query
1878
- if (filters.procedureFamily) {
1879
- constraints.push(where('family', '==', filters.procedureFamily));
1880
- }
1881
- if (filters.practitionerId) {
1882
- constraints.push(where('practitionerId', '==', filters.practitionerId));
1883
- }
1884
- if (filters.clinicId) {
1885
- constraints.push(where('clinicBranchId', '==', filters.clinicId));
1886
- }
1887
- if (filters.minPrice !== undefined) {
1888
- constraints.push(where('price', '>=', filters.minPrice));
1889
- }
1890
- if (filters.maxPrice !== undefined) {
1891
- constraints.push(where('price', '<=', filters.maxPrice));
1892
- }
1893
- if (filters.minRating !== undefined) {
1894
- constraints.push(where('reviewInfo.averageRating', '>=', filters.minRating));
1895
- }
1896
- if (filters.maxRating !== undefined) {
1897
- constraints.push(where('reviewInfo.averageRating', '<=', filters.maxRating));
1898
- }
1899
- if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
1900
- const benefitIdsToMatch = filters.treatmentBenefits;
1901
- constraints.push(where('treatmentBenefitIds', 'array-contains-any', benefitIdsToMatch));
1902
- }
1903
-
1904
- // NOTE: We intentionally EXCLUDE these nested field filters from Firestore query:
1905
- // - filters.procedureTechnology (technology.id)
1906
- // - filters.procedureCategory (category.id)
1907
- // - filters.procedureSubcategory (subcategory.id)
1908
- // These will be applied client-side in applyInMemoryFilters
1909
-
1910
- console.log(
1911
- '[PROCEDURE_SERVICE] Strategy 3 Firestore constraints (nested filters excluded):',
1912
- constraints.map(c => (c as any).fieldPath || 'unknown'),
1913
- );
1914
- constraints.push(orderBy('createdAt', 'desc'));
1915
-
1916
- // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1917
- if (filters.lastDoc) {
1918
- const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'createdAt');
1919
- if (cursorValues) {
1920
- constraints.push(startAfter(...cursorValues));
1921
- console.log('[PROCEDURE_SERVICE] Strategy 3: Using cursor for pagination');
1922
- }
1923
- }
1924
- constraints.push(limit(filters.pagination || 10));
1925
-
1926
- const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1927
- const querySnapshot = await getDocs(q);
1928
- let procedures = querySnapshot.docs.map(
1929
- doc => ({ ...doc.data(), id: doc.id } as Procedure),
1930
- );
1931
-
1932
- // Apply all client-side filters using centralized function
1933
- console.log('[PROCEDURE_SERVICE] Before applyInMemoryFilters (Strategy 3):', {
1934
- procedureCount: procedures.length,
1935
- procedureTechnology: filters.procedureTechnology,
1936
- filtersObject: {
1937
- procedureTechnology: filters.procedureTechnology,
1938
- procedureFamily: filters.procedureFamily,
1939
- procedureCategory: filters.procedureCategory,
1940
- procedureSubcategory: filters.procedureSubcategory,
1941
- },
1942
- });
1943
- procedures = this.applyInMemoryFilters(procedures, filters);
1944
-
1945
- // Filter out procedures from draft practitioners
1946
- if(filters.excludeDraftPractitioners){
1947
- procedures = await this.filterDraftPractitionerProcedures(procedures);
1948
- }
1949
-
1950
- console.log('[PROCEDURE_SERVICE] After applyInMemoryFilters (Strategy 3):', {
1951
- procedureCount: procedures.length,
1952
- queryDocCount: querySnapshot.docs.length,
1953
- });
1954
-
1955
- console.log(`[PROCEDURE_SERVICE] Strategy 3 success: ${procedures.length} procedures (${querySnapshot.docs.length} from query)`);
1956
-
1957
- // Fix Load More - if we got fewer documents from Firestore than requested, no more pages
1958
- if (querySnapshot.docs.length < (filters.pagination || 10)) {
1959
- return { procedures, lastDoc: null };
1960
- }
1961
-
1962
- // Return serializable cursor for pagination
1963
- const lastDocSnapshot = querySnapshot.docs.length > 0
1964
- ? querySnapshot.docs[querySnapshot.docs.length - 1]
1965
- : null;
1966
- const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'createdAt');
1967
-
1968
- return { procedures, lastDoc: serializableCursor };
1969
- } catch (error) {
1970
- console.log('[PROCEDURE_SERVICE] Strategy 3 failed:', error);
1971
- }
1972
-
1973
- // Strategy 4: Minimal query fallback
1974
- try {
1975
- console.log('[PROCEDURE_SERVICE] Strategy 4: Minimal query fallback');
1976
- const constraints: QueryConstraint[] = [
1977
- where('isActive', '==', filters.isActive !== undefined ? filters.isActive : true),
1978
- orderBy('createdAt', 'desc'),
1979
- ];
1980
- if (filters.practitionerId) {
1981
- constraints.push(where('practitionerId', '==', filters.practitionerId));
1982
- }
1983
- if (filters.clinicId) {
1984
- constraints.push(where('clinicBranchId', '==', filters.clinicId));
1985
- }
1986
-
1987
- // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1988
- if (filters.lastDoc) {
1989
- const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'createdAt');
1990
- if (cursorValues) {
1991
- constraints.push(startAfter(...cursorValues));
1992
- console.log('[PROCEDURE_SERVICE] Strategy 4: Using cursor for pagination');
1993
- }
1994
- }
1995
- constraints.push(limit(filters.pagination || 10));
1996
-
1997
- const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1998
- const querySnapshot = await getDocs(q);
1999
- let procedures = querySnapshot.docs.map(
2000
- doc => ({ ...doc.data(), id: doc.id } as Procedure),
2001
- );
2002
-
2003
- // Apply all client-side filters using centralized function
2004
- procedures = this.applyInMemoryFilters(procedures, filters);
2005
-
2006
- // Filter out procedures from draft practitioners
2007
- if(filters.excludeDraftPractitioners){
2008
- procedures = await this.filterDraftPractitionerProcedures(procedures);
2009
- }
2010
-
2011
- console.log(`[PROCEDURE_SERVICE] Strategy 4 success: ${procedures.length} procedures (${querySnapshot.docs.length} from query)`);
2012
-
2013
- // Fix Load More - if we got fewer documents from Firestore than requested, no more pages
2014
- if (querySnapshot.docs.length < (filters.pagination || 10)) {
2015
- return { procedures, lastDoc: null };
2016
- }
2017
-
2018
- // Return serializable cursor for pagination
2019
- const lastDocSnapshot = querySnapshot.docs.length > 0
2020
- ? querySnapshot.docs[querySnapshot.docs.length - 1]
2021
- : null;
2022
- const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'createdAt');
2023
-
2024
- return { procedures, lastDoc: serializableCursor };
2025
- } catch (error) {
2026
- console.log('[PROCEDURE_SERVICE] Strategy 4 failed:', error);
2027
- }
2028
-
2029
- // All strategies failed
2030
- console.log('[PROCEDURE_SERVICE] All strategies failed, returning empty result');
2031
- return { procedures: [], lastDoc: null };
2032
- } catch (error) {
2033
- console.error('[PROCEDURE_SERVICE] Error filtering procedures:', error);
2034
- return { procedures: [], lastDoc: null };
2035
- }
2036
- }
2037
-
2038
- /**
2039
- * Applies in-memory filters to procedures array
2040
- * Used when Firestore queries fail or for complex filtering
2041
- */
2042
- private applyInMemoryFilters(
2043
- procedures: Procedure[],
2044
- filters: any,
2045
- ): (Procedure & { distance?: number })[] {
2046
- let filteredProcedures = [...procedures]; // Create copy to avoid mutating original
2047
-
2048
- // Debug: Log what filters we received
2049
- console.log('[PROCEDURE_SERVICE] applyInMemoryFilters called:', {
2050
- procedureCount: procedures.length,
2051
- procedureTechnology: filters.procedureTechnology,
2052
- hasTechnologyFilter: !!filters.procedureTechnology,
2053
- allFilterKeys: Object.keys(filters).filter(k => filters[k] !== undefined && filters[k] !== null),
2054
- });
2055
-
2056
- // Name search filter
2057
- if (filters.nameSearch && filters.nameSearch.trim()) {
2058
- const searchTerm = filters.nameSearch.trim().toLowerCase();
2059
- filteredProcedures = filteredProcedures.filter(procedure => {
2060
- const name = (procedure.name || '').toLowerCase();
2061
- const nameLower = procedure.nameLower || '';
2062
- return name.includes(searchTerm) || nameLower.includes(searchTerm);
2063
- });
2064
- console.log(`[PROCEDURE_SERVICE] Applied name filter, results: ${filteredProcedures.length}`);
2065
- }
2066
-
2067
- // Price filtering
2068
- if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
2069
- filteredProcedures = filteredProcedures.filter(procedure => {
2070
- const price = procedure.price || 0;
2071
- if (filters.minPrice !== undefined && price < filters.minPrice) return false;
2072
- if (filters.maxPrice !== undefined && price > filters.maxPrice) return false;
2073
- return true;
2074
- });
2075
- console.log(
2076
- `[PROCEDURE_SERVICE] Applied price filter (${filters.minPrice}-${filters.maxPrice}), results: ${filteredProcedures.length}`,
2077
- );
2078
- }
2079
-
2080
- // Rating filtering
2081
- if (filters.minRating !== undefined || filters.maxRating !== undefined) {
2082
- filteredProcedures = filteredProcedures.filter(procedure => {
2083
- const rating = procedure.reviewInfo?.averageRating || 0;
2084
- if (filters.minRating !== undefined && rating < filters.minRating) return false;
2085
- if (filters.maxRating !== undefined && rating > filters.maxRating) return false;
2086
- return true;
2087
- });
2088
- console.log(
2089
- `[PROCEDURE_SERVICE] Applied rating filter, results: ${filteredProcedures.length}`,
2090
- );
2091
- }
2092
-
2093
- // Treatment benefits filtering
2094
- if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
2095
- const benefitIdsToMatch = filters.treatmentBenefits;
2096
- filteredProcedures = filteredProcedures.filter(procedure => {
2097
- const procedureBenefitIds = procedure.treatmentBenefitIds || [];
2098
- return benefitIdsToMatch.some((benefitId: string) =>
2099
- procedureBenefitIds.includes(benefitId),
2100
- );
2101
- });
2102
- console.log(
2103
- `[PROCEDURE_SERVICE] Applied benefits filter, results: ${filteredProcedures.length}`,
2104
- );
2105
- }
2106
-
2107
- // Procedure family filtering
2108
- if (filters.procedureFamily) {
2109
- filteredProcedures = filteredProcedures.filter(
2110
- procedure => procedure.family === filters.procedureFamily,
2111
- );
2112
- console.log(
2113
- `[PROCEDURE_SERVICE] Applied family filter, results: ${filteredProcedures.length}`,
2114
- );
2115
- }
2116
-
2117
- // Category filtering
2118
- if (filters.procedureCategory) {
2119
- filteredProcedures = filteredProcedures.filter(
2120
- procedure => procedure.category?.id === filters.procedureCategory,
2121
- );
2122
- console.log(
2123
- `[PROCEDURE_SERVICE] Applied category filter, results: ${filteredProcedures.length}`,
2124
- );
2125
- }
2126
-
2127
- // Subcategory filtering
2128
- if (filters.procedureSubcategory) {
2129
- filteredProcedures = filteredProcedures.filter(
2130
- procedure => procedure.subcategory?.id === filters.procedureSubcategory,
2131
- );
2132
- console.log(
2133
- `[PROCEDURE_SERVICE] Applied subcategory filter, results: ${filteredProcedures.length}`,
2134
- );
2135
- }
2136
-
2137
- // Technology filtering
2138
- if (filters.procedureTechnology) {
2139
- const beforeCount = filteredProcedures.length;
2140
- filteredProcedures = filteredProcedures.filter(
2141
- procedure => procedure.technology?.id === filters.procedureTechnology,
2142
- );
2143
- console.log(
2144
- `[PROCEDURE_SERVICE] Applied technology filter (${filters.procedureTechnology}), before: ${beforeCount}, after: ${filteredProcedures.length}`,
2145
- );
2146
- // Log sample technology IDs for debugging
2147
- if (beforeCount > filteredProcedures.length) {
2148
- const filteredOut = procedures
2149
- .filter(p => p.technology?.id !== filters.procedureTechnology)
2150
- .slice(0, 3)
2151
- .map(p => ({ id: p.id, techId: p.technology?.id, name: p.name }));
2152
- console.log('[PROCEDURE_SERVICE] Filtered out sample procedures:', filteredOut);
2153
- }
2154
- }
2155
-
2156
- // Practitioner filtering
2157
- if (filters.practitionerId) {
2158
- filteredProcedures = filteredProcedures.filter(
2159
- procedure => procedure.practitionerId === filters.practitionerId,
2160
- );
2161
- console.log(
2162
- `[PROCEDURE_SERVICE] Applied practitioner filter, results: ${filteredProcedures.length}`,
2163
- );
2164
- }
2165
-
2166
- // Clinic filtering
2167
- if (filters.clinicId) {
2168
- filteredProcedures = filteredProcedures.filter(
2169
- procedure => procedure.clinicBranchId === filters.clinicId,
2170
- );
2171
- console.log(
2172
- `[PROCEDURE_SERVICE] Applied clinic filter, results: ${filteredProcedures.length}`,
2173
- );
2174
- }
2175
-
2176
- // Geo-radius filter
2177
- if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
2178
- const location = filters.location;
2179
- const radiusInKm = filters.radiusInKm;
2180
- filteredProcedures = filteredProcedures.filter(procedure => {
2181
- const clinicLocation = procedure.clinicInfo?.location;
2182
- if (!clinicLocation?.latitude || !clinicLocation?.longitude) {
2183
- return false;
2184
- }
2185
-
2186
- const distance = distanceBetween(
2187
- [location.latitude, location.longitude],
2188
- [clinicLocation.latitude, clinicLocation.longitude],
2189
- ); // Already returns km
2190
-
2191
- // Attach distance for frontend sorting/display
2192
- (procedure as any).distance = distance;
2193
-
2194
- return distance <= radiusInKm;
2195
- });
2196
- console.log(`[PROCEDURE_SERVICE] Applied geo filter, results: ${filteredProcedures.length}`);
2197
-
2198
- // Sort by distance when geo filtering is applied
2199
- filteredProcedures.sort((a, b) => ((a as any).distance || 0) - ((b as any).distance || 0));
2200
- }
2201
-
2202
- return filteredProcedures as (Procedure & { distance?: number })[];
2203
- }
2204
-
2205
- private handleGeoQuery(filters: any): Promise<{
2206
- procedures: (Procedure & { distance?: number })[];
2207
- lastDoc: any;
2208
- }> {
2209
- console.log('[PROCEDURE_SERVICE] Executing geo query with geohash bounds');
2210
- try {
2211
- const location = filters.location;
2212
- const radiusInKm = filters.radiusInKm;
2213
-
2214
- if (!location || !radiusInKm) {
2215
- return Promise.resolve({ procedures: [], lastDoc: null });
2216
- }
2217
-
2218
- const bounds = geohashQueryBounds([location.latitude, location.longitude], radiusInKm * 1000);
2219
-
2220
- const fetches = bounds.map(b => {
2221
- const constraints: QueryConstraint[] = [
2222
- where('clinicInfo.location.geohash', '>=', b[0]),
2223
- where('clinicInfo.location.geohash', '<=', b[1]),
2224
- where('isActive', '==', filters.isActive !== undefined ? filters.isActive : true),
2225
- ];
2226
- if (filters.practitionerId) {
2227
- constraints.push(where('practitionerId', '==', filters.practitionerId));
2228
- }
2229
- if (filters.clinicId) {
2230
- constraints.push(where('clinicBranchId', '==', filters.clinicId));
2231
- }
2232
- return getDocs(query(collection(this.db, PROCEDURES_COLLECTION), ...constraints));
2233
- });
2234
-
2235
- return Promise.all(fetches)
2236
- .then(snaps => {
2237
- const collected: Procedure[] = [];
2238
- snaps.forEach(snap => {
2239
- snap.docs.forEach(d => collected.push({ ...(d.data() as Procedure), id: d.id }));
2240
- });
2241
-
2242
- // Deduplicate by id
2243
- const uniqueMap = new Map<string, Procedure>();
2244
- for (const p of collected) {
2245
- uniqueMap.set(p.id, p);
2246
- }
2247
- let procedures = Array.from(uniqueMap.values());
2248
-
2249
- // Apply remaining filters including precise distance and sorting
2250
- procedures = this.applyInMemoryFilters(procedures, filters);
2251
-
2252
- // Manual pagination
2253
- const pageSize = filters.pagination || 10;
2254
- let startIndex = 0;
2255
- if (
2256
- filters.lastDoc &&
2257
- typeof filters.lastDoc === 'object' &&
2258
- (filters.lastDoc as any).id
2259
- ) {
2260
- const idx = procedures.findIndex(p => p.id === (filters.lastDoc as any).id);
2261
- if (idx >= 0) startIndex = idx + 1;
2262
- }
2263
- const page = procedures.slice(startIndex, startIndex + pageSize);
2264
- const newLastDoc = page.length === pageSize ? page[page.length - 1] : null;
2265
-
2266
- console.log(
2267
- `[PROCEDURE_SERVICE] Geo query success: ${page.length} (of ${procedures.length}) within ${radiusInKm}km`,
2268
- );
2269
- return { procedures: page, lastDoc: newLastDoc };
2270
- })
2271
- .catch(err => {
2272
- console.error('[PROCEDURE_SERVICE] Geo bounds fetch failed:', err);
2273
- return { procedures: [], lastDoc: null };
2274
- });
2275
- } catch (error) {
2276
- console.error('[PROCEDURE_SERVICE] Geo query failed:', error);
2277
- return Promise.resolve({ procedures: [], lastDoc: null });
2278
- }
2279
- }
2280
-
2281
- /**
2282
- * Creates a consultation procedure without requiring a product
2283
- * This is a special method for consultation procedures that don't use products
2284
- * @param data - The data for creating a consultation procedure (without productId)
2285
- * @returns The created procedure
2286
- */
2287
- async createConsultationProcedure(
2288
- data: Omit<CreateProcedureData, 'productId'>,
2289
- ): Promise<Procedure> {
2290
- // Generate procedure ID first so we can use it for media uploads
2291
- const procedureId = this.generateId();
2292
-
2293
- // Get references to related entities (Category, Subcategory, Technology)
2294
- // For consultation, we don't need a product
2295
- const [category, subcategory, technology] = await Promise.all([
2296
- this.categoryService.getByIdInternal(data.categoryId),
2297
- this.subcategoryService.getByIdInternal(data.categoryId, data.subcategoryId),
2298
- this.technologyService.getByIdInternal(data.technologyId),
2299
- ]);
2300
-
2301
- if (!category || !subcategory || !technology) {
2302
- throw new Error('One or more required base entities not found');
2303
- }
2304
-
2305
- // Get clinic and practitioner information for aggregation
2306
- const clinicRef = doc(this.db, CLINICS_COLLECTION, data.clinicBranchId);
2307
- const clinicSnapshot = await getDoc(clinicRef);
2308
- if (!clinicSnapshot.exists()) {
2309
- throw new Error(`Clinic with ID ${data.clinicBranchId} not found`);
2310
- }
2311
- const clinic = clinicSnapshot.data() as Clinic;
2312
-
2313
- const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, data.practitionerId);
2314
- const practitionerSnapshot = await getDoc(practitionerRef);
2315
- if (!practitionerSnapshot.exists()) {
2316
- throw new Error(`Practitioner with ID ${data.practitionerId} not found`);
2317
- }
2318
- const practitioner = practitionerSnapshot.data() as Practitioner;
2319
-
2320
- // Process photos if provided
2321
- let processedPhotos: string[] = [];
2322
- if (data.photos && data.photos.length > 0) {
2323
- processedPhotos = await this.processMediaArray(data.photos, procedureId, 'procedure-photos');
2324
- }
2325
-
2326
- // If no photos provided and technology has a photoTemplate, use it as default photo
2327
- if (processedPhotos.length === 0 && technology.photoTemplate) {
2328
- console.log(`[ProcedureService] Using technology photoTemplate as default photo for consultation: ${technology.photoTemplate}`);
2329
- const photoTemplateUrl = await this.mediaService.getMediaDownloadUrl(technology.photoTemplate);
2330
- if (photoTemplateUrl) {
2331
- processedPhotos.push(photoTemplateUrl);
2332
- } else {
2333
- console.warn(`[ProcedureService] Could not fetch photoTemplate URL for media ID: ${technology.photoTemplate}`);
2334
- }
2335
- }
2336
-
2337
- // Transform productsMetadata from validation format to ProcedureProduct format
2338
- // For consultations, this will return empty array since no products are provided
2339
- const transformedProductsMetadata = await this.transformProductsMetadata(
2340
- data.productsMetadata,
2341
- data.technologyId,
2342
- );
2343
-
2344
- // Create aggregated clinic info for the procedure document
2345
- const clinicInfo = {
2346
- id: clinicSnapshot.id,
2347
- name: clinic.name,
2348
- description: clinic.description || '',
2349
- featuredPhoto:
2350
- clinic.featuredPhotos && clinic.featuredPhotos.length > 0
2351
- ? typeof clinic.featuredPhotos[0] === 'string'
2352
- ? clinic.featuredPhotos[0]
2353
- : ''
2354
- : typeof clinic.coverPhoto === 'string'
2355
- ? clinic.coverPhoto
2356
- : '',
2357
- location: clinic.location,
2358
- contactInfo: clinic.contactInfo,
2359
- };
2360
-
2361
- // Create aggregated doctor info for the procedure document
2362
- const doctorInfo = {
2363
- id: practitionerSnapshot.id,
2364
- name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
2365
- description: practitioner.basicInfo.bio || '',
2366
- photo:
2367
- typeof practitioner.basicInfo.profileImageUrl === 'string'
2368
- ? practitioner.basicInfo.profileImageUrl
2369
- : '',
2370
- rating: practitioner.reviewInfo?.averageRating || 0,
2371
- services: practitioner.procedures || [],
2372
- };
2373
-
2374
- // Create the procedure object
2375
- const { productsMetadata: _, ...dataWithoutProductsMetadata } = data;
2376
- const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
2377
- id: procedureId,
2378
- ...dataWithoutProductsMetadata,
2379
- nameLower: (data as any).nameLower || data.name.toLowerCase(),
2380
- photos: processedPhotos,
2381
- category,
2382
- subcategory,
2383
- technology,
2384
- // No product field for consultations (Firestore doesn't allow undefined, so we omit it entirely)
2385
- productsMetadata: transformedProductsMetadata, // Empty array for consultations
2386
- blockingConditions: technology.blockingConditions,
2387
- contraindications: technology.contraindications || [],
2388
- contraindicationIds: technology.contraindications?.map(c => c.id) || [],
2389
- treatmentBenefits: technology.benefits,
2390
- treatmentBenefitIds: Array.isArray(technology.benefits)
2391
- ? technology.benefits.map(b => typeof b === 'string' ? b : b.id)
2392
- : [],
2393
- preRequirements: technology.requirements.pre,
2394
- postRequirements: technology.requirements.post,
2395
- certificationRequirement: technology.certificationRequirement,
2396
- documentationTemplates: technology?.documentationTemplates || [],
2397
- clinicInfo,
2398
- doctorInfo,
2399
- reviewInfo: {
2400
- totalReviews: 0,
2401
- averageRating: 0,
2402
- effectivenessOfTreatment: 0,
2403
- outcomeExplanation: 0,
2404
- painManagement: 0,
2405
- followUpCare: 0,
2406
- valueForMoney: 0,
2407
- recommendationPercentage: 0,
2408
- },
2409
- isActive: true,
2410
- };
2411
-
2412
- // Create the procedure document
2413
- const procedureRef = doc(this.db, PROCEDURES_COLLECTION, procedureId);
2414
- await setDoc(procedureRef, {
2415
- ...newProcedure,
2416
- createdAt: serverTimestamp(),
2417
- updatedAt: serverTimestamp(),
2418
- });
2419
-
2420
- // Return the created procedure (fetch again to get server timestamps)
2421
- const savedDoc = await getDoc(procedureRef);
2422
- return savedDoc.data() as Procedure;
2423
- }
2424
-
2425
- /**
2426
- * Gets all procedures with minimal info for map display (id, name, clinicId, clinicName, address, latitude, longitude)
2427
- * This is optimized for mobile map usage to reduce payload size.
2428
- * @returns Array of minimal procedure info for map
2429
- */
2430
- async getProceduresForMap(): Promise<
2431
- {
2432
- id: string;
2433
- name: string;
2434
- clinicId: string | undefined;
2435
- clinicName: string | undefined;
2436
- address: string;
2437
- latitude: number | undefined;
2438
- longitude: number | undefined;
2439
- }[]
2440
- > {
2441
- const proceduresRef = collection(this.db, PROCEDURES_COLLECTION);
2442
- const snapshot = await getDocs(proceduresRef);
2443
- let procedures = snapshot.docs.map(doc => ({
2444
- id: doc.id,
2445
- ...doc.data(),
2446
- } as Procedure));
2447
-
2448
- // Filter out procedures with draft/inactive practitioners
2449
- procedures = await this.filterDraftPractitionerProcedures(procedures);
2450
-
2451
- const proceduresForMap = procedures.map(procedure => ({
2452
- id: procedure.id,
2453
- name: procedure.name,
2454
- clinicId: procedure.clinicInfo?.id,
2455
- clinicName: procedure.clinicInfo?.name,
2456
- address: procedure.clinicInfo?.location?.address || '',
2457
- latitude: procedure.clinicInfo?.location?.latitude,
2458
- longitude: procedure.clinicInfo?.location?.longitude,
2459
- }));
2460
- return proceduresForMap;
2461
- }
2462
-
2463
- /**
2464
- * Gets procedures filtered by clinic and practitioner with optional family filter
2465
- * @param clinicBranchId Clinic branch ID to filter by
2466
- * @param practitionerId Practitioner ID to filter by
2467
- * @param filterByFamily If true, shows only procedures of the same family as the default procedure
2468
- * @param defaultProcedureId Optional default procedure ID to determine the family
2469
- * @returns Array of procedures
2470
- */
2471
- async getProceduresForConsultation(
2472
- clinicBranchId: string,
2473
- practitionerId: string,
2474
- filterByFamily: boolean = true,
2475
- defaultProcedureId?: string
2476
- ): Promise<Procedure[]> {
2477
- let familyToFilter: ProcedureFamily | null = null;
2478
-
2479
- // If family filtering is enabled and we have a default procedure, get its family
2480
- if (filterByFamily && defaultProcedureId) {
2481
- const defaultProcedureRef = doc(this.db, PROCEDURES_COLLECTION, defaultProcedureId);
2482
- const defaultProcedureSnap = await getDoc(defaultProcedureRef);
2483
-
2484
- if (defaultProcedureSnap.exists()) {
2485
- const defaultProcedure = defaultProcedureSnap.data() as Procedure;
2486
- familyToFilter = defaultProcedure.family;
2487
- }
2488
- }
2489
-
2490
- // Build query constraints
2491
- const constraints: QueryConstraint[] = [
2492
- where('clinicBranchId', '==', clinicBranchId),
2493
- where('practitionerId', '==', practitionerId),
2494
- where('isActive', '==', true),
2495
- ];
2496
-
2497
- // Add family filter if applicable
2498
- if (filterByFamily && familyToFilter) {
2499
- constraints.push(where('family', '==', familyToFilter));
2500
- }
2501
-
2502
- // Execute query
2503
- const proceduresQuery = query(
2504
- collection(this.db, PROCEDURES_COLLECTION),
2505
- ...constraints,
2506
- orderBy('name', 'asc')
2507
- );
2508
-
2509
- const querySnapshot = await getDocs(proceduresQuery);
2510
-
2511
- let procedures = querySnapshot.docs.map(doc => ({
2512
- id: doc.id,
2513
- ...doc.data(),
2514
- } as Procedure));
2515
-
2516
- // Filter out procedures with draft/inactive practitioners
2517
- procedures = await this.filterDraftPractitionerProcedures(procedures);
2518
-
2519
- return procedures;
2520
- }
2521
- }
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
+ DocumentData,
14
+ writeBatch,
15
+ arrayUnion,
16
+ arrayRemove,
17
+ FieldValue,
18
+ orderBy,
19
+ limit,
20
+ startAfter,
21
+ QueryConstraint,
22
+ documentId,
23
+ } from 'firebase/firestore';
24
+ import { BaseService } from '../base.service';
25
+ import { enforceProcedureLimit } from '../tier-enforcement';
26
+ import {
27
+ Procedure,
28
+ CreateProcedureData,
29
+ UpdateProcedureData,
30
+ PROCEDURES_COLLECTION,
31
+ ProcedureSummaryInfo,
32
+ } from '../../types/procedure';
33
+ import { createProcedureSchema, updateProcedureSchema } from '../../validations/procedure.schema';
34
+ import { z } from 'zod';
35
+ import { Auth } from 'firebase/auth';
36
+ import { Firestore } from 'firebase/firestore';
37
+ import { FirebaseApp } from 'firebase/app';
38
+ import { Category, CATEGORIES_COLLECTION } from '../../backoffice/types/category.types';
39
+ import { Subcategory, SUBCATEGORIES_COLLECTION } from '../../backoffice/types/subcategory.types';
40
+ import { Technology, TECHNOLOGIES_COLLECTION } from '../../backoffice/types/technology.types';
41
+ import { Product, PRODUCTS_COLLECTION } from '../../backoffice/types/product.types';
42
+ import { CategoryService } from '../../backoffice/services/category.service';
43
+ import { SubcategoryService } from '../../backoffice/services/subcategory.service';
44
+ import { TechnologyService } from '../../backoffice/services/technology.service';
45
+ import { ProductService } from '../../backoffice/services/product.service';
46
+ import { Practitioner, PRACTITIONERS_COLLECTION } from '../../types/practitioner';
47
+ import {
48
+ CertificationLevel,
49
+ CertificationSpecialty,
50
+ ProcedureFamily,
51
+ type TreatmentBenefitDynamic,
52
+ } from '../../backoffice/types';
53
+ import { Currency, PricingMeasure } from '../../backoffice/types/static/pricing.types';
54
+ import { Clinic, CLINICS_COLLECTION } from '../../types/clinic';
55
+ import { ProcedureReviewInfo } from '../../types/reviews';
56
+ import { distanceBetween, geohashQueryBounds } from 'geofire-common';
57
+ import { MediaService, MediaAccessLevel } from '../media/media.service';
58
+ import type { ProcedureProduct } from '../../backoffice/types/procedure-product.types';
59
+
60
+ import { PractitionerService } from '../practitioner/practitioner.service';
61
+ import { PractitionerStatus } from '../../types/practitioner';
62
+
63
+ export class ProcedureService extends BaseService {
64
+ private categoryService: CategoryService;
65
+ private subcategoryService: SubcategoryService;
66
+ private technologyService: TechnologyService;
67
+ private productService: ProductService;
68
+ private mediaService: MediaService;
69
+ private practitionerService?: PractitionerService;
70
+
71
+ constructor(
72
+ db: Firestore,
73
+ auth: Auth,
74
+ app: FirebaseApp,
75
+ categoryService: CategoryService,
76
+ subcategoryService: SubcategoryService,
77
+ technologyService: TechnologyService,
78
+ productService: ProductService,
79
+ mediaService: MediaService,
80
+ ) {
81
+ super(db, auth, app);
82
+ this.categoryService = categoryService;
83
+ this.subcategoryService = subcategoryService;
84
+ this.technologyService = technologyService;
85
+ this.productService = productService;
86
+ this.mediaService = mediaService;
87
+ }
88
+
89
+ setPractitionerService(practitionerService: PractitionerService) {
90
+ this.practitionerService = practitionerService;
91
+ }
92
+
93
+ /**
94
+ * Filters out procedures that should not be visible to patients:
95
+ * 1. Procedures with no practitioner (missing practitionerId)
96
+ * 2. Procedures where practitioner doesn't exist
97
+ * 3. Procedures from draft practitioners
98
+ * 4. Procedures from inactive practitioners (isActive === false)
99
+ *
100
+ * Note: Each procedure has ONE practitionerId. If that practitioner is inactive/draft/missing,
101
+ * the procedure is filtered out.
102
+ *
103
+ * @param procedures - Array of procedures to filter
104
+ * @returns Filtered array of procedures (excluding invalid/inactive/draft practitioners)
105
+ */
106
+ private async filterDraftPractitionerProcedures(
107
+ procedures: Procedure[]
108
+ ): Promise<Procedure[]> {
109
+ console.log(`[ProcedureService] filterDraftPractitionerProcedures called with ${procedures.length} procedures, practitionerService available: ${!!this.practitionerService}`);
110
+ if (!this.practitionerService || procedures.length === 0) {
111
+ if (!this.practitionerService) {
112
+ console.warn('[ProcedureService] practitionerService not available - skipping filtering');
113
+ }
114
+ return procedures;
115
+ }
116
+
117
+ try {
118
+ // First, filter out procedures with no practitionerId
119
+ const proceduresWithPractitioner = procedures.filter((p) =>
120
+ p.practitionerId && p.practitionerId.trim() !== ''
121
+ );
122
+
123
+ if (proceduresWithPractitioner.length === 0) {
124
+ console.log(
125
+ `[ProcedureService] All ${procedures.length} procedures have no practitionerId - filtering out`
126
+ );
127
+ return [];
128
+ }
129
+
130
+ // Get unique practitioner IDs from procedures
131
+ const practitionerIds = Array.from(
132
+ new Set(proceduresWithPractitioner.map((p) => p.practitionerId).filter(Boolean))
133
+ );
134
+
135
+ if (practitionerIds.length === 0) {
136
+ return [];
137
+ }
138
+
139
+ // Fetch all practitioners in parallel
140
+ const practitionerPromises = practitionerIds.map((id) =>
141
+ this.practitionerService!.getPractitioner(id).catch(() => null)
142
+ );
143
+ const practitioners = await Promise.all(practitionerPromises);
144
+
145
+ // Create a map of practitioner ID to practitioner data
146
+ const practitionerMap = new Map<string, Practitioner>();
147
+ practitioners.forEach((practitioner, index) => {
148
+ if (practitioner) {
149
+ practitionerMap.set(practitionerIds[index], practitioner);
150
+ }
151
+ });
152
+
153
+ // Filter out procedures that:
154
+ // 1. Have no practitionerId (already filtered above, but double-check)
155
+ // 2. Have a practitioner that doesn't exist
156
+ // 3. Have a practitioner with DRAFT status
157
+ // 4. Have a practitioner that is not active (isActive === false)
158
+ const filteredProcedures = proceduresWithPractitioner.filter((procedure) => {
159
+ // Check if practitioner exists
160
+ const practitioner = practitionerMap.get(procedure.practitionerId);
161
+
162
+ if (!practitioner) {
163
+ console.log(
164
+ `[ProcedureService] Filtering out procedure ${procedure.id} - practitioner ${procedure.practitionerId} not found`
165
+ );
166
+ return false;
167
+ }
168
+
169
+ // Check if practitioner is DRAFT
170
+ if (practitioner.status === PractitionerStatus.DRAFT) {
171
+ console.log(
172
+ `[ProcedureService] Filtering out procedure ${procedure.id} - practitioner ${procedure.practitionerId} is DRAFT`
173
+ );
174
+ return false;
175
+ }
176
+
177
+ // Check if practitioner is active (must be true to show procedure)
178
+ if (!practitioner.isActive) {
179
+ console.log(
180
+ `[ProcedureService] Filtering out procedure ${procedure.id} - practitioner ${procedure.practitionerId} is not active`
181
+ );
182
+ return false;
183
+ }
184
+
185
+ return true;
186
+ });
187
+
188
+ const filteredCount = procedures.length - filteredProcedures.length;
189
+ if (filteredCount > 0) {
190
+ const noPractitionerCount = procedures.length - proceduresWithPractitioner.length;
191
+ const invalidPractitionerCount = proceduresWithPractitioner.length - filteredProcedures.length;
192
+ console.log(
193
+ `[ProcedureService] Filtered out ${filteredCount} procedures: ` +
194
+ `${noPractitionerCount} with no practitionerId, ` +
195
+ `${invalidPractitionerCount} with missing/draft/inactive practitioners`
196
+ );
197
+ }
198
+
199
+ return filteredProcedures;
200
+ } catch (error) {
201
+ console.error(
202
+ '[ProcedureService] Error filtering practitioner procedures:',
203
+ error
204
+ );
205
+ // On error, return original procedures to avoid breaking functionality
206
+ return procedures;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Process media resource (string URL or File object)
212
+ * @param media String URL or File object
213
+ * @param ownerId Owner ID for the media (usually procedureId)
214
+ * @param collectionName Collection name for organizing files
215
+ * @returns URL string after processing
216
+ */
217
+ private async processMedia(
218
+ media: string | File | Blob | null | undefined,
219
+ ownerId: string,
220
+ collectionName: string,
221
+ ): Promise<string | null> {
222
+ if (!media) return null;
223
+
224
+ // If already a string URL, return it directly
225
+ if (typeof media === 'string') {
226
+ return media;
227
+ }
228
+
229
+ // If it's a File, upload it using MediaService
230
+ if (media instanceof File || media instanceof Blob) {
231
+ console.log(`[ProcedureService] Uploading ${collectionName} media for ${ownerId}`);
232
+ const metadata = await this.mediaService.uploadMedia(
233
+ media,
234
+ ownerId,
235
+ MediaAccessLevel.PUBLIC,
236
+ collectionName,
237
+ );
238
+ return metadata.url;
239
+ }
240
+
241
+ return null;
242
+ }
243
+
244
+ /**
245
+ * Process array of media resources (strings or Files)
246
+ * @param mediaArray Array of string URLs or File objects
247
+ * @param ownerId Owner ID for the media
248
+ * @param collectionName Collection name for organizing files
249
+ * @returns Array of URL strings after processing
250
+ */
251
+ private async processMediaArray(
252
+ mediaArray: (string | File | Blob)[] | undefined,
253
+ ownerId: string,
254
+ collectionName: string,
255
+ ): Promise<string[]> {
256
+ if (!mediaArray || mediaArray.length === 0) return [];
257
+
258
+ const result: string[] = [];
259
+
260
+ for (const media of mediaArray) {
261
+ const processedUrl = await this.processMedia(media, ownerId, collectionName);
262
+ if (processedUrl) {
263
+ result.push(processedUrl);
264
+ }
265
+ }
266
+
267
+ return result;
268
+ }
269
+
270
+ /**
271
+ * Transforms validated procedure product data (with productId) to ProcedureProduct objects (with full product)
272
+ * @param productsMetadata Array of validated procedure product data (optional)
273
+ * @param technologyId Technology ID to fetch products from
274
+ * @returns Array of ProcedureProduct objects with full product information
275
+ */
276
+ private async transformProductsMetadata(
277
+ productsMetadata: {
278
+ productId: string;
279
+ price: number;
280
+ currency: Currency;
281
+ pricingMeasure: PricingMeasure;
282
+ isDefault?: boolean;
283
+ }[] | undefined,
284
+ technologyId: string,
285
+ ): Promise<ProcedureProduct[]> {
286
+ // Return empty array if no products metadata provided (for product-free procedures like consultations)
287
+ if (!productsMetadata || productsMetadata.length === 0) {
288
+ return [];
289
+ }
290
+
291
+ const transformedProducts: ProcedureProduct[] = [];
292
+
293
+ for (const productData of productsMetadata) {
294
+ // Fetch the full product object
295
+ const product = await this.productService.getById(technologyId, productData.productId);
296
+ if (!product) {
297
+ throw new Error(
298
+ `Product with ID ${productData.productId} not found for technology ${technologyId}`,
299
+ );
300
+ }
301
+
302
+ // Transform to ProcedureProduct
303
+ transformedProducts.push({
304
+ product,
305
+ price: productData.price,
306
+ currency: productData.currency,
307
+ pricingMeasure: productData.pricingMeasure,
308
+ isDefault: productData.isDefault,
309
+ });
310
+ }
311
+
312
+ return transformedProducts;
313
+ }
314
+
315
+ /**
316
+ * Creates a new procedure
317
+ * @param data - The data for creating a new procedure
318
+ * @returns The created procedure
319
+ */
320
+ async createProcedure(data: CreateProcedureData): Promise<Procedure> {
321
+ const validatedData = createProcedureSchema.parse(data);
322
+
323
+ // Check if this is a product-free procedure (e.g., free consultation)
324
+ const isProductFree = !validatedData.productId;
325
+
326
+ // Generate procedure ID first so we can use it for media uploads
327
+ const procedureId = this.generateId();
328
+
329
+ // Get references to related entities (Category, Subcategory, Technology, and optionally Product)
330
+ const baseEntitiesPromises: Promise<Category | Subcategory | Technology | Product | null>[] = [
331
+ this.categoryService.getById(validatedData.categoryId),
332
+ this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
333
+ this.technologyService.getById(validatedData.technologyId),
334
+ ];
335
+
336
+ // Only fetch product if productId is provided
337
+ if (!isProductFree) {
338
+ baseEntitiesPromises.push(
339
+ this.productService.getById(validatedData.technologyId, validatedData.productId!)
340
+ );
341
+ }
342
+
343
+ const results = await Promise.all(baseEntitiesPromises);
344
+ const category = results[0] as Category | null;
345
+ const subcategory = results[1] as Subcategory | null;
346
+ const technology = results[2] as Technology | null;
347
+ const product = isProductFree ? undefined : ((results[3] as Product | null) || undefined);
348
+
349
+ if (!category || !subcategory || !technology) {
350
+ throw new Error('One or more required base entities not found');
351
+ }
352
+
353
+ // For regular procedures, validate product exists
354
+ if (!isProductFree && !product) {
355
+ throw new Error('Product not found for regular procedure');
356
+ }
357
+
358
+ // Get clinic and practitioner information for aggregation
359
+ const clinicRef = doc(this.db, CLINICS_COLLECTION, validatedData.clinicBranchId);
360
+ const clinicSnapshot = await getDoc(clinicRef);
361
+ if (!clinicSnapshot.exists()) {
362
+ throw new Error(`Clinic with ID ${validatedData.clinicBranchId} not found`);
363
+ }
364
+ const clinic = clinicSnapshot.data() as Clinic; // Assert type
365
+
366
+ // Enforce tier limit before creating procedure (per-provider, per-branch)
367
+ await enforceProcedureLimit(this.db, clinic.clinicGroupId, validatedData.clinicBranchId, validatedData.practitionerId);
368
+
369
+ const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, validatedData.practitionerId);
370
+ const practitionerSnapshot = await getDoc(practitionerRef);
371
+ if (!practitionerSnapshot.exists()) {
372
+ throw new Error(`Practitioner with ID ${validatedData.practitionerId} not found`);
373
+ }
374
+ const practitioner = practitionerSnapshot.data() as Practitioner; // Assert type
375
+
376
+ // Check if practitioner already has a procedure with the same technology ID in this clinic branch
377
+ const existingProceduresQuery = query(
378
+ collection(this.db, PROCEDURES_COLLECTION),
379
+ where('practitionerId', '==', validatedData.practitionerId),
380
+ where('clinicBranchId', '==', validatedData.clinicBranchId),
381
+ where('isActive', '==', true)
382
+ );
383
+ const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
384
+ const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
385
+
386
+ const hasSameTechnology = existingProcedures.some(
387
+ proc => proc.technology?.id === validatedData.technologyId
388
+ );
389
+ if (hasSameTechnology) {
390
+ throw new Error(
391
+ `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} already has a procedure with technology "${technology?.name || validatedData.technologyId}" in this clinic branch`
392
+ );
393
+ }
394
+
395
+ // Process photos if provided
396
+ let processedPhotos: string[] = [];
397
+ if (validatedData.photos && validatedData.photos.length > 0) {
398
+ processedPhotos = await this.processMediaArray(
399
+ validatedData.photos,
400
+ procedureId,
401
+ 'procedure-photos',
402
+ );
403
+ }
404
+
405
+ // If no photos provided and technology has a photoTemplate, use it as default photo
406
+ if (processedPhotos.length === 0 && technology.photoTemplate) {
407
+ console.log(`[ProcedureService] Using technology photoTemplate as default photo: ${technology.photoTemplate}`);
408
+ const photoTemplateUrl = await this.mediaService.getMediaDownloadUrl(technology.photoTemplate);
409
+ if (photoTemplateUrl) {
410
+ processedPhotos.push(photoTemplateUrl);
411
+ } else {
412
+ console.warn(`[ProcedureService] Could not fetch photoTemplate URL for media ID: ${technology.photoTemplate}`);
413
+ }
414
+ }
415
+
416
+ // Transform productsMetadata from validation format to ProcedureProduct format
417
+ const transformedProductsMetadata = await this.transformProductsMetadata(
418
+ validatedData.productsMetadata,
419
+ validatedData.technologyId,
420
+ );
421
+
422
+ // Create aggregated clinic info for the procedure document
423
+ const clinicInfo = {
424
+ id: clinicSnapshot.id,
425
+ name: clinic.name,
426
+ description: clinic.description || '',
427
+ featuredPhoto:
428
+ clinic.featuredPhotos && clinic.featuredPhotos.length > 0
429
+ ? typeof clinic.featuredPhotos[0] === 'string'
430
+ ? clinic.featuredPhotos[0]
431
+ : ''
432
+ : typeof clinic.coverPhoto === 'string'
433
+ ? clinic.coverPhoto
434
+ : '',
435
+ location: clinic.location,
436
+ contactInfo: clinic.contactInfo,
437
+ };
438
+
439
+ // Create aggregated doctor info for the procedure document
440
+ const doctorInfo = {
441
+ id: practitionerSnapshot.id,
442
+ name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
443
+ description: practitioner.basicInfo.bio || '',
444
+ photo:
445
+ typeof practitioner.basicInfo.profileImageUrl === 'string'
446
+ ? practitioner.basicInfo.profileImageUrl
447
+ : '', // Default to empty string if not a processed URL
448
+ rating: practitioner.reviewInfo?.averageRating || 0,
449
+ services: practitioner.procedures || [],
450
+ };
451
+
452
+ // Create the procedure object
453
+ const { productsMetadata: _, productId: __, photos: ___, ...validatedDataWithoutProductsMetadata } = validatedData;
454
+ const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
455
+ id: procedureId,
456
+ ...validatedDataWithoutProductsMetadata,
457
+ // Ensure nameLower is always set even if omitted by client
458
+ nameLower: (validatedData as any).nameLower || validatedData.name.toLowerCase(),
459
+ photos: processedPhotos,
460
+ category, // Embed full objects
461
+ subcategory,
462
+ technology,
463
+ ...(product && { product }), // Only include product field if it exists (Firestore doesn't allow undefined)
464
+ productsMetadata: transformedProductsMetadata, // Use transformed data, not original
465
+ blockingConditions: technology.blockingConditions,
466
+ contraindications: technology.contraindications || [],
467
+ contraindicationIds: technology.contraindications?.map(c => c.id) || [],
468
+ treatmentBenefits: technology.benefits,
469
+ treatmentBenefitIds: Array.isArray(technology.benefits)
470
+ ? technology.benefits.map(b => typeof b === 'string' ? b : b.id)
471
+ : [],
472
+ preRequirements: technology.requirements.pre,
473
+ postRequirements: technology.requirements.post,
474
+ certificationRequirement: technology.certificationRequirement,
475
+ documentationTemplates: technology?.documentationTemplates || [],
476
+ clinicInfo, // Embed aggregated info
477
+ doctorInfo, // Embed aggregated info
478
+ reviewInfo: {
479
+ // Default empty reviews
480
+ totalReviews: 0,
481
+ averageRating: 0,
482
+ effectivenessOfTreatment: 0,
483
+ outcomeExplanation: 0,
484
+ painManagement: 0,
485
+ followUpCare: 0,
486
+ valueForMoney: 0,
487
+ recommendationPercentage: 0,
488
+ },
489
+ isActive: true, // Default to active
490
+ };
491
+
492
+ // 🔥 DEBUG: Find undefined fields before writing to Firestore
493
+ console.log('🔥🔥🔥 CREATE PROCEDURE - Processing procedure:', procedureId);
494
+ console.log('🔥🔥🔥 FULL PROCEDURE OBJECT:', JSON.stringify(newProcedure, null, 2));
495
+
496
+ const undefinedFields: string[] = [];
497
+ Object.entries(newProcedure).forEach(([key, value]) => {
498
+ if (value === undefined) {
499
+ undefinedFields.push(key);
500
+ }
501
+ });
502
+ if (undefinedFields.length > 0) {
503
+ console.error('🔥🔥🔥 UNDEFINED FIELDS DETECTED:', undefinedFields);
504
+ throw new Error(`Cannot write procedure with undefined fields: ${undefinedFields.join(', ')}`);
505
+ }
506
+ console.log('🔥🔥🔥 NO UNDEFINED FIELDS - Proceeding with setDoc');
507
+
508
+ // Create the procedure document
509
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, procedureId);
510
+ await setDoc(procedureRef, {
511
+ ...newProcedure,
512
+ createdAt: serverTimestamp(),
513
+ updatedAt: serverTimestamp(),
514
+ });
515
+
516
+ // Return the created procedure (fetch again to get server timestamps)
517
+ const savedDoc = await getDoc(procedureRef);
518
+ return savedDoc.data() as Procedure;
519
+ }
520
+
521
+ /**
522
+ * Validates if a practitioner can perform a procedure based on certification requirements.
523
+ *
524
+ * @param procedure - The procedure to check
525
+ * @param practitioner - The practitioner to validate
526
+ * @returns true if practitioner can perform the procedure, false otherwise
527
+ */
528
+ canPractitionerPerformProcedure(procedure: Procedure, practitioner: Practitioner): boolean {
529
+ if (!practitioner.certification) {
530
+ return false;
531
+ }
532
+
533
+ const requiredCert = procedure.certificationRequirement;
534
+ const practitionerCert = practitioner.certification;
535
+
536
+ // Check certification level
537
+ const levelOrder = [
538
+ 'aesthetician',
539
+ 'nurse_assistant',
540
+ 'nurse',
541
+ 'nurse_practitioner',
542
+ 'physician_assistant',
543
+ 'doctor',
544
+ 'specialist',
545
+ 'plastic_surgeon',
546
+ ];
547
+
548
+ const practitionerLevelIndex = levelOrder.indexOf(practitionerCert.level);
549
+ const requiredLevelIndex = levelOrder.indexOf(requiredCert.minimumLevel);
550
+
551
+ if (practitionerLevelIndex < requiredLevelIndex) {
552
+ return false;
553
+ }
554
+
555
+ // Check required specialties
556
+ const requiredSpecialties = requiredCert.requiredSpecialties || [];
557
+ if (requiredSpecialties.length > 0) {
558
+ const practitionerSpecialties = practitionerCert.specialties || [];
559
+ const hasAllRequired = requiredSpecialties.every(specialty =>
560
+ practitionerSpecialties.includes(specialty)
561
+ );
562
+ if (!hasAllRequired) {
563
+ return false;
564
+ }
565
+ }
566
+
567
+ return true;
568
+ }
569
+
570
+ /**
571
+ * Clones an existing procedure for a target practitioner.
572
+ * This creates a new procedure document with the same data as the source procedure,
573
+ * but linked to the target practitioner.
574
+ *
575
+ * @param sourceProcedureId - The ID of the procedure to clone
576
+ * @param targetPractitionerId - The ID of the practitioner to assign the cloned procedure to
577
+ * @param overrides - Optional overrides for the new procedure (e.g. price, duration, isActive)
578
+ * @returns The newly created procedure
579
+ */
580
+ async cloneProcedureForPractitioner(
581
+ sourceProcedureId: string,
582
+ targetPractitionerId: string,
583
+ overrides?: Partial<CreateProcedureData> & { isActive?: boolean }
584
+ ): Promise<Procedure> {
585
+ // 1. Fetch source procedure
586
+ const sourceProcedure = await this.getProcedure(sourceProcedureId);
587
+ if (!sourceProcedure) {
588
+ throw new Error(`Source procedure with ID ${sourceProcedureId} not found`);
589
+ }
590
+
591
+ // 2. Fetch target practitioner
592
+ const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, targetPractitionerId);
593
+ const practitionerSnapshot = await getDoc(practitionerRef);
594
+ if (!practitionerSnapshot.exists()) {
595
+ throw new Error(`Target practitioner with ID ${targetPractitionerId} not found`);
596
+ }
597
+ const practitioner = practitionerSnapshot.data() as Practitioner;
598
+
599
+ // 3. Validate certification
600
+ if (!this.canPractitionerPerformProcedure(sourceProcedure, practitioner)) {
601
+ throw new Error(
602
+ `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} does not meet the certification requirements for this procedure`
603
+ );
604
+ }
605
+
606
+ // 4. Check if practitioner already has a procedure with the same technology ID in this clinic branch
607
+ const existingProceduresQuery = query(
608
+ collection(this.db, PROCEDURES_COLLECTION),
609
+ where('practitionerId', '==', targetPractitionerId),
610
+ where('clinicBranchId', '==', sourceProcedure.clinicBranchId),
611
+ where('isActive', '==', true)
612
+ );
613
+ const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
614
+ const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
615
+
616
+ const hasSameTechnology = existingProcedures.some(
617
+ proc => proc.technology?.id === sourceProcedure.technology?.id
618
+ );
619
+ if (hasSameTechnology) {
620
+ throw new Error(
621
+ `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} already has a procedure with technology "${sourceProcedure.technology?.name || sourceProcedure.technology?.id}" in this clinic branch`
622
+ );
623
+ }
624
+
625
+ // 5. Prepare data for new procedure
626
+ const newProcedureId = this.generateId();
627
+
628
+ // Create aggregated doctor info for the new procedure
629
+ const doctorInfo = {
630
+ id: practitioner.id,
631
+ name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
632
+ description: practitioner.basicInfo.bio || '',
633
+ photo:
634
+ typeof practitioner.basicInfo.profileImageUrl === 'string'
635
+ ? practitioner.basicInfo.profileImageUrl
636
+ : '',
637
+ rating: practitioner.reviewInfo?.averageRating || 0,
638
+ services: practitioner.procedures || [],
639
+ };
640
+
641
+ // Construct the new procedure object
642
+ // We copy everything from source, but override specific fields
643
+ const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
644
+ ...sourceProcedure,
645
+ id: newProcedureId,
646
+ practitionerId: targetPractitionerId,
647
+ doctorInfo, // Link to new doctor
648
+
649
+ // Reset review info for the new procedure
650
+ reviewInfo: {
651
+ totalReviews: 0,
652
+ averageRating: 0,
653
+ effectivenessOfTreatment: 0,
654
+ outcomeExplanation: 0,
655
+ painManagement: 0,
656
+ followUpCare: 0,
657
+ valueForMoney: 0,
658
+ recommendationPercentage: 0,
659
+ },
660
+
661
+ // Apply any overrides if provided
662
+ ...(overrides?.price !== undefined && { price: overrides.price }),
663
+ ...(overrides?.duration !== undefined && { duration: overrides.duration }),
664
+ ...(overrides?.description !== undefined && { description: overrides.description }),
665
+
666
+ // Ensure it's active by default unless specified otherwise
667
+ isActive: overrides?.isActive !== undefined ? overrides.isActive : true,
668
+ };
669
+
670
+ // 6. Save to Firestore
671
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, newProcedureId);
672
+ await setDoc(procedureRef, {
673
+ ...newProcedure,
674
+ createdAt: serverTimestamp(),
675
+ updatedAt: serverTimestamp(),
676
+ });
677
+
678
+ // 7. Return the new procedure
679
+ const savedDoc = await getDoc(procedureRef);
680
+ return savedDoc.data() as Procedure;
681
+ }
682
+
683
+ /**
684
+ * Clones an existing procedure for multiple target practitioners.
685
+ * This creates new procedure documents with the same data as the source procedure,
686
+ * but linked to each target practitioner.
687
+ *
688
+ * @param sourceProcedureId - The ID of the procedure to clone
689
+ * @param targetPractitionerIds - Array of practitioner IDs to assign the cloned procedure to
690
+ * @param overrides - Optional overrides for the new procedures (e.g. price, duration, isActive)
691
+ * @returns Array of newly created procedures
692
+ */
693
+ async bulkCloneProcedureForPractitioners(
694
+ sourceProcedureId: string,
695
+ targetPractitionerIds: string[],
696
+ overrides?: Partial<CreateProcedureData> & { isActive?: boolean }
697
+ ): Promise<Procedure[]> {
698
+ if (!targetPractitionerIds || targetPractitionerIds.length === 0) {
699
+ throw new Error('At least one target practitioner ID is required');
700
+ }
701
+
702
+ // 1. Fetch source procedure
703
+ const sourceProcedure = await this.getProcedure(sourceProcedureId);
704
+ if (!sourceProcedure) {
705
+ throw new Error(`Source procedure with ID ${sourceProcedureId} not found`);
706
+ }
707
+
708
+ // 2. Fetch all target practitioners
709
+ const practitionerPromises = targetPractitionerIds.map(id =>
710
+ getDoc(doc(this.db, PRACTITIONERS_COLLECTION, id))
711
+ );
712
+ const practitionerSnapshots = await Promise.all(practitionerPromises);
713
+
714
+ // 3. Validate all practitioners exist, can perform the procedure, and don't already have the same technology
715
+ const practitioners: Practitioner[] = [];
716
+ const sourceTechnologyId = sourceProcedure.technology?.id;
717
+
718
+ for (let i = 0; i < practitionerSnapshots.length; i++) {
719
+ const snapshot = practitionerSnapshots[i];
720
+ if (!snapshot.exists()) {
721
+ throw new Error(`Target practitioner with ID ${targetPractitionerIds[i]} not found`);
722
+ }
723
+ const practitioner = snapshot.data() as Practitioner;
724
+
725
+ if (!this.canPractitionerPerformProcedure(sourceProcedure, practitioner)) {
726
+ throw new Error(
727
+ `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} does not meet the certification requirements for this procedure`
728
+ );
729
+ }
730
+
731
+ // Check if practitioner already has a procedure with the same technology ID in this clinic branch
732
+ const existingProceduresQuery = query(
733
+ collection(this.db, PROCEDURES_COLLECTION),
734
+ where('practitionerId', '==', practitioner.id),
735
+ where('clinicBranchId', '==', sourceProcedure.clinicBranchId),
736
+ where('isActive', '==', true)
737
+ );
738
+ const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
739
+ const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
740
+
741
+ const hasSameTechnology = existingProcedures.some(
742
+ proc => proc.technology?.id === sourceTechnologyId
743
+ );
744
+ if (hasSameTechnology) {
745
+ throw new Error(
746
+ `Practitioner ${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName} already has a procedure with technology "${sourceProcedure.technology?.name || sourceTechnologyId}" in this clinic branch`
747
+ );
748
+ }
749
+
750
+ practitioners.push(practitioner);
751
+ }
752
+
753
+ // 4. Create procedures in batch
754
+ const batch = writeBatch(this.db);
755
+ const newProcedures: Omit<Procedure, 'createdAt' | 'updatedAt'>[] = [];
756
+
757
+ for (const practitioner of practitioners) {
758
+ const newProcedureId = this.generateId();
759
+
760
+ // Create aggregated doctor info for the new procedure
761
+ const doctorInfo = {
762
+ id: practitioner.id,
763
+ name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
764
+ description: practitioner.basicInfo.bio || '',
765
+ photo:
766
+ typeof practitioner.basicInfo.profileImageUrl === 'string'
767
+ ? practitioner.basicInfo.profileImageUrl
768
+ : '',
769
+ rating: practitioner.reviewInfo?.averageRating || 0,
770
+ services: practitioner.procedures || [],
771
+ };
772
+
773
+ // Construct the new procedure object
774
+ const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
775
+ ...sourceProcedure,
776
+ id: newProcedureId,
777
+ practitionerId: practitioner.id,
778
+ doctorInfo,
779
+
780
+ // Reset review info for the new procedure
781
+ reviewInfo: {
782
+ totalReviews: 0,
783
+ averageRating: 0,
784
+ effectivenessOfTreatment: 0,
785
+ outcomeExplanation: 0,
786
+ painManagement: 0,
787
+ followUpCare: 0,
788
+ valueForMoney: 0,
789
+ recommendationPercentage: 0,
790
+ },
791
+
792
+ // Apply any overrides if provided
793
+ ...(overrides?.price !== undefined && { price: overrides.price }),
794
+ ...(overrides?.duration !== undefined && { duration: overrides.duration }),
795
+ ...(overrides?.description !== undefined && { description: overrides.description }),
796
+
797
+ // Ensure it's active by default unless specified otherwise
798
+ isActive: overrides?.isActive !== undefined ? overrides.isActive : true,
799
+ };
800
+
801
+ newProcedures.push(newProcedure);
802
+
803
+ // Add to batch
804
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, newProcedureId);
805
+ batch.set(procedureRef, {
806
+ ...newProcedure,
807
+ createdAt: serverTimestamp(),
808
+ updatedAt: serverTimestamp(),
809
+ });
810
+ }
811
+
812
+ // 5. Commit batch
813
+ await batch.commit();
814
+
815
+ // 6. Fetch and return the created procedures
816
+ const createdProcedures = await Promise.all(
817
+ newProcedures.map(p => this.getProcedure(p.id))
818
+ );
819
+
820
+ return createdProcedures.filter((p): p is Procedure => p !== null);
821
+ }
822
+
823
+ /**
824
+ * Creates multiple procedures for a list of practitioners based on common data.
825
+ * This method is optimized for bulk creation to reduce database reads and writes.
826
+ *
827
+ * @param baseData - The base data for the procedures to be created, omitting the practitionerId.
828
+ * @param practitionerIds - An array of practitioner IDs for whom the procedures will be created.
829
+ * @returns A promise that resolves to an array of the newly created procedures.
830
+ */
831
+ async bulkCreateProcedures(
832
+ baseData: Omit<CreateProcedureData, 'practitionerId'>,
833
+ practitionerIds: string[],
834
+ ): Promise<Procedure[]> {
835
+ // 1. Validation
836
+ if (!practitionerIds || practitionerIds.length === 0) {
837
+ throw new Error('Practitioner IDs array cannot be empty.');
838
+ }
839
+
840
+ // Check if this is a product-free procedure
841
+ const isProductFree = !baseData.productId;
842
+
843
+ // Add a dummy practitionerId for the validation schema to pass
844
+ const validationData = { ...baseData, practitionerId: practitionerIds[0] };
845
+ const validatedData = createProcedureSchema.parse(validationData);
846
+
847
+ // 2. Fetch common data once to avoid redundant reads
848
+ const baseEntitiesPromises: Promise<Category | Subcategory | Technology | Product | null>[] = [
849
+ this.categoryService.getById(validatedData.categoryId),
850
+ this.subcategoryService.getById(validatedData.categoryId, validatedData.subcategoryId),
851
+ this.technologyService.getById(validatedData.technologyId),
852
+ ];
853
+
854
+ // Only fetch product if productId is provided
855
+ if (!isProductFree) {
856
+ baseEntitiesPromises.push(
857
+ this.productService.getById(validatedData.technologyId, validatedData.productId!)
858
+ );
859
+ }
860
+
861
+ // Fetch clinic separately to maintain type safety
862
+ const clinicSnapshotPromise = getDoc(doc(this.db, CLINICS_COLLECTION, validatedData.clinicBranchId));
863
+
864
+ const [baseResults, clinicSnapshot] = await Promise.all([
865
+ Promise.all(baseEntitiesPromises),
866
+ clinicSnapshotPromise
867
+ ]);
868
+
869
+ const category = baseResults[0] as Category | null;
870
+ const subcategory = baseResults[1] as Subcategory | null;
871
+ const technology = baseResults[2] as Technology | null;
872
+ const product = isProductFree ? undefined : ((baseResults[3] as Product | null) || undefined);
873
+
874
+ if (!category || !subcategory || !technology) {
875
+ throw new Error('One or more required base entities not found');
876
+ }
877
+
878
+ // For regular procedures, validate product exists
879
+ if (!isProductFree && !product) {
880
+ throw new Error('Product not found for regular procedure');
881
+ }
882
+
883
+ if (!clinicSnapshot || !clinicSnapshot.exists()) {
884
+ throw new Error(`Clinic with ID ${validatedData.clinicBranchId} not found`);
885
+ }
886
+ const clinic = clinicSnapshot.data() as Clinic;
887
+
888
+ // Enforce tier limit for each practitioner (per-provider, per-branch)
889
+ for (const practitionerId of practitionerIds) {
890
+ await enforceProcedureLimit(this.db, clinic.clinicGroupId, validatedData.clinicBranchId, practitionerId);
891
+ }
892
+
893
+ // 3. Handle media uploads once for efficiency
894
+ let processedPhotos: string[] = [];
895
+ if (validatedData.photos && validatedData.photos.length > 0) {
896
+ const batchId = this.generateId(); // Use a single ID for all media in this batch
897
+ processedPhotos = await this.processMediaArray(
898
+ validatedData.photos,
899
+ batchId,
900
+ 'procedure-photos-batch',
901
+ );
902
+ }
903
+
904
+ // If no photos provided and technology has a photoTemplate, use it as default photo
905
+ if (processedPhotos.length === 0 && technology.photoTemplate) {
906
+ console.log(`[ProcedureService] Using technology photoTemplate as default photo for bulk create: ${technology.photoTemplate}`);
907
+ const photoTemplateUrl = await this.mediaService.getMediaDownloadUrl(technology.photoTemplate);
908
+ if (photoTemplateUrl) {
909
+ processedPhotos.push(photoTemplateUrl);
910
+ } else {
911
+ console.warn(`[ProcedureService] Could not fetch photoTemplate URL for media ID: ${technology.photoTemplate}`);
912
+ }
913
+ }
914
+
915
+ // Transform productsMetadata from validation format to ProcedureProduct format
916
+ const transformedProductsMetadata = await this.transformProductsMetadata(
917
+ validatedData.productsMetadata,
918
+ validatedData.technologyId,
919
+ );
920
+
921
+ // 4. Fetch all practitioner data efficiently
922
+ const practitionersMap = new Map<string, Practitioner>();
923
+ // Use 'in' query in chunks of 30, as this is the Firestore limit
924
+ for (let i = 0; i < practitionerIds.length; i += 30) {
925
+ const chunk = practitionerIds.slice(i, i + 30);
926
+ const practitionersQuery = query(
927
+ collection(this.db, PRACTITIONERS_COLLECTION),
928
+ where(documentId(), 'in', chunk),
929
+ );
930
+ const practitionersSnapshot = await getDocs(practitionersQuery);
931
+ practitionersSnapshot.docs.forEach(doc => {
932
+ practitionersMap.set(doc.id, doc.data() as Practitioner);
933
+ });
934
+ }
935
+
936
+ // Verify all practitioners were found
937
+ if (practitionersMap.size !== practitionerIds.length) {
938
+ const foundIds = Array.from(practitionersMap.keys());
939
+ const notFoundIds = practitionerIds.filter(id => !foundIds.includes(id));
940
+ throw new Error(`The following practitioners were not found: ${notFoundIds.join(', ')}`);
941
+ }
942
+
943
+ // 5. Check for duplicates across all practitioners before creating any procedures
944
+ const duplicatePractitioners: string[] = [];
945
+ const duplicateChecks = await Promise.all(
946
+ practitionerIds.map(async (practitionerId) => {
947
+ const existingProceduresQuery = query(
948
+ collection(this.db, PROCEDURES_COLLECTION),
949
+ where('practitionerId', '==', practitionerId),
950
+ where('clinicBranchId', '==', validatedData.clinicBranchId),
951
+ where('isActive', '==', true)
952
+ );
953
+ const existingProceduresSnapshot = await getDocs(existingProceduresQuery);
954
+ const existingProcedures = existingProceduresSnapshot.docs.map(doc => doc.data() as Procedure);
955
+
956
+ const hasSameTechnology = existingProcedures.some(
957
+ proc => proc.technology?.id === validatedData.technologyId
958
+ );
959
+
960
+ return { practitionerId, hasSameTechnology };
961
+ })
962
+ );
963
+
964
+ // Collect all practitioners with duplicates
965
+ duplicateChecks.forEach(({ practitionerId, hasSameTechnology }) => {
966
+ if (hasSameTechnology) {
967
+ duplicatePractitioners.push(practitionerId);
968
+ }
969
+ });
970
+
971
+ // If any duplicates found, throw error listing all of them
972
+ if (duplicatePractitioners.length > 0) {
973
+ const duplicateNames = duplicatePractitioners
974
+ .map(id => {
975
+ const practitioner = practitionersMap.get(id)!;
976
+ return `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`;
977
+ })
978
+ .join(', ');
979
+
980
+ throw new Error(
981
+ `The following practitioner(s) already have a procedure with technology "${technology?.name || validatedData.technologyId}" in this clinic branch: ${duplicateNames}. Please remove them from the selection and try again.`
982
+ );
983
+ }
984
+
985
+ // 6. Use a Firestore batch for atomic creation
986
+ const batch = writeBatch(this.db);
987
+ const createdProcedureIds: string[] = [];
988
+ const clinicInfo = {
989
+ id: clinicSnapshot.id,
990
+ name: clinic.name,
991
+ description: clinic.description || '',
992
+ featuredPhoto:
993
+ clinic.featuredPhotos && clinic.featuredPhotos.length > 0
994
+ ? typeof clinic.featuredPhotos[0] === 'string'
995
+ ? clinic.featuredPhotos[0]
996
+ : ''
997
+ : typeof clinic.coverPhoto === 'string'
998
+ ? clinic.coverPhoto
999
+ : '',
1000
+ location: clinic.location,
1001
+ contactInfo: clinic.contactInfo,
1002
+ };
1003
+
1004
+ for (const practitionerId of practitionerIds) {
1005
+ const practitioner = practitionersMap.get(practitionerId)!;
1006
+
1007
+ const doctorInfo = {
1008
+ id: practitioner.id,
1009
+ name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
1010
+ description: practitioner.basicInfo.bio || '',
1011
+ photo:
1012
+ typeof practitioner.basicInfo.profileImageUrl === 'string'
1013
+ ? practitioner.basicInfo.profileImageUrl
1014
+ : '',
1015
+ rating: practitioner.reviewInfo?.averageRating || 0,
1016
+ services: practitioner.procedures || [],
1017
+ };
1018
+
1019
+ const procedureId = this.generateId();
1020
+ createdProcedureIds.push(procedureId);
1021
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, procedureId);
1022
+
1023
+ // Construct the new procedure, reusing common data
1024
+ const { productsMetadata: _, productId: __, photos: ___, ...validatedDataWithoutProductsMetadata } = validatedData;
1025
+ const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
1026
+ id: procedureId,
1027
+ ...validatedDataWithoutProductsMetadata,
1028
+ nameLower: (validatedData as any).nameLower || validatedData.name.toLowerCase(),
1029
+ practitionerId: practitionerId, // Override practitionerId with the correct one
1030
+ photos: processedPhotos,
1031
+ category,
1032
+ subcategory,
1033
+ technology,
1034
+ ...(product && { product }), // Only include product field if it exists (Firestore doesn't allow undefined)
1035
+ productsMetadata: transformedProductsMetadata, // Use transformed data, not original
1036
+ blockingConditions: technology.blockingConditions,
1037
+ contraindications: technology.contraindications || [],
1038
+ contraindicationIds: technology.contraindications?.map(c => c.id) || [],
1039
+ treatmentBenefits: technology.benefits,
1040
+ treatmentBenefitIds: Array.isArray(technology.benefits)
1041
+ ? technology.benefits.map(b => typeof b === 'string' ? b : b.id)
1042
+ : [],
1043
+ preRequirements: technology.requirements.pre,
1044
+ postRequirements: technology.requirements.post,
1045
+ certificationRequirement: technology.certificationRequirement,
1046
+ documentationTemplates: technology?.documentationTemplates || [],
1047
+ clinicInfo,
1048
+ doctorInfo, // Set specific doctor info
1049
+ reviewInfo: {
1050
+ totalReviews: 0,
1051
+ averageRating: 0,
1052
+ effectivenessOfTreatment: 0,
1053
+ outcomeExplanation: 0,
1054
+ painManagement: 0,
1055
+ followUpCare: 0,
1056
+ valueForMoney: 0,
1057
+ recommendationPercentage: 0,
1058
+ },
1059
+ isActive: true,
1060
+ };
1061
+
1062
+ // 🔥 DEBUG: Find undefined fields before writing to Firestore
1063
+ console.log('🔥🔥🔥 BULK CREATE - Processing procedure:', procedureId, 'for practitioner:', practitionerId);
1064
+ console.log('🔥🔥🔥 FULL PROCEDURE OBJECT:', JSON.stringify(newProcedure, null, 2));
1065
+
1066
+ const undefinedFields: string[] = [];
1067
+ Object.entries(newProcedure).forEach(([key, value]) => {
1068
+ if (value === undefined) {
1069
+ undefinedFields.push(key);
1070
+ }
1071
+ });
1072
+ if (undefinedFields.length > 0) {
1073
+ console.error('🔥🔥🔥 UNDEFINED FIELDS DETECTED:', undefinedFields);
1074
+ throw new Error(`Cannot write procedure with undefined fields: ${undefinedFields.join(', ')}`);
1075
+ }
1076
+ console.log('🔥🔥🔥 NO UNDEFINED FIELDS - Proceeding with batch.set');
1077
+
1078
+ batch.set(procedureRef, {
1079
+ ...newProcedure,
1080
+ createdAt: serverTimestamp(),
1081
+ updatedAt: serverTimestamp(),
1082
+ });
1083
+ }
1084
+
1085
+ // 7. Commit the atomic batch write
1086
+ await batch.commit();
1087
+
1088
+ // 8. Fetch and return the newly created procedures
1089
+ const fetchedProcedures: Procedure[] = [];
1090
+ for (let i = 0; i < createdProcedureIds.length; i += 30) {
1091
+ const chunk = createdProcedureIds.slice(i, i + 30);
1092
+ const q = query(collection(this.db, PROCEDURES_COLLECTION), where(documentId(), 'in', chunk));
1093
+ const snapshot = await getDocs(q);
1094
+ snapshot.forEach(doc => {
1095
+ fetchedProcedures.push(doc.data() as Procedure);
1096
+ });
1097
+ }
1098
+
1099
+ return fetchedProcedures;
1100
+ }
1101
+
1102
+ /**
1103
+ * Gets a procedure by ID
1104
+ * @param id - The ID of the procedure to get
1105
+ * @returns The procedure if found, null otherwise
1106
+ */
1107
+ async getProcedure(id: string): Promise<Procedure | null> {
1108
+ const docRef = doc(this.db, PROCEDURES_COLLECTION, id);
1109
+ const docSnap = await getDoc(docRef);
1110
+
1111
+ if (!docSnap.exists()) {
1112
+ return null;
1113
+ }
1114
+
1115
+ return docSnap.data() as Procedure;
1116
+ }
1117
+
1118
+ /**
1119
+ * Gets all procedures for a clinic branch
1120
+ * @param clinicBranchId - The ID of the clinic branch
1121
+ * @param excludeDraftPractitioners - Whether to exclude procedures if the practitioner is in DRAFT status (default: false for admin views)
1122
+ * @returns List of procedures
1123
+ */
1124
+ async getProceduresByClinicBranch(
1125
+ clinicBranchId: string,
1126
+ excludeDraftPractitioners: boolean = false
1127
+ ): Promise<Procedure[]> {
1128
+ const q = query(
1129
+ collection(this.db, PROCEDURES_COLLECTION),
1130
+ where('clinicBranchId', '==', clinicBranchId),
1131
+ where('isActive', '==', true),
1132
+ );
1133
+ const snapshot = await getDocs(q);
1134
+ const procedures = snapshot.docs.map(doc => doc.data() as Procedure);
1135
+
1136
+ // Filter out procedures from draft practitioners only if explicitly requested (for patient-facing apps)
1137
+ if (excludeDraftPractitioners) {
1138
+ return await this.filterDraftPractitionerProcedures(procedures);
1139
+ }
1140
+
1141
+ return procedures;
1142
+ }
1143
+
1144
+ /**
1145
+ * Gets all procedures for a practitioner
1146
+ * @param practitionerId - The ID of the practitioner
1147
+ * @param clinicBranchId - Optional clinic branch ID to filter by
1148
+ * @param excludeDraftPractitioners - Whether to exclude procedures if the practitioner is in DRAFT status
1149
+ * @returns List of procedures
1150
+ */
1151
+ async getProceduresByPractitioner(
1152
+ practitionerId: string,
1153
+ clinicBranchId?: string,
1154
+ excludeDraftPractitioners: boolean = true
1155
+ ): Promise<Procedure[]> {
1156
+ const constraints: QueryConstraint[] = [
1157
+ where('practitionerId', '==', practitionerId),
1158
+ where('isActive', '==', true),
1159
+ ];
1160
+
1161
+ if (clinicBranchId) {
1162
+ constraints.push(where('clinicBranchId', '==', clinicBranchId));
1163
+ }
1164
+
1165
+ const q = query(
1166
+ collection(this.db, PROCEDURES_COLLECTION),
1167
+ ...constraints
1168
+ );
1169
+ const snapshot = await getDocs(q);
1170
+ const procedures = snapshot.docs.map(doc => doc.data() as Procedure);
1171
+
1172
+ // If we need to exclude draft practitioners and have the service available
1173
+ if (excludeDraftPractitioners && this.practitionerService) {
1174
+ try {
1175
+ const practitioner = await this.practitionerService.getPractitioner(practitionerId);
1176
+ if (practitioner && practitioner.status === PractitionerStatus.DRAFT) {
1177
+ console.log(`[ProcedureService] Excluding procedures for draft practitioner ${practitionerId}`);
1178
+ return [];
1179
+ }
1180
+ } catch (error) {
1181
+ console.error(`[ProcedureService] Error checking practitioner status for ${practitionerId}:`, error);
1182
+ // On error, default to returning procedures to avoid breaking UI
1183
+ }
1184
+ }
1185
+
1186
+ return procedures;
1187
+ }
1188
+
1189
+ /**
1190
+ * Gets all inactive procedures for a practitioner
1191
+ * @param practitionerId - The ID of the practitioner
1192
+ * @returns List of inactive procedures
1193
+ */
1194
+ async getInactiveProceduresByPractitioner(practitionerId: string): Promise<Procedure[]> {
1195
+ const q = query(
1196
+ collection(this.db, PROCEDURES_COLLECTION),
1197
+ where('practitionerId', '==', practitionerId),
1198
+ where('isActive', '==', false),
1199
+ );
1200
+ const snapshot = await getDocs(q);
1201
+ return snapshot.docs.map(doc => doc.data() as Procedure);
1202
+ }
1203
+
1204
+ /**
1205
+ * Updates a procedure
1206
+ * @param id - The ID of the procedure to update
1207
+ * @param data - The data to update the procedure with
1208
+ * @returns The updated procedure
1209
+ */
1210
+ async updateProcedure(id: string, data: UpdateProcedureData): Promise<Procedure> {
1211
+ const validatedData = updateProcedureSchema.parse(data);
1212
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, id);
1213
+ const procedureSnapshot = await getDoc(procedureRef);
1214
+
1215
+ if (!procedureSnapshot.exists()) {
1216
+ throw new Error(`Procedure with ID ${id} not found`);
1217
+ }
1218
+
1219
+ const existingProcedure = procedureSnapshot.data() as Procedure;
1220
+ let updatedProcedureData: Partial<Procedure> = {};
1221
+
1222
+ // Copy validated simple fields
1223
+ if (validatedData.name !== undefined) updatedProcedureData.name = validatedData.name;
1224
+ if (validatedData.description !== undefined)
1225
+ updatedProcedureData.description = validatedData.description;
1226
+ if (validatedData.price !== undefined) updatedProcedureData.price = validatedData.price;
1227
+ if (validatedData.currency !== undefined)
1228
+ updatedProcedureData.currency = validatedData.currency;
1229
+ if (validatedData.pricingMeasure !== undefined)
1230
+ updatedProcedureData.pricingMeasure = validatedData.pricingMeasure;
1231
+ if (validatedData.duration !== undefined)
1232
+ updatedProcedureData.duration = validatedData.duration;
1233
+ if (validatedData.isActive !== undefined)
1234
+ updatedProcedureData.isActive = validatedData.isActive;
1235
+
1236
+ let practitionerChanged = false;
1237
+ let clinicChanged = false;
1238
+ const oldPractitionerId = existingProcedure.practitionerId;
1239
+ const oldClinicId = existingProcedure.clinicBranchId;
1240
+ let newPractitioner: Practitioner | null = null;
1241
+ let newClinic: Clinic | null = null;
1242
+
1243
+ // Process photos if provided
1244
+ if (validatedData.photos !== undefined) {
1245
+ updatedProcedureData.photos = await this.processMediaArray(
1246
+ validatedData.photos,
1247
+ id,
1248
+ 'procedure-photos',
1249
+ );
1250
+ }
1251
+
1252
+ // Transform productsMetadata if provided
1253
+ if (validatedData.productsMetadata !== undefined) {
1254
+ const technologyId = validatedData.technologyId ?? existingProcedure.technology.id;
1255
+ if (!technologyId) {
1256
+ throw new Error('Technology ID is required for updating products metadata');
1257
+ }
1258
+ updatedProcedureData.productsMetadata = await this.transformProductsMetadata(
1259
+ validatedData.productsMetadata,
1260
+ technologyId,
1261
+ );
1262
+ }
1263
+
1264
+ // --- Prepare updates and fetch new related data if IDs change ---
1265
+
1266
+ // Handle Practitioner Change
1267
+ if (validatedData.practitionerId && validatedData.practitionerId !== oldPractitionerId) {
1268
+ practitionerChanged = true;
1269
+ const newPractitionerRef = doc(
1270
+ this.db,
1271
+ PRACTITIONERS_COLLECTION,
1272
+ validatedData.practitionerId,
1273
+ );
1274
+ const newPractitionerSnap = await getDoc(newPractitionerRef);
1275
+ if (!newPractitionerSnap.exists())
1276
+ throw new Error(`New Practitioner ${validatedData.practitionerId} not found`);
1277
+ newPractitioner = newPractitionerSnap.data() as Practitioner;
1278
+ // Update doctorInfo within the procedure document
1279
+ updatedProcedureData.doctorInfo = {
1280
+ id: newPractitioner.id,
1281
+ name: `${newPractitioner.basicInfo.firstName} ${newPractitioner.basicInfo.lastName}`,
1282
+ description: newPractitioner.basicInfo.bio || '',
1283
+ photo:
1284
+ typeof newPractitioner.basicInfo.profileImageUrl === 'string'
1285
+ ? newPractitioner.basicInfo.profileImageUrl
1286
+ : '', // Default to empty string if not a processed URL
1287
+ rating: newPractitioner.reviewInfo?.averageRating || 0,
1288
+ services: newPractitioner.procedures || [],
1289
+ };
1290
+ }
1291
+
1292
+ // Handle Clinic Change
1293
+ if (validatedData.clinicBranchId && validatedData.clinicBranchId !== oldClinicId) {
1294
+ clinicChanged = true;
1295
+ const newClinicRef = doc(this.db, CLINICS_COLLECTION, validatedData.clinicBranchId);
1296
+ const newClinicSnap = await getDoc(newClinicRef);
1297
+ if (!newClinicSnap.exists())
1298
+ throw new Error(`New Clinic ${validatedData.clinicBranchId} not found`);
1299
+ newClinic = newClinicSnap.data() as Clinic;
1300
+ // Update clinicInfo within the procedure document
1301
+ updatedProcedureData.clinicInfo = {
1302
+ id: newClinic.id,
1303
+ name: newClinic.name,
1304
+ description: newClinic.description || '',
1305
+ featuredPhoto:
1306
+ newClinic.featuredPhotos && newClinic.featuredPhotos.length > 0
1307
+ ? typeof newClinic.featuredPhotos[0] === 'string'
1308
+ ? newClinic.featuredPhotos[0]
1309
+ : ''
1310
+ : typeof newClinic.coverPhoto === 'string'
1311
+ ? newClinic.coverPhoto
1312
+ : '',
1313
+ location: newClinic.location,
1314
+ contactInfo: newClinic.contactInfo,
1315
+ };
1316
+ }
1317
+
1318
+ // Handle Category/Subcategory/Technology/Product Changes
1319
+ let finalCategoryId = existingProcedure.category.id;
1320
+ if (validatedData.name) {
1321
+ updatedProcedureData.nameLower = validatedData.name.toLowerCase();
1322
+ }
1323
+ if (validatedData.categoryId) {
1324
+ const category = await this.categoryService.getById(validatedData.categoryId);
1325
+ if (!category) throw new Error(`Category ${validatedData.categoryId} not found`);
1326
+ updatedProcedureData.category = category;
1327
+ finalCategoryId = category.id; // Update finalCategoryId if category changed
1328
+ }
1329
+
1330
+ // Only fetch subcategory if its ID is provided AND we have a valid finalCategoryId
1331
+ if (validatedData.subcategoryId && finalCategoryId) {
1332
+ const subcategory = await this.subcategoryService.getById(
1333
+ finalCategoryId,
1334
+ validatedData.subcategoryId,
1335
+ );
1336
+ if (!subcategory)
1337
+ throw new Error(
1338
+ `Subcategory ${validatedData.subcategoryId} not found for category ${finalCategoryId}`,
1339
+ );
1340
+ updatedProcedureData.subcategory = subcategory;
1341
+ } else if (validatedData.subcategoryId) {
1342
+ console.warn('Attempted to update subcategory without a valid categoryId');
1343
+ }
1344
+
1345
+ let finalTechnologyId = existingProcedure.technology.id;
1346
+ if (validatedData.technologyId) {
1347
+ const technology = await this.technologyService.getById(validatedData.technologyId);
1348
+ if (!technology) throw new Error(`Technology ${validatedData.technologyId} not found`);
1349
+ updatedProcedureData.technology = technology;
1350
+ finalTechnologyId = technology.id; // Update finalTechnologyId if technology changed
1351
+ // Update related fields derived from technology
1352
+ updatedProcedureData.blockingConditions = technology.blockingConditions;
1353
+ updatedProcedureData.contraindications = technology.contraindications || [];
1354
+ updatedProcedureData.contraindicationIds = technology.contraindications?.map(c => c.id) || [];
1355
+ updatedProcedureData.treatmentBenefits = technology.benefits;
1356
+ updatedProcedureData.treatmentBenefitIds = Array.isArray(technology.benefits)
1357
+ ? technology.benefits.map(b => typeof b === 'string' ? b : b.id)
1358
+ : [];
1359
+ updatedProcedureData.preRequirements = technology.requirements.pre;
1360
+ updatedProcedureData.postRequirements = technology.requirements.post;
1361
+ updatedProcedureData.certificationRequirement = technology.certificationRequirement;
1362
+ updatedProcedureData.documentationTemplates = technology.documentationTemplates || [];
1363
+ }
1364
+
1365
+ // Only fetch product if its ID is provided AND we have a valid finalTechnologyId
1366
+ if (validatedData.productId && finalTechnologyId) {
1367
+ const product = await this.productService.getById(finalTechnologyId, validatedData.productId);
1368
+ if (!product)
1369
+ throw new Error(
1370
+ `Product ${validatedData.productId} not found for technology ${finalTechnologyId}`,
1371
+ );
1372
+ updatedProcedureData.product = product;
1373
+ } else if (validatedData.productId) {
1374
+ console.warn('Attempted to update product without a valid technologyId');
1375
+ }
1376
+
1377
+ // Update the procedure document
1378
+ await updateDoc(procedureRef, {
1379
+ ...updatedProcedureData,
1380
+ updatedAt: serverTimestamp(),
1381
+ });
1382
+
1383
+ // Return the updated procedure
1384
+ const updatedSnapshot = await getDoc(procedureRef);
1385
+ return updatedSnapshot.data() as Procedure;
1386
+ }
1387
+
1388
+ /**
1389
+ * Deactivates a procedure (soft delete)
1390
+ * @param id - The ID of the procedure to deactivate
1391
+ */
1392
+ async deactivateProcedure(id: string): Promise<void> {
1393
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, id);
1394
+ const procedureSnap = await getDoc(procedureRef);
1395
+ if (!procedureSnap.exists()) {
1396
+ console.warn(`Procedure ${id} not found for deactivation.`);
1397
+ return;
1398
+ }
1399
+
1400
+ // Mark procedure as inactive
1401
+ await updateDoc(procedureRef, {
1402
+ isActive: false,
1403
+ updatedAt: serverTimestamp(),
1404
+ });
1405
+ }
1406
+
1407
+ /**
1408
+ * Deletes a procedure permanently
1409
+ * @param id - The ID of the procedure to delete
1410
+ * @returns A boolean indicating if the deletion was successful
1411
+ */
1412
+ async deleteProcedure(id: string): Promise<boolean> {
1413
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, id);
1414
+ const procedureSnapshot = await getDoc(procedureRef);
1415
+
1416
+ if (!procedureSnapshot.exists()) {
1417
+ // Already deleted or never existed
1418
+ return false;
1419
+ }
1420
+
1421
+ // Delete the procedure document
1422
+ await deleteDoc(procedureRef);
1423
+ return true;
1424
+ }
1425
+
1426
+ /**
1427
+ * Gets all procedures that a practitioner is certified to perform
1428
+ * @param practitioner - The practitioner's profile
1429
+ * @returns Object containing allowed technologies, families, categories, subcategories
1430
+ */
1431
+ async getAllowedTechnologies(practitioner: Practitioner): Promise<{
1432
+ technologies: Technology[];
1433
+ families: ProcedureFamily[];
1434
+ categories: string[];
1435
+ subcategories: string[];
1436
+ }> {
1437
+ // This logic depends on TechnologyService and remains valid
1438
+ const { technologies, families, categories, subcategories } =
1439
+ await this.technologyService.getAllowedTechnologies(practitioner);
1440
+
1441
+ return {
1442
+ technologies,
1443
+ families,
1444
+ categories,
1445
+ subcategories,
1446
+ };
1447
+ }
1448
+
1449
+ /**
1450
+ * Gets all procedures with optional pagination
1451
+ *
1452
+ * @param pagination - Optional number of procedures per page (0 or undefined returns all)
1453
+ * @param lastDoc - Optional last document for pagination (if continuing from a previous page)
1454
+ * @param excludeDraftPractitioners - Whether to exclude procedures from draft practitioners (default: true)
1455
+ * @returns Object containing procedures array and the last document for pagination
1456
+ */
1457
+ async getAllProcedures(
1458
+ pagination?: number,
1459
+ lastDoc?: any,
1460
+ excludeDraftPractitioners: boolean = true,
1461
+ ): Promise<{ procedures: Procedure[]; lastDoc: any }> {
1462
+ try {
1463
+ const proceduresCollection = collection(this.db, PROCEDURES_COLLECTION);
1464
+ let proceduresQuery = query(proceduresCollection);
1465
+
1466
+ // Apply pagination if specified
1467
+ if (pagination && pagination > 0) {
1468
+ const { limit, startAfter } = await import('firebase/firestore'); // Use dynamic import if needed top-level
1469
+
1470
+ if (lastDoc) {
1471
+ proceduresQuery = query(
1472
+ proceduresCollection,
1473
+ orderBy('name'), // Use imported orderBy
1474
+ startAfter(lastDoc),
1475
+ limit(pagination),
1476
+ );
1477
+ } else {
1478
+ proceduresQuery = query(proceduresCollection, orderBy('name'), limit(pagination)); // Use imported orderBy
1479
+ }
1480
+ } else {
1481
+ proceduresQuery = query(proceduresCollection, orderBy('name')); // Use imported orderBy
1482
+ }
1483
+
1484
+ const proceduresSnapshot = await getDocs(proceduresQuery);
1485
+
1486
+ let procedures = proceduresSnapshot.docs.map(doc => {
1487
+ const data = doc.data() as Procedure;
1488
+ return {
1489
+ ...data,
1490
+ id: doc.id, // Ensure ID is present
1491
+ };
1492
+ });
1493
+
1494
+ // Filter out procedures from draft practitioners if requested
1495
+ if (excludeDraftPractitioners) {
1496
+ procedures = await this.filterDraftPractitionerProcedures(procedures);
1497
+ }
1498
+
1499
+ // Fix lastDoc - if we got fewer documents from Firestore than requested, no more pages
1500
+ const lastDocForPagination =
1501
+ pagination && pagination > 0 && proceduresSnapshot.docs.length < pagination
1502
+ ? null
1503
+ : proceduresSnapshot.docs.length > 0
1504
+ ? proceduresSnapshot.docs[proceduresSnapshot.docs.length - 1]
1505
+ : null;
1506
+
1507
+ return {
1508
+ procedures,
1509
+ lastDoc: lastDocForPagination,
1510
+ };
1511
+ } catch (error) {
1512
+ console.error('[PROCEDURE_SERVICE] Error getting all procedures:', error);
1513
+ throw error;
1514
+ }
1515
+ }
1516
+
1517
+ /**
1518
+ * Creates a serializable cursor from a DocumentSnapshot or returns the cursor values.
1519
+ * This format can be passed through React Native state/Redux without losing data.
1520
+ *
1521
+ * @param doc - The Firestore DocumentSnapshot
1522
+ * @param orderByField - The field used in orderBy clause
1523
+ * @returns Serializable cursor object with values needed for startAfter
1524
+ */
1525
+ private createSerializableCursor(
1526
+ doc: any,
1527
+ orderByField: string = 'createdAt',
1528
+ ): { __cursor: true; values: any[]; id: string; orderByField: string } | null {
1529
+ if (!doc) return null;
1530
+
1531
+ const data = typeof doc.data === 'function' ? doc.data() : doc;
1532
+ const docId = doc.id || data?.id;
1533
+
1534
+ if (!docId) return null;
1535
+
1536
+ // Get the value of the orderBy field
1537
+ let orderByValue = data?.[orderByField];
1538
+
1539
+ // Handle Firestore Timestamp
1540
+ if (orderByValue && typeof orderByValue.toDate === 'function') {
1541
+ orderByValue = orderByValue.toMillis();
1542
+ } else if (orderByValue && orderByValue.seconds) {
1543
+ // Serialized Timestamp
1544
+ orderByValue = orderByValue.seconds * 1000 + (orderByValue.nanoseconds || 0) / 1000000;
1545
+ }
1546
+
1547
+ return {
1548
+ __cursor: true,
1549
+ values: [orderByValue],
1550
+ id: docId,
1551
+ orderByField,
1552
+ };
1553
+ }
1554
+
1555
+ /**
1556
+ * Converts a serializable cursor back to values for startAfter.
1557
+ * Handles both native DocumentSnapshots and serialized cursor objects.
1558
+ *
1559
+ * @param lastDoc - Either a DocumentSnapshot or a serializable cursor object
1560
+ * @param orderByField - The field used in orderBy clause (for validation)
1561
+ * @returns Values to spread into startAfter, or null if invalid
1562
+ */
1563
+ private getCursorValuesForStartAfter(
1564
+ lastDoc: any,
1565
+ orderByField: string = 'createdAt',
1566
+ ): any[] | null {
1567
+ if (!lastDoc) return null;
1568
+
1569
+ // If it's a native DocumentSnapshot with data() method
1570
+ if (typeof lastDoc.data === 'function') {
1571
+ return [lastDoc];
1572
+ }
1573
+
1574
+ // If it's our serializable cursor format
1575
+ if (lastDoc.__cursor && Array.isArray(lastDoc.values)) {
1576
+ // Reconstruct Timestamp if needed for createdAt
1577
+ if (orderByField === 'createdAt' && typeof lastDoc.values[0] === 'number') {
1578
+ const timestamp = Timestamp.fromMillis(lastDoc.values[0]);
1579
+ return [timestamp];
1580
+ }
1581
+ return lastDoc.values;
1582
+ }
1583
+
1584
+ // If it's an array of values directly
1585
+ if (Array.isArray(lastDoc)) {
1586
+ return lastDoc;
1587
+ }
1588
+
1589
+ // Fallback: try to use the object's orderByField value
1590
+ if (lastDoc[orderByField]) {
1591
+ let value = lastDoc[orderByField];
1592
+ if (typeof value === 'number' && orderByField === 'createdAt') {
1593
+ value = Timestamp.fromMillis(value);
1594
+ } else if (value.seconds && orderByField === 'createdAt') {
1595
+ value = new Timestamp(value.seconds, value.nanoseconds || 0);
1596
+ }
1597
+ return [value];
1598
+ }
1599
+
1600
+ console.warn('[PROCEDURE_SERVICE] Could not parse lastDoc cursor:', typeof lastDoc);
1601
+ return null;
1602
+ }
1603
+
1604
+ /**
1605
+ * Searches and filters procedures based on multiple criteria
1606
+ *
1607
+ * @note Frontend can now send either a DocumentSnapshot or a serializable cursor object.
1608
+ * The serializable cursor format is: { __cursor: true, values: [...], id: string, orderByField: string }
1609
+ *
1610
+ * @param filters - Various filters to apply
1611
+ * @param filters.nameSearch - Optional search text for procedure name
1612
+ * @param filters.treatmentBenefitIds - Optional array of treatment benefits to filter by
1613
+ * @param filters.procedureFamily - Optional procedure family to filter by
1614
+ * @param filters.procedureCategory - Optional procedure category to filter by
1615
+ * @param filters.procedureSubcategory - Optional procedure subcategory to filter by
1616
+ * @param filters.procedureTechnology - Optional procedure technology to filter by
1617
+ * @param filters.location - Optional location for distance-based search
1618
+ * @param filters.radiusInKm - Optional radius in kilometers (required if location is provided)
1619
+ * @param filters.minPrice - Optional minimum price
1620
+ * @param filters.maxPrice - Optional maximum price
1621
+ * @param filters.minRating - Optional minimum rating (0-5)
1622
+ * @param filters.maxRating - Optional maximum rating (0-5)
1623
+ * @param filters.pagination - Optional number of results per page
1624
+ * @param filters.lastDoc - Optional last document for pagination
1625
+ * @param filters.isActive - Optional filter for active procedures only
1626
+ * @returns Filtered procedures and the last document for pagination
1627
+ */
1628
+ async getProceduresByFilters(filters: {
1629
+ nameSearch?: string;
1630
+ treatmentBenefits?: string[];
1631
+ procedureFamily?: ProcedureFamily;
1632
+ procedureCategory?: string;
1633
+ procedureSubcategory?: string;
1634
+ procedureTechnology?: string;
1635
+ location?: { latitude: number; longitude: number };
1636
+ radiusInKm?: number;
1637
+ minPrice?: number;
1638
+ maxPrice?: number;
1639
+ minRating?: number;
1640
+ maxRating?: number;
1641
+ pagination?: number;
1642
+ lastDoc?: any;
1643
+ isActive?: boolean;
1644
+ practitionerId?: string;
1645
+ clinicId?: string;
1646
+ excludeDraftPractitioners?: boolean;
1647
+ }): Promise<{
1648
+ procedures: (Procedure & { distance?: number })[];
1649
+ lastDoc: any;
1650
+ }> {
1651
+ try {
1652
+ console.log('[PROCEDURE_SERVICE] Starting procedure filtering with multiple strategies');
1653
+ console.log("excludeDraftPractitioners is : ", filters.excludeDraftPractitioners)
1654
+
1655
+ // Geo query debug i validacija
1656
+ if (filters.location && filters.radiusInKm) {
1657
+ console.log('[PROCEDURE_SERVICE] Executing geo query:', {
1658
+ location: filters.location,
1659
+ radius: filters.radiusInKm,
1660
+ serviceName: 'ProcedureService',
1661
+ });
1662
+
1663
+ // Validacija location podataka
1664
+ if (!filters.location.latitude || !filters.location.longitude) {
1665
+ console.warn('[PROCEDURE_SERVICE] Invalid location data:', filters.location);
1666
+ filters.location = undefined;
1667
+ filters.radiusInKm = undefined;
1668
+ }
1669
+ }
1670
+
1671
+ // Handle geo queries separately (they work differently)
1672
+ const isGeoQuery = filters.location && filters.radiusInKm && filters.radiusInKm > 0;
1673
+ if (isGeoQuery) {
1674
+ return this.handleGeoQuery(filters);
1675
+ }
1676
+
1677
+ // Base constraints (used in all strategies)
1678
+ const getBaseConstraints = () => {
1679
+ const constraints: QueryConstraint[] = [];
1680
+
1681
+ // Active status filter
1682
+ if (filters.isActive !== undefined) {
1683
+ constraints.push(where('isActive', '==', filters.isActive));
1684
+ } else {
1685
+ constraints.push(where('isActive', '==', true));
1686
+ }
1687
+
1688
+ // Filter constraints
1689
+ if (filters.procedureFamily) {
1690
+ constraints.push(where('family', '==', filters.procedureFamily));
1691
+ }
1692
+ if (filters.procedureCategory) {
1693
+ constraints.push(where('category.id', '==', filters.procedureCategory));
1694
+ }
1695
+ if (filters.procedureSubcategory) {
1696
+ constraints.push(where('subcategory.id', '==', filters.procedureSubcategory));
1697
+ }
1698
+ if (filters.procedureTechnology) {
1699
+ constraints.push(where('technology.id', '==', filters.procedureTechnology));
1700
+ }
1701
+ if (filters.practitionerId) {
1702
+ constraints.push(where('practitionerId', '==', filters.practitionerId));
1703
+ }
1704
+ if (filters.clinicId) {
1705
+ constraints.push(where('clinicBranchId', '==', filters.clinicId));
1706
+ }
1707
+ if (filters.minPrice !== undefined) {
1708
+ constraints.push(where('price', '>=', filters.minPrice));
1709
+ }
1710
+ if (filters.maxPrice !== undefined) {
1711
+ constraints.push(where('price', '<=', filters.maxPrice));
1712
+ }
1713
+ if (filters.minRating !== undefined) {
1714
+ constraints.push(where('reviewInfo.averageRating', '>=', filters.minRating));
1715
+ }
1716
+ if (filters.maxRating !== undefined) {
1717
+ constraints.push(where('reviewInfo.averageRating', '<=', filters.maxRating));
1718
+ }
1719
+ if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
1720
+ const benefitIdsToMatch = filters.treatmentBenefits;
1721
+ constraints.push(where('treatmentBenefitIds', 'array-contains-any', benefitIdsToMatch));
1722
+ }
1723
+
1724
+ return constraints;
1725
+ };
1726
+
1727
+ // Strategy 1: Try nameLower search if nameSearch exists
1728
+ if (filters.nameSearch && filters.nameSearch.trim()) {
1729
+ try {
1730
+ console.log('[PROCEDURE_SERVICE] Strategy 1: Trying nameLower search');
1731
+ const searchTerm = filters.nameSearch.trim().toLowerCase();
1732
+ const constraints = getBaseConstraints();
1733
+
1734
+ // Check if we have nested field filters that might conflict with orderBy
1735
+ const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1736
+
1737
+ if (hasNestedFilters) {
1738
+ console.log('[PROCEDURE_SERVICE] Strategy 1: Has nested filters, will apply client-side after query');
1739
+ }
1740
+
1741
+ constraints.push(where('nameLower', '>=', searchTerm));
1742
+ constraints.push(where('nameLower', '<=', searchTerm + '\uf8ff'));
1743
+ constraints.push(orderBy('nameLower'));
1744
+
1745
+ // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1746
+ if (filters.lastDoc) {
1747
+ const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'nameLower');
1748
+ if (cursorValues) {
1749
+ constraints.push(startAfter(...cursorValues));
1750
+ console.log('[PROCEDURE_SERVICE] Strategy 1: Using cursor for pagination');
1751
+ }
1752
+ }
1753
+ constraints.push(limit(filters.pagination || 10));
1754
+
1755
+ const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1756
+ const querySnapshot = await getDocs(q);
1757
+ let procedures = querySnapshot.docs.map(
1758
+ doc => ({ ...doc.data(), id: doc.id } as Procedure),
1759
+ );
1760
+
1761
+ // Apply client-side filters for nested fields if needed
1762
+ if (hasNestedFilters) {
1763
+ procedures = this.applyInMemoryFilters(procedures, filters);
1764
+ }
1765
+
1766
+ // Filter out procedures from draft practitioners if requested
1767
+ if (filters.excludeDraftPractitioners) {
1768
+ procedures = await this.filterDraftPractitionerProcedures(procedures);
1769
+ }
1770
+
1771
+ console.log(`[PROCEDURE_SERVICE] Strategy 1 success: ${procedures.length} procedures (${querySnapshot.docs.length} from query)`);
1772
+
1773
+ // Fix Load More - if we got fewer documents from Firestore than requested, no more pages
1774
+ if (querySnapshot.docs.length < (filters.pagination || 10)) {
1775
+ return { procedures, lastDoc: null };
1776
+ }
1777
+
1778
+ // Return serializable cursor for pagination
1779
+ const lastDocSnapshot = querySnapshot.docs.length > 0
1780
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
1781
+ : null;
1782
+ const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'nameLower');
1783
+
1784
+ return { procedures, lastDoc: serializableCursor };
1785
+ } catch (error) {
1786
+ console.log('[PROCEDURE_SERVICE] Strategy 1 failed:', error);
1787
+ }
1788
+ }
1789
+
1790
+ // Strategy 2: Try name field search as fallback
1791
+ if (filters.nameSearch && filters.nameSearch.trim()) {
1792
+ try {
1793
+ console.log('[PROCEDURE_SERVICE] Strategy 2: Trying name field search');
1794
+ const searchTerm = filters.nameSearch.trim().toLowerCase();
1795
+ const constraints = getBaseConstraints();
1796
+
1797
+ // Check if we have nested field filters that might conflict with orderBy
1798
+ const hasNestedFilters = !!(filters.procedureTechnology || filters.procedureCategory || filters.procedureSubcategory);
1799
+
1800
+ if (hasNestedFilters) {
1801
+ console.log('[PROCEDURE_SERVICE] Strategy 2: Has nested filters, will apply client-side after query');
1802
+ }
1803
+
1804
+ constraints.push(where('name', '>=', searchTerm));
1805
+ constraints.push(where('name', '<=', searchTerm + '\uf8ff'));
1806
+ constraints.push(orderBy('name'));
1807
+
1808
+ // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1809
+ if (filters.lastDoc) {
1810
+ const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'name');
1811
+ if (cursorValues) {
1812
+ constraints.push(startAfter(...cursorValues));
1813
+ console.log('[PROCEDURE_SERVICE] Strategy 2: Using cursor for pagination');
1814
+ }
1815
+ }
1816
+ constraints.push(limit(filters.pagination || 10));
1817
+
1818
+ const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1819
+ const querySnapshot = await getDocs(q);
1820
+ let procedures = querySnapshot.docs.map(
1821
+ doc => ({ ...doc.data(), id: doc.id } as Procedure),
1822
+ );
1823
+
1824
+ // Apply client-side filters for nested fields if needed
1825
+ if (hasNestedFilters) {
1826
+ procedures = this.applyInMemoryFilters(procedures, filters);
1827
+ }
1828
+
1829
+ // Filter out procedures from draft practitioners
1830
+ if(filters.excludeDraftPractitioners){
1831
+ procedures = await this.filterDraftPractitionerProcedures(procedures);
1832
+ }
1833
+
1834
+ console.log(`[PROCEDURE_SERVICE] Strategy 2 success: ${procedures.length} procedures (${querySnapshot.docs.length} from query)`);
1835
+
1836
+ // Fix Load More - if we got fewer documents from Firestore than requested, no more pages
1837
+ if (querySnapshot.docs.length < (filters.pagination || 10)) {
1838
+ return { procedures, lastDoc: null };
1839
+ }
1840
+
1841
+ // Return serializable cursor for pagination
1842
+ const lastDocSnapshot = querySnapshot.docs.length > 0
1843
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
1844
+ : null;
1845
+ const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'name');
1846
+
1847
+ return { procedures, lastDoc: serializableCursor };
1848
+ } catch (error) {
1849
+ console.log('[PROCEDURE_SERVICE] Strategy 2 failed:', error);
1850
+ }
1851
+ }
1852
+
1853
+ // Strategy 3: orderBy createdAt with client-side filtering
1854
+ // NOTE: This strategy excludes nested field filters (technology.id, category.id, subcategory.id)
1855
+ // from Firestore query because Firestore doesn't support orderBy on different field
1856
+ // when using where on nested fields without a composite index.
1857
+ // These filters are applied client-side instead.
1858
+ try {
1859
+ console.log(
1860
+ '[PROCEDURE_SERVICE] Strategy 3: Using createdAt orderBy with client-side filtering',
1861
+ {
1862
+ procedureTechnology: filters.procedureTechnology,
1863
+ hasTechnologyFilter: !!filters.procedureTechnology,
1864
+ },
1865
+ );
1866
+
1867
+ // Build constraints WITHOUT nested field filters (these will be applied client-side)
1868
+ const constraints: QueryConstraint[] = [];
1869
+
1870
+ // Active status filter
1871
+ if (filters.isActive !== undefined) {
1872
+ constraints.push(where('isActive', '==', filters.isActive));
1873
+ } else {
1874
+ constraints.push(where('isActive', '==', true));
1875
+ }
1876
+
1877
+ // Only include non-nested field filters in Firestore query
1878
+ if (filters.procedureFamily) {
1879
+ constraints.push(where('family', '==', filters.procedureFamily));
1880
+ }
1881
+ if (filters.practitionerId) {
1882
+ constraints.push(where('practitionerId', '==', filters.practitionerId));
1883
+ }
1884
+ if (filters.clinicId) {
1885
+ constraints.push(where('clinicBranchId', '==', filters.clinicId));
1886
+ }
1887
+ if (filters.minPrice !== undefined) {
1888
+ constraints.push(where('price', '>=', filters.minPrice));
1889
+ }
1890
+ if (filters.maxPrice !== undefined) {
1891
+ constraints.push(where('price', '<=', filters.maxPrice));
1892
+ }
1893
+ if (filters.minRating !== undefined) {
1894
+ constraints.push(where('reviewInfo.averageRating', '>=', filters.minRating));
1895
+ }
1896
+ if (filters.maxRating !== undefined) {
1897
+ constraints.push(where('reviewInfo.averageRating', '<=', filters.maxRating));
1898
+ }
1899
+ if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
1900
+ const benefitIdsToMatch = filters.treatmentBenefits;
1901
+ constraints.push(where('treatmentBenefitIds', 'array-contains-any', benefitIdsToMatch));
1902
+ }
1903
+
1904
+ // NOTE: We intentionally EXCLUDE these nested field filters from Firestore query:
1905
+ // - filters.procedureTechnology (technology.id)
1906
+ // - filters.procedureCategory (category.id)
1907
+ // - filters.procedureSubcategory (subcategory.id)
1908
+ // These will be applied client-side in applyInMemoryFilters
1909
+
1910
+ console.log(
1911
+ '[PROCEDURE_SERVICE] Strategy 3 Firestore constraints (nested filters excluded):',
1912
+ constraints.map(c => (c as any).fieldPath || 'unknown'),
1913
+ );
1914
+ constraints.push(orderBy('createdAt', 'desc'));
1915
+
1916
+ // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1917
+ if (filters.lastDoc) {
1918
+ const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'createdAt');
1919
+ if (cursorValues) {
1920
+ constraints.push(startAfter(...cursorValues));
1921
+ console.log('[PROCEDURE_SERVICE] Strategy 3: Using cursor for pagination');
1922
+ }
1923
+ }
1924
+ constraints.push(limit(filters.pagination || 10));
1925
+
1926
+ const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1927
+ const querySnapshot = await getDocs(q);
1928
+ let procedures = querySnapshot.docs.map(
1929
+ doc => ({ ...doc.data(), id: doc.id } as Procedure),
1930
+ );
1931
+
1932
+ // Apply all client-side filters using centralized function
1933
+ console.log('[PROCEDURE_SERVICE] Before applyInMemoryFilters (Strategy 3):', {
1934
+ procedureCount: procedures.length,
1935
+ procedureTechnology: filters.procedureTechnology,
1936
+ filtersObject: {
1937
+ procedureTechnology: filters.procedureTechnology,
1938
+ procedureFamily: filters.procedureFamily,
1939
+ procedureCategory: filters.procedureCategory,
1940
+ procedureSubcategory: filters.procedureSubcategory,
1941
+ },
1942
+ });
1943
+ procedures = this.applyInMemoryFilters(procedures, filters);
1944
+
1945
+ // Filter out procedures from draft practitioners
1946
+ if(filters.excludeDraftPractitioners){
1947
+ procedures = await this.filterDraftPractitionerProcedures(procedures);
1948
+ }
1949
+
1950
+ console.log('[PROCEDURE_SERVICE] After applyInMemoryFilters (Strategy 3):', {
1951
+ procedureCount: procedures.length,
1952
+ queryDocCount: querySnapshot.docs.length,
1953
+ });
1954
+
1955
+ console.log(`[PROCEDURE_SERVICE] Strategy 3 success: ${procedures.length} procedures (${querySnapshot.docs.length} from query)`);
1956
+
1957
+ // Fix Load More - if we got fewer documents from Firestore than requested, no more pages
1958
+ if (querySnapshot.docs.length < (filters.pagination || 10)) {
1959
+ return { procedures, lastDoc: null };
1960
+ }
1961
+
1962
+ // Return serializable cursor for pagination
1963
+ const lastDocSnapshot = querySnapshot.docs.length > 0
1964
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
1965
+ : null;
1966
+ const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'createdAt');
1967
+
1968
+ return { procedures, lastDoc: serializableCursor };
1969
+ } catch (error) {
1970
+ console.log('[PROCEDURE_SERVICE] Strategy 3 failed:', error);
1971
+ }
1972
+
1973
+ // Strategy 4: Minimal query fallback
1974
+ try {
1975
+ console.log('[PROCEDURE_SERVICE] Strategy 4: Minimal query fallback');
1976
+ const constraints: QueryConstraint[] = [
1977
+ where('isActive', '==', filters.isActive !== undefined ? filters.isActive : true),
1978
+ orderBy('createdAt', 'desc'),
1979
+ ];
1980
+ if (filters.practitionerId) {
1981
+ constraints.push(where('practitionerId', '==', filters.practitionerId));
1982
+ }
1983
+ if (filters.clinicId) {
1984
+ constraints.push(where('clinicBranchId', '==', filters.clinicId));
1985
+ }
1986
+
1987
+ // Handle lastDoc cursor - supports both native DocumentSnapshot and serializable cursor
1988
+ if (filters.lastDoc) {
1989
+ const cursorValues = this.getCursorValuesForStartAfter(filters.lastDoc, 'createdAt');
1990
+ if (cursorValues) {
1991
+ constraints.push(startAfter(...cursorValues));
1992
+ console.log('[PROCEDURE_SERVICE] Strategy 4: Using cursor for pagination');
1993
+ }
1994
+ }
1995
+ constraints.push(limit(filters.pagination || 10));
1996
+
1997
+ const q = query(collection(this.db, PROCEDURES_COLLECTION), ...constraints);
1998
+ const querySnapshot = await getDocs(q);
1999
+ let procedures = querySnapshot.docs.map(
2000
+ doc => ({ ...doc.data(), id: doc.id } as Procedure),
2001
+ );
2002
+
2003
+ // Apply all client-side filters using centralized function
2004
+ procedures = this.applyInMemoryFilters(procedures, filters);
2005
+
2006
+ // Filter out procedures from draft practitioners
2007
+ if(filters.excludeDraftPractitioners){
2008
+ procedures = await this.filterDraftPractitionerProcedures(procedures);
2009
+ }
2010
+
2011
+ console.log(`[PROCEDURE_SERVICE] Strategy 4 success: ${procedures.length} procedures (${querySnapshot.docs.length} from query)`);
2012
+
2013
+ // Fix Load More - if we got fewer documents from Firestore than requested, no more pages
2014
+ if (querySnapshot.docs.length < (filters.pagination || 10)) {
2015
+ return { procedures, lastDoc: null };
2016
+ }
2017
+
2018
+ // Return serializable cursor for pagination
2019
+ const lastDocSnapshot = querySnapshot.docs.length > 0
2020
+ ? querySnapshot.docs[querySnapshot.docs.length - 1]
2021
+ : null;
2022
+ const serializableCursor = this.createSerializableCursor(lastDocSnapshot, 'createdAt');
2023
+
2024
+ return { procedures, lastDoc: serializableCursor };
2025
+ } catch (error) {
2026
+ console.log('[PROCEDURE_SERVICE] Strategy 4 failed:', error);
2027
+ }
2028
+
2029
+ // All strategies failed
2030
+ console.log('[PROCEDURE_SERVICE] All strategies failed, returning empty result');
2031
+ return { procedures: [], lastDoc: null };
2032
+ } catch (error) {
2033
+ console.error('[PROCEDURE_SERVICE] Error filtering procedures:', error);
2034
+ return { procedures: [], lastDoc: null };
2035
+ }
2036
+ }
2037
+
2038
+ /**
2039
+ * Applies in-memory filters to procedures array
2040
+ * Used when Firestore queries fail or for complex filtering
2041
+ */
2042
+ private applyInMemoryFilters(
2043
+ procedures: Procedure[],
2044
+ filters: any,
2045
+ ): (Procedure & { distance?: number })[] {
2046
+ let filteredProcedures = [...procedures]; // Create copy to avoid mutating original
2047
+
2048
+ // Debug: Log what filters we received
2049
+ console.log('[PROCEDURE_SERVICE] applyInMemoryFilters called:', {
2050
+ procedureCount: procedures.length,
2051
+ procedureTechnology: filters.procedureTechnology,
2052
+ hasTechnologyFilter: !!filters.procedureTechnology,
2053
+ allFilterKeys: Object.keys(filters).filter(k => filters[k] !== undefined && filters[k] !== null),
2054
+ });
2055
+
2056
+ // Name search filter
2057
+ if (filters.nameSearch && filters.nameSearch.trim()) {
2058
+ const searchTerm = filters.nameSearch.trim().toLowerCase();
2059
+ filteredProcedures = filteredProcedures.filter(procedure => {
2060
+ const name = (procedure.name || '').toLowerCase();
2061
+ const nameLower = procedure.nameLower || '';
2062
+ return name.includes(searchTerm) || nameLower.includes(searchTerm);
2063
+ });
2064
+ console.log(`[PROCEDURE_SERVICE] Applied name filter, results: ${filteredProcedures.length}`);
2065
+ }
2066
+
2067
+ // Price filtering
2068
+ if (filters.minPrice !== undefined || filters.maxPrice !== undefined) {
2069
+ filteredProcedures = filteredProcedures.filter(procedure => {
2070
+ const price = procedure.price || 0;
2071
+ if (filters.minPrice !== undefined && price < filters.minPrice) return false;
2072
+ if (filters.maxPrice !== undefined && price > filters.maxPrice) return false;
2073
+ return true;
2074
+ });
2075
+ console.log(
2076
+ `[PROCEDURE_SERVICE] Applied price filter (${filters.minPrice}-${filters.maxPrice}), results: ${filteredProcedures.length}`,
2077
+ );
2078
+ }
2079
+
2080
+ // Rating filtering
2081
+ if (filters.minRating !== undefined || filters.maxRating !== undefined) {
2082
+ filteredProcedures = filteredProcedures.filter(procedure => {
2083
+ const rating = procedure.reviewInfo?.averageRating || 0;
2084
+ if (filters.minRating !== undefined && rating < filters.minRating) return false;
2085
+ if (filters.maxRating !== undefined && rating > filters.maxRating) return false;
2086
+ return true;
2087
+ });
2088
+ console.log(
2089
+ `[PROCEDURE_SERVICE] Applied rating filter, results: ${filteredProcedures.length}`,
2090
+ );
2091
+ }
2092
+
2093
+ // Treatment benefits filtering
2094
+ if (filters.treatmentBenefits && filters.treatmentBenefits.length > 0) {
2095
+ const benefitIdsToMatch = filters.treatmentBenefits;
2096
+ filteredProcedures = filteredProcedures.filter(procedure => {
2097
+ const procedureBenefitIds = procedure.treatmentBenefitIds || [];
2098
+ return benefitIdsToMatch.some((benefitId: string) =>
2099
+ procedureBenefitIds.includes(benefitId),
2100
+ );
2101
+ });
2102
+ console.log(
2103
+ `[PROCEDURE_SERVICE] Applied benefits filter, results: ${filteredProcedures.length}`,
2104
+ );
2105
+ }
2106
+
2107
+ // Procedure family filtering
2108
+ if (filters.procedureFamily) {
2109
+ filteredProcedures = filteredProcedures.filter(
2110
+ procedure => procedure.family === filters.procedureFamily,
2111
+ );
2112
+ console.log(
2113
+ `[PROCEDURE_SERVICE] Applied family filter, results: ${filteredProcedures.length}`,
2114
+ );
2115
+ }
2116
+
2117
+ // Category filtering
2118
+ if (filters.procedureCategory) {
2119
+ filteredProcedures = filteredProcedures.filter(
2120
+ procedure => procedure.category?.id === filters.procedureCategory,
2121
+ );
2122
+ console.log(
2123
+ `[PROCEDURE_SERVICE] Applied category filter, results: ${filteredProcedures.length}`,
2124
+ );
2125
+ }
2126
+
2127
+ // Subcategory filtering
2128
+ if (filters.procedureSubcategory) {
2129
+ filteredProcedures = filteredProcedures.filter(
2130
+ procedure => procedure.subcategory?.id === filters.procedureSubcategory,
2131
+ );
2132
+ console.log(
2133
+ `[PROCEDURE_SERVICE] Applied subcategory filter, results: ${filteredProcedures.length}`,
2134
+ );
2135
+ }
2136
+
2137
+ // Technology filtering
2138
+ if (filters.procedureTechnology) {
2139
+ const beforeCount = filteredProcedures.length;
2140
+ filteredProcedures = filteredProcedures.filter(
2141
+ procedure => procedure.technology?.id === filters.procedureTechnology,
2142
+ );
2143
+ console.log(
2144
+ `[PROCEDURE_SERVICE] Applied technology filter (${filters.procedureTechnology}), before: ${beforeCount}, after: ${filteredProcedures.length}`,
2145
+ );
2146
+ // Log sample technology IDs for debugging
2147
+ if (beforeCount > filteredProcedures.length) {
2148
+ const filteredOut = procedures
2149
+ .filter(p => p.technology?.id !== filters.procedureTechnology)
2150
+ .slice(0, 3)
2151
+ .map(p => ({ id: p.id, techId: p.technology?.id, name: p.name }));
2152
+ console.log('[PROCEDURE_SERVICE] Filtered out sample procedures:', filteredOut);
2153
+ }
2154
+ }
2155
+
2156
+ // Practitioner filtering
2157
+ if (filters.practitionerId) {
2158
+ filteredProcedures = filteredProcedures.filter(
2159
+ procedure => procedure.practitionerId === filters.practitionerId,
2160
+ );
2161
+ console.log(
2162
+ `[PROCEDURE_SERVICE] Applied practitioner filter, results: ${filteredProcedures.length}`,
2163
+ );
2164
+ }
2165
+
2166
+ // Clinic filtering
2167
+ if (filters.clinicId) {
2168
+ filteredProcedures = filteredProcedures.filter(
2169
+ procedure => procedure.clinicBranchId === filters.clinicId,
2170
+ );
2171
+ console.log(
2172
+ `[PROCEDURE_SERVICE] Applied clinic filter, results: ${filteredProcedures.length}`,
2173
+ );
2174
+ }
2175
+
2176
+ // Geo-radius filter
2177
+ if (filters.location && filters.radiusInKm && filters.radiusInKm > 0) {
2178
+ const location = filters.location;
2179
+ const radiusInKm = filters.radiusInKm;
2180
+ filteredProcedures = filteredProcedures.filter(procedure => {
2181
+ const clinicLocation = procedure.clinicInfo?.location;
2182
+ if (!clinicLocation?.latitude || !clinicLocation?.longitude) {
2183
+ return false;
2184
+ }
2185
+
2186
+ const distance = distanceBetween(
2187
+ [location.latitude, location.longitude],
2188
+ [clinicLocation.latitude, clinicLocation.longitude],
2189
+ ); // Already returns km
2190
+
2191
+ // Attach distance for frontend sorting/display
2192
+ (procedure as any).distance = distance;
2193
+
2194
+ return distance <= radiusInKm;
2195
+ });
2196
+ console.log(`[PROCEDURE_SERVICE] Applied geo filter, results: ${filteredProcedures.length}`);
2197
+
2198
+ // Sort by distance when geo filtering is applied
2199
+ filteredProcedures.sort((a, b) => ((a as any).distance || 0) - ((b as any).distance || 0));
2200
+ }
2201
+
2202
+ return filteredProcedures as (Procedure & { distance?: number })[];
2203
+ }
2204
+
2205
+ private handleGeoQuery(filters: any): Promise<{
2206
+ procedures: (Procedure & { distance?: number })[];
2207
+ lastDoc: any;
2208
+ }> {
2209
+ console.log('[PROCEDURE_SERVICE] Executing geo query with geohash bounds');
2210
+ try {
2211
+ const location = filters.location;
2212
+ const radiusInKm = filters.radiusInKm;
2213
+
2214
+ if (!location || !radiusInKm) {
2215
+ return Promise.resolve({ procedures: [], lastDoc: null });
2216
+ }
2217
+
2218
+ const bounds = geohashQueryBounds([location.latitude, location.longitude], radiusInKm * 1000);
2219
+
2220
+ const fetches = bounds.map(b => {
2221
+ const constraints: QueryConstraint[] = [
2222
+ where('clinicInfo.location.geohash', '>=', b[0]),
2223
+ where('clinicInfo.location.geohash', '<=', b[1]),
2224
+ where('isActive', '==', filters.isActive !== undefined ? filters.isActive : true),
2225
+ ];
2226
+ if (filters.practitionerId) {
2227
+ constraints.push(where('practitionerId', '==', filters.practitionerId));
2228
+ }
2229
+ if (filters.clinicId) {
2230
+ constraints.push(where('clinicBranchId', '==', filters.clinicId));
2231
+ }
2232
+ return getDocs(query(collection(this.db, PROCEDURES_COLLECTION), ...constraints));
2233
+ });
2234
+
2235
+ return Promise.all(fetches)
2236
+ .then(snaps => {
2237
+ const collected: Procedure[] = [];
2238
+ snaps.forEach(snap => {
2239
+ snap.docs.forEach(d => collected.push({ ...(d.data() as Procedure), id: d.id }));
2240
+ });
2241
+
2242
+ // Deduplicate by id
2243
+ const uniqueMap = new Map<string, Procedure>();
2244
+ for (const p of collected) {
2245
+ uniqueMap.set(p.id, p);
2246
+ }
2247
+ let procedures = Array.from(uniqueMap.values());
2248
+
2249
+ // Apply remaining filters including precise distance and sorting
2250
+ procedures = this.applyInMemoryFilters(procedures, filters);
2251
+
2252
+ // Manual pagination
2253
+ const pageSize = filters.pagination || 10;
2254
+ let startIndex = 0;
2255
+ if (
2256
+ filters.lastDoc &&
2257
+ typeof filters.lastDoc === 'object' &&
2258
+ (filters.lastDoc as any).id
2259
+ ) {
2260
+ const idx = procedures.findIndex(p => p.id === (filters.lastDoc as any).id);
2261
+ if (idx >= 0) startIndex = idx + 1;
2262
+ }
2263
+ const page = procedures.slice(startIndex, startIndex + pageSize);
2264
+ const newLastDoc = page.length === pageSize ? page[page.length - 1] : null;
2265
+
2266
+ console.log(
2267
+ `[PROCEDURE_SERVICE] Geo query success: ${page.length} (of ${procedures.length}) within ${radiusInKm}km`,
2268
+ );
2269
+ return { procedures: page, lastDoc: newLastDoc };
2270
+ })
2271
+ .catch(err => {
2272
+ console.error('[PROCEDURE_SERVICE] Geo bounds fetch failed:', err);
2273
+ return { procedures: [], lastDoc: null };
2274
+ });
2275
+ } catch (error) {
2276
+ console.error('[PROCEDURE_SERVICE] Geo query failed:', error);
2277
+ return Promise.resolve({ procedures: [], lastDoc: null });
2278
+ }
2279
+ }
2280
+
2281
+ /**
2282
+ * Creates a consultation procedure without requiring a product
2283
+ * This is a special method for consultation procedures that don't use products
2284
+ * @param data - The data for creating a consultation procedure (without productId)
2285
+ * @returns The created procedure
2286
+ */
2287
+ async createConsultationProcedure(
2288
+ data: Omit<CreateProcedureData, 'productId'>,
2289
+ ): Promise<Procedure> {
2290
+ // Generate procedure ID first so we can use it for media uploads
2291
+ const procedureId = this.generateId();
2292
+
2293
+ // Get references to related entities (Category, Subcategory, Technology)
2294
+ // For consultation, we don't need a product
2295
+ const [category, subcategory, technology] = await Promise.all([
2296
+ this.categoryService.getByIdInternal(data.categoryId),
2297
+ this.subcategoryService.getByIdInternal(data.categoryId, data.subcategoryId),
2298
+ this.technologyService.getByIdInternal(data.technologyId),
2299
+ ]);
2300
+
2301
+ if (!category || !subcategory || !technology) {
2302
+ throw new Error('One or more required base entities not found');
2303
+ }
2304
+
2305
+ // Get clinic and practitioner information for aggregation
2306
+ const clinicRef = doc(this.db, CLINICS_COLLECTION, data.clinicBranchId);
2307
+ const clinicSnapshot = await getDoc(clinicRef);
2308
+ if (!clinicSnapshot.exists()) {
2309
+ throw new Error(`Clinic with ID ${data.clinicBranchId} not found`);
2310
+ }
2311
+ const clinic = clinicSnapshot.data() as Clinic;
2312
+
2313
+ const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, data.practitionerId);
2314
+ const practitionerSnapshot = await getDoc(practitionerRef);
2315
+ if (!practitionerSnapshot.exists()) {
2316
+ throw new Error(`Practitioner with ID ${data.practitionerId} not found`);
2317
+ }
2318
+ const practitioner = practitionerSnapshot.data() as Practitioner;
2319
+
2320
+ // Process photos if provided
2321
+ let processedPhotos: string[] = [];
2322
+ if (data.photos && data.photos.length > 0) {
2323
+ processedPhotos = await this.processMediaArray(data.photos, procedureId, 'procedure-photos');
2324
+ }
2325
+
2326
+ // If no photos provided and technology has a photoTemplate, use it as default photo
2327
+ if (processedPhotos.length === 0 && technology.photoTemplate) {
2328
+ console.log(`[ProcedureService] Using technology photoTemplate as default photo for consultation: ${technology.photoTemplate}`);
2329
+ const photoTemplateUrl = await this.mediaService.getMediaDownloadUrl(technology.photoTemplate);
2330
+ if (photoTemplateUrl) {
2331
+ processedPhotos.push(photoTemplateUrl);
2332
+ } else {
2333
+ console.warn(`[ProcedureService] Could not fetch photoTemplate URL for media ID: ${technology.photoTemplate}`);
2334
+ }
2335
+ }
2336
+
2337
+ // Transform productsMetadata from validation format to ProcedureProduct format
2338
+ // For consultations, this will return empty array since no products are provided
2339
+ const transformedProductsMetadata = await this.transformProductsMetadata(
2340
+ data.productsMetadata,
2341
+ data.technologyId,
2342
+ );
2343
+
2344
+ // Create aggregated clinic info for the procedure document
2345
+ const clinicInfo = {
2346
+ id: clinicSnapshot.id,
2347
+ name: clinic.name,
2348
+ description: clinic.description || '',
2349
+ featuredPhoto:
2350
+ clinic.featuredPhotos && clinic.featuredPhotos.length > 0
2351
+ ? typeof clinic.featuredPhotos[0] === 'string'
2352
+ ? clinic.featuredPhotos[0]
2353
+ : ''
2354
+ : typeof clinic.coverPhoto === 'string'
2355
+ ? clinic.coverPhoto
2356
+ : '',
2357
+ location: clinic.location,
2358
+ contactInfo: clinic.contactInfo,
2359
+ };
2360
+
2361
+ // Create aggregated doctor info for the procedure document
2362
+ const doctorInfo = {
2363
+ id: practitionerSnapshot.id,
2364
+ name: `${practitioner.basicInfo.firstName} ${practitioner.basicInfo.lastName}`,
2365
+ description: practitioner.basicInfo.bio || '',
2366
+ photo:
2367
+ typeof practitioner.basicInfo.profileImageUrl === 'string'
2368
+ ? practitioner.basicInfo.profileImageUrl
2369
+ : '',
2370
+ rating: practitioner.reviewInfo?.averageRating || 0,
2371
+ services: practitioner.procedures || [],
2372
+ };
2373
+
2374
+ // Create the procedure object
2375
+ const { productsMetadata: _, ...dataWithoutProductsMetadata } = data;
2376
+ const newProcedure: Omit<Procedure, 'createdAt' | 'updatedAt'> = {
2377
+ id: procedureId,
2378
+ ...dataWithoutProductsMetadata,
2379
+ nameLower: (data as any).nameLower || data.name.toLowerCase(),
2380
+ photos: processedPhotos,
2381
+ category,
2382
+ subcategory,
2383
+ technology,
2384
+ // No product field for consultations (Firestore doesn't allow undefined, so we omit it entirely)
2385
+ productsMetadata: transformedProductsMetadata, // Empty array for consultations
2386
+ blockingConditions: technology.blockingConditions,
2387
+ contraindications: technology.contraindications || [],
2388
+ contraindicationIds: technology.contraindications?.map(c => c.id) || [],
2389
+ treatmentBenefits: technology.benefits,
2390
+ treatmentBenefitIds: Array.isArray(technology.benefits)
2391
+ ? technology.benefits.map(b => typeof b === 'string' ? b : b.id)
2392
+ : [],
2393
+ preRequirements: technology.requirements.pre,
2394
+ postRequirements: technology.requirements.post,
2395
+ certificationRequirement: technology.certificationRequirement,
2396
+ documentationTemplates: technology?.documentationTemplates || [],
2397
+ clinicInfo,
2398
+ doctorInfo,
2399
+ reviewInfo: {
2400
+ totalReviews: 0,
2401
+ averageRating: 0,
2402
+ effectivenessOfTreatment: 0,
2403
+ outcomeExplanation: 0,
2404
+ painManagement: 0,
2405
+ followUpCare: 0,
2406
+ valueForMoney: 0,
2407
+ recommendationPercentage: 0,
2408
+ },
2409
+ isActive: true,
2410
+ };
2411
+
2412
+ // Create the procedure document
2413
+ const procedureRef = doc(this.db, PROCEDURES_COLLECTION, procedureId);
2414
+ await setDoc(procedureRef, {
2415
+ ...newProcedure,
2416
+ createdAt: serverTimestamp(),
2417
+ updatedAt: serverTimestamp(),
2418
+ });
2419
+
2420
+ // Return the created procedure (fetch again to get server timestamps)
2421
+ const savedDoc = await getDoc(procedureRef);
2422
+ return savedDoc.data() as Procedure;
2423
+ }
2424
+
2425
+ /**
2426
+ * Gets all procedures with minimal info for map display (id, name, clinicId, clinicName, address, latitude, longitude)
2427
+ * This is optimized for mobile map usage to reduce payload size.
2428
+ * @returns Array of minimal procedure info for map
2429
+ */
2430
+ async getProceduresForMap(): Promise<
2431
+ {
2432
+ id: string;
2433
+ name: string;
2434
+ clinicId: string | undefined;
2435
+ clinicName: string | undefined;
2436
+ address: string;
2437
+ latitude: number | undefined;
2438
+ longitude: number | undefined;
2439
+ }[]
2440
+ > {
2441
+ const proceduresRef = collection(this.db, PROCEDURES_COLLECTION);
2442
+ const snapshot = await getDocs(proceduresRef);
2443
+ let procedures = snapshot.docs.map(doc => ({
2444
+ id: doc.id,
2445
+ ...doc.data(),
2446
+ } as Procedure));
2447
+
2448
+ // Filter out procedures with draft/inactive practitioners
2449
+ procedures = await this.filterDraftPractitionerProcedures(procedures);
2450
+
2451
+ const proceduresForMap = procedures.map(procedure => ({
2452
+ id: procedure.id,
2453
+ name: procedure.name,
2454
+ clinicId: procedure.clinicInfo?.id,
2455
+ clinicName: procedure.clinicInfo?.name,
2456
+ address: procedure.clinicInfo?.location?.address || '',
2457
+ latitude: procedure.clinicInfo?.location?.latitude,
2458
+ longitude: procedure.clinicInfo?.location?.longitude,
2459
+ }));
2460
+ return proceduresForMap;
2461
+ }
2462
+
2463
+ /**
2464
+ * Gets procedures filtered by clinic and practitioner with optional family filter
2465
+ * @param clinicBranchId Clinic branch ID to filter by
2466
+ * @param practitionerId Practitioner ID to filter by
2467
+ * @param filterByFamily If true, shows only procedures of the same family as the default procedure
2468
+ * @param defaultProcedureId Optional default procedure ID to determine the family
2469
+ * @returns Array of procedures
2470
+ */
2471
+ async getProceduresForConsultation(
2472
+ clinicBranchId: string,
2473
+ practitionerId: string,
2474
+ filterByFamily: boolean = true,
2475
+ defaultProcedureId?: string
2476
+ ): Promise<Procedure[]> {
2477
+ let familyToFilter: ProcedureFamily | null = null;
2478
+
2479
+ // If family filtering is enabled and we have a default procedure, get its family
2480
+ if (filterByFamily && defaultProcedureId) {
2481
+ const defaultProcedureRef = doc(this.db, PROCEDURES_COLLECTION, defaultProcedureId);
2482
+ const defaultProcedureSnap = await getDoc(defaultProcedureRef);
2483
+
2484
+ if (defaultProcedureSnap.exists()) {
2485
+ const defaultProcedure = defaultProcedureSnap.data() as Procedure;
2486
+ familyToFilter = defaultProcedure.family;
2487
+ }
2488
+ }
2489
+
2490
+ // Build query constraints
2491
+ const constraints: QueryConstraint[] = [
2492
+ where('clinicBranchId', '==', clinicBranchId),
2493
+ where('practitionerId', '==', practitionerId),
2494
+ where('isActive', '==', true),
2495
+ ];
2496
+
2497
+ // Add family filter if applicable
2498
+ if (filterByFamily && familyToFilter) {
2499
+ constraints.push(where('family', '==', familyToFilter));
2500
+ }
2501
+
2502
+ // Execute query
2503
+ const proceduresQuery = query(
2504
+ collection(this.db, PROCEDURES_COLLECTION),
2505
+ ...constraints,
2506
+ orderBy('name', 'asc')
2507
+ );
2508
+
2509
+ const querySnapshot = await getDocs(proceduresQuery);
2510
+
2511
+ let procedures = querySnapshot.docs.map(doc => ({
2512
+ id: doc.id,
2513
+ ...doc.data(),
2514
+ } as Procedure));
2515
+
2516
+ // Filter out procedures with draft/inactive practitioners
2517
+ procedures = await this.filterDraftPractitionerProcedures(procedures);
2518
+
2519
+ return procedures;
2520
+ }
2521
+ }