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

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,1023 +1,1023 @@
1
- import {
2
- collection,
3
- doc,
4
- getDoc,
5
- getDocs,
6
- query,
7
- where,
8
- updateDoc,
9
- setDoc,
10
- deleteDoc,
11
- Timestamp,
12
- Firestore,
13
- QueryConstraint,
14
- addDoc,
15
- writeBatch,
16
- limit,
17
- startAfter,
18
- } from "firebase/firestore";
19
- import {
20
- Clinic,
21
- CreateClinicData,
22
- CLINICS_COLLECTION,
23
- ClinicTag,
24
- ClinicGroup,
25
- ClinicBranchSetupData,
26
- ClinicLocation,
27
- DoctorInfo,
28
- } from "../../../types/clinic";
29
- import {
30
- geohashForLocation,
31
- distanceBetween,
32
- geohashQueryBounds,
33
- } from "geofire-common";
34
- import {
35
- clinicSchema,
36
- createClinicSchema,
37
- } from "../../../validations/clinic.schema";
38
- import { z } from "zod";
39
- import { FirebaseApp } from "firebase/app";
40
- import {
41
- uploadPhoto,
42
- uploadMultiplePhotos,
43
- processEntityPhotos,
44
- } from "./photos.utils";
45
-
46
- /**
47
- * Creates a new clinic
48
- * @param db - Firestore database instance
49
- * @param data - Clinic data
50
- * @param creatorAdminId - ID of the admin creating the clinic
51
- * @param clinicGroupService - Service for clinic group operations
52
- * @param clinicAdminService - Service for clinic admin operations
53
- * @param app - Firebase app instance
54
- * @returns The created clinic
55
- */
56
- export async function createClinic(
57
- db: Firestore,
58
- data: CreateClinicData,
59
- creatorAdminId: string,
60
- clinicGroupService: any,
61
- clinicAdminService: any,
62
- app: FirebaseApp
63
- ): Promise<Clinic> {
64
- console.log("[CLINIC] Starting clinic creation", { creatorAdminId });
65
- console.log("[CLINIC] Input data:", JSON.stringify(data, null, 2));
66
-
67
- // Validacija podataka
68
- try {
69
- const validatedData = createClinicSchema.parse(data);
70
- console.log("[CLINIC] Data validation passed");
71
- } catch (validationError) {
72
- console.error("[CLINIC] Data validation failed:", validationError);
73
- throw validationError;
74
- }
75
-
76
- const validatedData = createClinicSchema.parse(data);
77
-
78
- // Proveravamo da li admin postoji i da li pripada grupi
79
- try {
80
- console.log("[CLINIC] Checking if admin exists and belongs to group");
81
- const admin = await clinicAdminService.getClinicAdmin(creatorAdminId);
82
- if (!admin) {
83
- console.error("[CLINIC] Admin not found", { creatorAdminId });
84
- throw new Error("Admin not found");
85
- }
86
-
87
- if (admin.clinicGroupId !== validatedData.clinicGroupId) {
88
- console.error("[CLINIC] Admin does not belong to this clinic group", {
89
- adminGroupId: admin.clinicGroupId,
90
- requestedGroupId: validatedData.clinicGroupId,
91
- });
92
- throw new Error("Admin does not belong to this clinic group");
93
- }
94
- console.log("[CLINIC] Admin verified");
95
- } catch (adminError) {
96
- console.error("[CLINIC] Error verifying admin:", adminError);
97
- throw adminError;
98
- }
99
-
100
- // Proveravamo da li grupa postoji
101
- try {
102
- console.log("[CLINIC] Checking if clinic group exists");
103
- const group = await clinicGroupService.getClinicGroup(
104
- validatedData.clinicGroupId
105
- );
106
- if (!group) {
107
- console.error("[CLINIC] Clinic group not found", {
108
- groupId: validatedData.clinicGroupId,
109
- });
110
- throw new Error("Clinic group not found");
111
- }
112
- console.log("[CLINIC] Clinic group verified");
113
- } catch (groupError) {
114
- console.error("[CLINIC] Error verifying clinic group:", groupError);
115
- throw groupError;
116
- }
117
-
118
- // Generišemo geohash za lokaciju
119
- console.log("[CLINIC] Generating geohash for location");
120
- if (validatedData.location) {
121
- try {
122
- validatedData.location.geohash = geohashForLocation([
123
- validatedData.location.latitude,
124
- validatedData.location.longitude,
125
- ]);
126
- console.log("[CLINIC] Geohash generated successfully", {
127
- geohash: validatedData.location.geohash,
128
- });
129
- } catch (geohashError) {
130
- console.error("[CLINIC] Error generating geohash:", geohashError);
131
- throw geohashError;
132
- }
133
- }
134
-
135
- // Generate a unique ID for the clinic
136
- const clinicId = doc(collection(db, CLINICS_COLLECTION)).id;
137
- console.log("[CLINIC] Generated clinic ID:", clinicId);
138
-
139
- // Process photos
140
- console.log("[CLINIC] Processing photos");
141
-
142
- // Handle logo upload if provided
143
- let logoUrl = null;
144
- if (validatedData.logo) {
145
- console.log("[CLINIC] Processing logo");
146
- try {
147
- logoUrl = await uploadPhoto(
148
- validatedData.logo as string,
149
- "clinics",
150
- clinicId,
151
- "logo",
152
- app
153
- );
154
- console.log("[CLINIC] Logo processed", { logoUrl });
155
- } catch (logoError) {
156
- console.error("[CLINIC] Error processing logo:", logoError);
157
- // Continue with clinic creation even if logo upload fails
158
- }
159
- }
160
-
161
- // Handle cover photo upload
162
- let processedCoverPhoto: string | null = null;
163
- if (validatedData.coverPhoto) {
164
- console.log("[CLINIC] Processing cover photo");
165
- try {
166
- processedCoverPhoto = await uploadPhoto(
167
- validatedData.coverPhoto as string,
168
- "clinics",
169
- clinicId,
170
- "cover",
171
- app
172
- );
173
- console.log("[CLINIC] Cover photo processed", {
174
- coverPhoto: processedCoverPhoto,
175
- });
176
- } catch (coverPhotoError) {
177
- console.error("[CLINIC] Error processing cover photo:", coverPhotoError);
178
- // Continue with clinic creation even if cover photo upload fails
179
- const coverPhotoStr = validatedData.coverPhoto as string;
180
- processedCoverPhoto = coverPhotoStr.startsWith("data:")
181
- ? null
182
- : coverPhotoStr;
183
- }
184
- }
185
-
186
- // Handle featured photos upload
187
- let processedFeaturedPhotos: string[] = [];
188
- const featuredPhotosStr = (validatedData.featuredPhotos || []) as string[];
189
- if (featuredPhotosStr.length > 0) {
190
- console.log("[CLINIC] Processing featured photos");
191
- try {
192
- processedFeaturedPhotos = await uploadMultiplePhotos(
193
- featuredPhotosStr,
194
- "clinics",
195
- clinicId,
196
- "featured",
197
- app
198
- );
199
- console.log("[CLINIC] Featured photos processed", {
200
- count: processedFeaturedPhotos.length,
201
- });
202
- } catch (featuredError) {
203
- console.error(
204
- "[CLINIC] Error processing featured photos:",
205
- featuredError
206
- );
207
- // Continue with clinic creation even if featured photos upload fails
208
- processedFeaturedPhotos = featuredPhotosStr.filter(
209
- (photo) => !photo.startsWith("data:")
210
- );
211
- }
212
- }
213
-
214
- // Handle photos with tags
215
- let processedPhotosWithTags: any[] = validatedData.photosWithTags || [];
216
- if (processedPhotosWithTags.length > 0) {
217
- console.log("[CLINIC] Processing photos with tags");
218
- try {
219
- const updatedPhotosWithTags: any[] = [];
220
- for (const photoWithTag of processedPhotosWithTags) {
221
- const photoUrl = photoWithTag.url as string;
222
- if (photoUrl && photoUrl.startsWith("data:")) {
223
- const uploadedUrl = await uploadPhoto(
224
- photoUrl,
225
- "clinics",
226
- clinicId,
227
- `tagged-${photoWithTag.tag}`,
228
- app
229
- );
230
- if (uploadedUrl) {
231
- updatedPhotosWithTags.push({
232
- url: uploadedUrl,
233
- tag: photoWithTag.tag,
234
- });
235
- }
236
- } else {
237
- updatedPhotosWithTags.push(photoWithTag);
238
- }
239
- }
240
- processedPhotosWithTags = updatedPhotosWithTags;
241
- console.log("[CLINIC] Photos with tags processed", {
242
- count: processedPhotosWithTags.length,
243
- });
244
- } catch (tagsError) {
245
- console.error("[CLINIC] Error processing photos with tags:", tagsError);
246
- // Continue with clinic creation even if photos with tags upload fails
247
- processedPhotosWithTags =
248
- validatedData.photosWithTags?.filter(
249
- (photo) => !(photo.url as string).startsWith("data:")
250
- ) || [];
251
- }
252
- }
253
-
254
- const now = Timestamp.now();
255
- console.log("[CLINIC] Preparing clinic data object");
256
-
257
- const clinicData: Clinic = {
258
- ...validatedData,
259
- id: clinicId,
260
- nameLower: (validatedData.name || "").toLowerCase(),
261
- description: validatedData.description || "",
262
- location: {
263
- address: validatedData.location.address || "",
264
- city: validatedData.location.city || "",
265
- country: validatedData.location.country || "",
266
- postalCode: validatedData.location.postalCode || "",
267
- latitude: validatedData.location.latitude || 0,
268
- longitude: validatedData.location.longitude || 0,
269
- geohash: validatedData.location.geohash || "",
270
- },
271
- contactInfo: {
272
- email: validatedData.contactInfo.email || "",
273
- phoneNumber: validatedData.contactInfo.phoneNumber || "",
274
- alternativePhoneNumber:
275
- validatedData.contactInfo.alternativePhoneNumber || "",
276
- website: validatedData.contactInfo.website || "",
277
- },
278
- logo: logoUrl || "",
279
- tags: validatedData.tags || [],
280
- featuredPhotos: processedFeaturedPhotos || [],
281
- coverPhoto: processedCoverPhoto,
282
- photosWithTags: processedPhotosWithTags,
283
- doctors: [],
284
- doctorsInfo: [],
285
- procedures: [],
286
- proceduresInfo: [],
287
- reviewInfo: {
288
- totalReviews: 0,
289
- averageRating: 0,
290
- cleanliness: 0,
291
- facilities: 0,
292
- staffFriendliness: 0,
293
- waitingTime: 0,
294
- accessibility: 0,
295
- recommendationPercentage: 0,
296
- },
297
- admins: [creatorAdminId],
298
- createdAt: now,
299
- updatedAt: now,
300
- isActive: true,
301
- isVerified: false,
302
- };
303
-
304
- try {
305
- // Validiramo kompletan objekat
306
- console.log("[CLINIC] Validating complete clinic object");
307
- try {
308
- clinicSchema.parse(clinicData);
309
- console.log("[CLINIC] Clinic validation passed");
310
- } catch (schemaError) {
311
- console.error(
312
- "[CLINIC] Clinic validation failed:",
313
- JSON.stringify(schemaError, null, 2)
314
- );
315
- throw schemaError;
316
- }
317
-
318
- // Čuvamo u Firestore
319
- console.log("[CLINIC] Saving clinic to Firestore", {
320
- clinicId: clinicData.id,
321
- });
322
- try {
323
- await setDoc(doc(db, CLINICS_COLLECTION, clinicData.id), clinicData);
324
- console.log("[CLINIC] Clinic saved successfully");
325
- } catch (firestoreError) {
326
- console.error("[CLINIC] Error saving to Firestore:", firestoreError);
327
- throw firestoreError;
328
- }
329
-
330
- // Dodajemo kliniku u grupaciju
331
- console.log("[CLINIC] Adding clinic to clinic group");
332
- try {
333
- const group = await clinicGroupService.getClinicGroup(
334
- validatedData.clinicGroupId
335
- );
336
- if (group) {
337
- await clinicGroupService.updateClinicGroup(
338
- validatedData.clinicGroupId,
339
- {
340
- clinics: [...group.clinics, clinicData.id],
341
- }
342
- );
343
- console.log("[CLINIC] Clinic added to group successfully");
344
- }
345
- } catch (groupUpdateError) {
346
- console.error("[CLINIC] Error adding clinic to group:", groupUpdateError);
347
- // Continue even if adding to group fails
348
- }
349
-
350
- // Dodajemo kliniku adminu koji ju je kreirao
351
- console.log("[CLINIC] Adding clinic to admin's managed clinics");
352
- try {
353
- await clinicAdminService.addClinicToManaged(
354
- creatorAdminId,
355
- clinicData.id
356
- );
357
- console.log(
358
- "[CLINIC] Clinic added to admin's managed clinics successfully"
359
- );
360
- } catch (adminUpdateError) {
361
- console.error(
362
- "[CLINIC] Error adding clinic to admin's managed clinics:",
363
- adminUpdateError
364
- );
365
- // Continue even if adding to admin fails
366
- }
367
-
368
- console.log("[CLINIC] Clinic creation completed successfully", {
369
- clinicId: clinicData.id,
370
- clinicName: clinicData.name,
371
- });
372
- return clinicData;
373
- } catch (error) {
374
- if (error instanceof z.ZodError) {
375
- console.error(
376
- "[CLINIC] Zod validation error:",
377
- JSON.stringify(error.errors, null, 2)
378
- );
379
- throw new Error("Invalid clinic data: " + error.message);
380
- }
381
- console.error("[CLINIC] Unhandled error in createClinic:", error);
382
- throw error;
383
- }
384
- }
385
-
386
- /**
387
- * Gets a clinic by ID
388
- * @param db - Firestore database instance
389
- * @param clinicId - ID of the clinic to get
390
- * @returns The clinic or null if not found
391
- */
392
- /**
393
- * Filters out draft/inactive practitioners from doctorsInfo array
394
- * Note: Since doctorInfo no longer contains status/isActive fields,
395
- * filtering is handled at the procedure level by ProcedureService.
396
- * This function now returns all doctors as-is.
397
- * @param doctorsInfo Array of doctor info objects
398
- * @returns Array of doctor info objects (no filtering applied)
399
- */
400
- function filterDoctorsInfo(doctorsInfo: DoctorInfo[]): DoctorInfo[] {
401
- if (!doctorsInfo || doctorsInfo.length === 0) {
402
- return doctorsInfo || [];
403
- }
404
-
405
- // Return all doctors - filtering is handled at procedure level
406
- return doctorsInfo;
407
- }
408
-
409
- /**
410
- * Filters embedded arrays in a Clinic object to exclude draft/inactive practitioners
411
- * @param clinic Clinic object to filter
412
- * @param excludeDraftPractitioners If true, filters out draft/inactive practitioners. If false (default), returns all practitioners (for admin views)
413
- * @returns Clinic object with filtered embedded arrays
414
- */
415
- export function filterClinicEmbeddedArrays(clinic: Clinic, excludeDraftPractitioners: boolean = false): Clinic {
416
- if (!clinic) {
417
- return clinic;
418
- }
419
-
420
- console.log(`[CLINIC_UTILS] filterClinicEmbeddedArrays called for clinic ${clinic.id} with excludeDraftPractitioners: ${excludeDraftPractitioners}`);
421
-
422
- // If excluding drafts is disabled (default), return clinic as-is (for admin views)
423
- if (!excludeDraftPractitioners) {
424
- console.log(`[CLINIC_UTILS] Skipping filtering for clinic ${clinic.id} (excludeDraftPractitioners is false)`);
425
- return clinic;
426
- }
427
-
428
- const filteredClinic = { ...clinic };
429
-
430
- // Filter doctorsInfo array
431
- if (filteredClinic.doctorsInfo && filteredClinic.doctorsInfo.length > 0) {
432
- const originalLength = filteredClinic.doctorsInfo.length;
433
- filteredClinic.doctorsInfo = filterDoctorsInfo(filteredClinic.doctorsInfo);
434
- if (filteredClinic.doctorsInfo.length !== originalLength) {
435
- console.log(
436
- `[CLINIC_UTILS] Filtered ${originalLength - filteredClinic.doctorsInfo.length} draft/inactive doctors from clinic ${clinic.id}`
437
- );
438
- }
439
- }
440
-
441
- // Note: proceduresInfo doesn't have embedded status/isActive, so we rely on
442
- // Cloud Functions to not include procedures from draft/inactive practitioners.
443
- // If needed, we could add additional filtering here by checking practitioner status.
444
-
445
- return filteredClinic;
446
- }
447
-
448
- export async function getClinic(
449
- db: Firestore,
450
- clinicId: string,
451
- excludeDraftPractitioners: boolean = false
452
- ): Promise<Clinic | null> {
453
- const docRef = doc(db, CLINICS_COLLECTION, clinicId);
454
- const docSnap = await getDoc(docRef);
455
-
456
- if (docSnap.exists()) {
457
- const clinicData = docSnap.data() as Clinic;
458
- return filterClinicEmbeddedArrays(clinicData, excludeDraftPractitioners);
459
- }
460
-
461
- return null;
462
- }
463
-
464
- /**
465
- * Gets all clinics in a group
466
- * @param db - Firestore database instance
467
- * @param groupId - ID of the clinic group
468
- * @returns Array of clinics in the group
469
- */
470
- export async function getClinicsByGroup(
471
- db: Firestore,
472
- groupId: string,
473
- excludeDraftPractitioners: boolean = false
474
- ): Promise<Clinic[]> {
475
- const q = query(
476
- collection(db, CLINICS_COLLECTION),
477
- where("clinicGroupId", "==", groupId),
478
- where("isActive", "==", true)
479
- );
480
-
481
- const querySnapshot = await getDocs(q);
482
- return querySnapshot.docs.map((doc) => {
483
- const clinicData = doc.data() as Clinic;
484
- return filterClinicEmbeddedArrays(clinicData, excludeDraftPractitioners);
485
- });
486
- }
487
-
488
- /**
489
- * Updates a clinic
490
- * @param db - Firestore database instance
491
- * @param clinicId - ID of the clinic to update
492
- * @param data - Data to update
493
- * @param adminId - ID of the admin making the update
494
- * @param clinicAdminService - Service for clinic admin operations
495
- * @param app - Firebase app instance
496
- * @returns The updated clinic
497
- */
498
- export async function updateClinic(
499
- db: Firestore,
500
- clinicId: string,
501
- data: Partial<Clinic>,
502
- adminId: string,
503
- clinicAdminService: any,
504
- app: FirebaseApp
505
- ): Promise<Clinic> {
506
- console.log("[CLINIC] Starting clinic update", { clinicId, adminId });
507
-
508
- const clinic = await getClinic(db, clinicId);
509
- if (!clinic) {
510
- console.error("[CLINIC] Clinic not found", { clinicId });
511
- throw new Error("Clinic not found");
512
- }
513
-
514
- // Proveravamo da li admin ima prava da ažurira kliniku
515
- try {
516
- console.log("[CLINIC] Checking admin permissions");
517
- const admin = await clinicAdminService.getClinicAdmin(adminId);
518
- if (!admin) {
519
- console.error("[CLINIC] Admin not found", { adminId });
520
- throw new Error("Admin not found");
521
- }
522
-
523
- // Check if admin is either:
524
- // 1. The owner of the clinic group that this clinic belongs to, OR
525
- // 2. Has this clinic in their managed clinics list AND is listed in the clinic's admins array
526
- const hasPermission =
527
- (admin.isGroupOwner && admin.clinicGroupId === clinic.clinicGroupId) ||
528
- (admin.clinicsManaged.includes(clinicId) &&
529
- clinic.admins &&
530
- clinic.admins.includes(adminId));
531
-
532
- if (!hasPermission) {
533
- console.error(
534
- "[CLINIC] Admin does not have permission to update this clinic",
535
- {
536
- adminId,
537
- clinicId,
538
- isGroupOwner: admin.isGroupOwner,
539
- clinicsManaged: admin.clinicsManaged,
540
- isClinicAdmin: clinic.admins && clinic.admins.includes(adminId),
541
- }
542
- );
543
- throw new Error("Admin does not have permission to update this clinic");
544
- }
545
- console.log("[CLINIC] Admin permissions verified");
546
- } catch (adminError) {
547
- console.error("[CLINIC] Error verifying admin permissions:", adminError);
548
- throw adminError;
549
- }
550
-
551
- // Process photos in the update data
552
- let updatedData = { ...data };
553
-
554
- // Handle logo update if provided
555
- if (
556
- data.logo &&
557
- typeof data.logo === "string" &&
558
- data.logo.startsWith("data:")
559
- ) {
560
- console.log("[CLINIC] Processing logo update");
561
- try {
562
- const logoUrl = await uploadPhoto(
563
- data.logo,
564
- "clinics",
565
- clinicId,
566
- "logo",
567
- app
568
- );
569
- console.log("[CLINIC] Logo update processed", { logoUrl });
570
-
571
- if (logoUrl !== null) {
572
- updatedData.logo = logoUrl;
573
- }
574
- } catch (logoError) {
575
- console.error("[CLINIC] Error processing logo update:", logoError);
576
- // Continue with update even if logo upload fails
577
- }
578
- }
579
-
580
- // Handle cover photo update if provided
581
- if (data.coverPhoto) {
582
- console.log("[CLINIC] Processing cover photo update");
583
- try {
584
- // Check if it's a data URL that needs to be uploaded
585
- if (
586
- typeof data.coverPhoto === "string" &&
587
- data.coverPhoto.startsWith("data:")
588
- ) {
589
- const uploadedPhoto = await uploadPhoto(
590
- data.coverPhoto,
591
- "clinics",
592
- clinicId,
593
- "cover",
594
- app
595
- );
596
- if (uploadedPhoto) {
597
- updatedData.coverPhoto = uploadedPhoto;
598
- }
599
- } else {
600
- // Keep existing photo URL
601
- updatedData.coverPhoto = data.coverPhoto;
602
- }
603
- console.log("[CLINIC] Cover photo update processed");
604
- } catch (photoError) {
605
- console.error(
606
- "[CLINIC] Error processing cover photo update:",
607
- photoError
608
- );
609
- // Keep existing photo if upload fails
610
- if (
611
- typeof data.coverPhoto === "string" &&
612
- !data.coverPhoto.startsWith("data:")
613
- ) {
614
- updatedData.coverPhoto = data.coverPhoto;
615
- }
616
- }
617
- }
618
-
619
- // Handle featured photos update if provided
620
- if (data.featuredPhotos && data.featuredPhotos.length > 0) {
621
- console.log("[CLINIC] Processing featured photos update");
622
- try {
623
- // Filter out only data URLs that need to be uploaded
624
- const allPhotos = data.featuredPhotos as string[];
625
- const dataUrlPhotos = allPhotos.filter(
626
- (photo) => typeof photo === "string" && photo.startsWith("data:")
627
- );
628
- const existingPhotos = allPhotos.filter(
629
- (photo) => typeof photo === "string" && !photo.startsWith("data:")
630
- );
631
-
632
- if (dataUrlPhotos.length > 0) {
633
- const uploadedPhotos = await uploadMultiplePhotos(
634
- dataUrlPhotos,
635
- "clinics",
636
- clinicId,
637
- "featured",
638
- app
639
- );
640
- console.log("[CLINIC] Featured photos update processed", {
641
- count: uploadedPhotos.length,
642
- });
643
-
644
- // Combine existing photos with newly uploaded ones
645
- updatedData.featuredPhotos = [...existingPhotos, ...uploadedPhotos] as any;
646
- } else {
647
- // If no new photos to upload, just use the existing ones
648
- updatedData.featuredPhotos = existingPhotos as any;
649
- }
650
- } catch (featuredError) {
651
- console.error(
652
- "[CLINIC] Error processing featured photos update:",
653
- featuredError
654
- );
655
- // Continue with update even if featured photos upload fails
656
- updatedData.featuredPhotos = (data.featuredPhotos as string[]).filter(
657
- (photo) => typeof photo === "string" && !photo.startsWith("data:")
658
- ) as any;
659
- }
660
- }
661
-
662
- // Handle photos with tags update if provided
663
- if (data.photosWithTags && data.photosWithTags.length > 0) {
664
- console.log("[CLINIC] Processing photos with tags update");
665
- try {
666
- const updatedPhotosWithTags: any[] = [];
667
-
668
- // Process each photo with tag
669
- for (const photoWithTag of data.photosWithTags) {
670
- const photoUrl = photoWithTag.url as string;
671
- if (photoUrl && photoUrl.startsWith("data:")) {
672
- // Upload new photo
673
- const uploadedUrl = await uploadPhoto(
674
- photoUrl,
675
- "clinics",
676
- clinicId,
677
- `tagged-${photoWithTag.tag}`,
678
- app
679
- );
680
-
681
- if (uploadedUrl) {
682
- updatedPhotosWithTags.push({
683
- url: uploadedUrl,
684
- tag: photoWithTag.tag,
685
- });
686
- }
687
- } else {
688
- // Keep existing photo
689
- updatedPhotosWithTags.push(photoWithTag);
690
- }
691
- }
692
-
693
- updatedData.photosWithTags = updatedPhotosWithTags;
694
- console.log("[CLINIC] Photos with tags update processed", {
695
- count: updatedPhotosWithTags.length,
696
- });
697
- } catch (tagsError) {
698
- console.error(
699
- "[CLINIC] Error processing photos with tags update:",
700
- tagsError
701
- );
702
- // Continue with update even if photos with tags upload fails
703
- updatedData.photosWithTags = data.photosWithTags.filter(
704
- (photo) => !(photo.url as string).startsWith("data:")
705
- );
706
- }
707
- }
708
-
709
- // Add timestamp
710
- updatedData = {
711
- ...updatedData,
712
- updatedAt: Timestamp.now(),
713
- };
714
-
715
- console.log("[CLINIC] Updating clinic in Firestore");
716
- try {
717
- await updateDoc(doc(db, CLINICS_COLLECTION, clinicId), updatedData);
718
- console.log("[CLINIC] Clinic updated successfully");
719
- } catch (updateError) {
720
- console.error("[CLINIC] Error updating clinic in Firestore:", updateError);
721
- throw updateError;
722
- }
723
-
724
- // Return updated data
725
- const updatedClinic = await getClinic(db, clinicId);
726
- if (!updatedClinic) {
727
- console.error("[CLINIC] Failed to retrieve updated clinic");
728
- throw new Error("Failed to retrieve updated clinic");
729
- }
730
-
731
- console.log("[CLINIC] Clinic update completed successfully");
732
- return updatedClinic;
733
- }
734
-
735
- /**
736
- * Deactivates a clinic
737
- * @param db - Firestore database instance
738
- * @param clinicId - ID of the clinic to deactivate
739
- * @param adminId - ID of the admin making the deactivation
740
- * @param clinicAdminService - Service for clinic admin operations
741
- */
742
- export async function deactivateClinic(
743
- db: Firestore,
744
- clinicId: string,
745
- adminId: string,
746
- clinicAdminService: any
747
- ): Promise<void> {
748
- const clinic = await getClinic(db, clinicId);
749
- if (!clinic) {
750
- throw new Error("Clinic not found");
751
- }
752
-
753
- // Proveravamo da li admin ima prava da deaktivira kliniku
754
- const admin = await clinicAdminService.getClinicAdmin(adminId);
755
- if (!admin) {
756
- throw new Error("Admin not found");
757
- }
758
-
759
- // Check if admin is either:
760
- // 1. The owner of the clinic group, OR
761
- // 2. Has this clinic in their managed clinics list, OR
762
- // 3. Is listed in the clinic's admins array
763
- const hasPermission =
764
- (admin.isGroupOwner && admin.clinicGroupId === clinic.clinicGroupId) ||
765
- (admin.clinicsManaged.includes(clinicId) &&
766
- clinic.admins &&
767
- clinic.admins.includes(adminId));
768
-
769
- if (!hasPermission) {
770
- throw new Error("Admin does not have permission to deactivate this clinic");
771
- }
772
-
773
- await updateDoc(doc(db, CLINICS_COLLECTION, clinicId), {
774
- isActive: false,
775
- updatedAt: Timestamp.now(),
776
- });
777
- }
778
-
779
- /**
780
- * Gets clinics managed by an admin
781
- * @param db - Firestore database instance
782
- * @param adminId - ID of the admin
783
- * @param options - Options for filtering clinics
784
- * @param clinicAdminService - Service for clinic admin operations
785
- * @param clinicGroupService - Service for clinic group operations
786
- * @returns Array of clinics managed by the admin
787
- */
788
- export async function getClinicsByAdmin(
789
- db: Firestore,
790
- adminId: string,
791
- options: {
792
- isActive?: boolean;
793
- includeGroupClinics?: boolean;
794
- } = {},
795
- clinicAdminService: any,
796
- clinicGroupService: any
797
- ): Promise<Clinic[]> {
798
- const admin = await clinicAdminService.getClinicAdmin(adminId);
799
- if (!admin) {
800
- throw new Error("Admin not found");
801
- }
802
-
803
- let clinicIds: string[] = [...admin.clinicsManaged];
804
-
805
- // Ako je admin vlasnik grupe i tražimo sve klinike grupe
806
- if (admin.isGroupOwner && options.includeGroupClinics) {
807
- const group = await clinicGroupService.getClinicGroup(admin.clinicGroupId);
808
- if (group) {
809
- clinicIds = [...new Set([...clinicIds, ...group.clinics])];
810
- }
811
- }
812
-
813
- if (clinicIds.length === 0) {
814
- return [];
815
- }
816
-
817
- const constraints: QueryConstraint[] = [where("id", "in", clinicIds)];
818
-
819
- if (options.isActive !== undefined) {
820
- constraints.push(where("isActive", "==", options.isActive));
821
- }
822
-
823
- const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
824
- const querySnapshot = await getDocs(q);
825
-
826
- return querySnapshot.docs.map((doc) => doc.data() as Clinic);
827
- }
828
-
829
- /**
830
- * Gets active clinics managed by an admin
831
- * @param db - Firestore database instance
832
- * @param adminId - ID of the admin
833
- * @param clinicAdminService - Service for clinic admin operations
834
- * @param clinicGroupService - Service for clinic group operations
835
- * @returns Array of active clinics managed by the admin
836
- */
837
- export async function getActiveClinicsByAdmin(
838
- db: Firestore,
839
- adminId: string,
840
- clinicAdminService: any,
841
- clinicGroupService: any
842
- ): Promise<Clinic[]> {
843
- return getClinicsByAdmin(
844
- db,
845
- adminId,
846
- { isActive: true },
847
- clinicAdminService,
848
- clinicGroupService
849
- );
850
- }
851
-
852
- /**
853
- * Retrieves a clinic by its ID
854
- *
855
- * @param db - Firestore database instance
856
- * @param clinicId - ID of the clinic to retrieve
857
- * @returns The clinic if found, null otherwise
858
- */
859
- export async function getClinicById(
860
- db: Firestore,
861
- clinicId: string,
862
- excludeDraftPractitioners: boolean = false
863
- ): Promise<Clinic | null> {
864
- try {
865
- const clinicRef = doc(db, CLINICS_COLLECTION, clinicId);
866
- const clinicSnapshot = await getDoc(clinicRef);
867
-
868
- if (!clinicSnapshot.exists()) {
869
- return null;
870
- }
871
-
872
- const clinicData = clinicSnapshot.data() as Clinic;
873
- const clinicWithId = {
874
- ...clinicData,
875
- id: clinicSnapshot.id,
876
- };
877
- return filterClinicEmbeddedArrays(clinicWithId, excludeDraftPractitioners);
878
- } catch (error) {
879
- console.error("[CLINIC_UTILS] Error getting clinic by ID:", error);
880
- throw error;
881
- }
882
- }
883
-
884
- /**
885
- * Retrieves all clinics with optional pagination
886
- *
887
- * @param db - Firestore database instance
888
- * @param pagination - Optional number of clinics per page (0 or undefined returns all)
889
- * @param lastDoc - Optional last document for pagination (if continuing from a previous page)
890
- * @returns Array of clinics and the last document for pagination
891
- */
892
- export async function getAllClinics(
893
- db: Firestore,
894
- pagination?: number,
895
- lastDoc?: any,
896
- excludeDraftPractitioners: boolean = false
897
- ): Promise<{ clinics: Clinic[]; lastDoc: any }> {
898
- try {
899
- const clinicsCollection = collection(db, CLINICS_COLLECTION);
900
- let clinicsQuery = query(clinicsCollection);
901
-
902
- // If pagination is specified and greater than 0, limit the query
903
- if (pagination && pagination > 0) {
904
- if (lastDoc) {
905
- clinicsQuery = query(
906
- clinicsCollection,
907
- startAfter(lastDoc),
908
- limit(pagination)
909
- );
910
- } else {
911
- clinicsQuery = query(clinicsCollection, limit(pagination));
912
- }
913
- }
914
-
915
- const clinicsSnapshot = await getDocs(clinicsQuery);
916
- const lastVisible = clinicsSnapshot.docs[clinicsSnapshot.docs.length - 1];
917
-
918
- const clinics = clinicsSnapshot.docs.map((doc) => {
919
- const data = doc.data() as Clinic;
920
- const clinicWithId = {
921
- ...data,
922
- id: doc.id,
923
- };
924
- return filterClinicEmbeddedArrays(clinicWithId, excludeDraftPractitioners);
925
- });
926
-
927
- return {
928
- clinics,
929
- lastDoc: lastVisible,
930
- };
931
- } catch (error) {
932
- console.error("[CLINIC_UTILS] Error getting all clinics:", error);
933
- throw error;
934
- }
935
- }
936
-
937
- /**
938
- * Retrieves all clinics within a specified range from a location with optional pagination
939
- *
940
- * @param db - Firestore database instance
941
- * @param center - The center location coordinates {latitude, longitude}
942
- * @param rangeInKm - The range in kilometers to search within
943
- * @param pagination - Optional number of clinics per page (0 or undefined returns all)
944
- * @param lastDoc - Optional last document for pagination (if continuing from a previous page)
945
- * @returns Array of clinics with distance information and the last document for pagination
946
- */
947
- export async function getAllClinicsInRange(
948
- db: Firestore,
949
- center: { latitude: number; longitude: number },
950
- rangeInKm: number,
951
- pagination?: number,
952
- lastDoc?: any,
953
- excludeDraftPractitioners: boolean = false
954
- ): Promise<{ clinics: (Clinic & { distance: number })[]; lastDoc: any }> {
955
- const bounds = geohashQueryBounds(
956
- [center.latitude, center.longitude],
957
- rangeInKm * 1000
958
- );
959
-
960
- const matchingClinics: (Clinic & { distance: number })[] = [];
961
- let lastDocSnapshot = null;
962
-
963
- for (const b of bounds) {
964
- const constraints: QueryConstraint[] = [
965
- where("location.geohash", ">=", b[0]),
966
- where("location.geohash", "<=", b[1]),
967
- where("isActive", "==", true),
968
- ];
969
-
970
- const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
971
- const querySnapshot = await getDocs(q);
972
-
973
- for (const doc of querySnapshot.docs) {
974
- const clinic = doc.data() as Clinic;
975
- const distance = distanceBetween(
976
- [center.latitude, center.longitude],
977
- [clinic.location.latitude, clinic.location.longitude]
978
- );
979
-
980
- const distanceInKm = distance; // distanceBetween already returns km
981
-
982
- if (distanceInKm <= rangeInKm) {
983
- const filteredClinic = filterClinicEmbeddedArrays(clinic, excludeDraftPractitioners);
984
- matchingClinics.push({
985
- ...filteredClinic,
986
- distance: distanceInKm,
987
- });
988
- }
989
- }
990
- }
991
-
992
- // Sort by distance
993
- matchingClinics.sort((a, b) => a.distance - b.distance);
994
-
995
- if (pagination && pagination > 0) {
996
- // Paginate results
997
- let result = matchingClinics;
998
- if (lastDoc && matchingClinics.length > 0) {
999
- const lastIndex = matchingClinics.findIndex(
1000
- (clinic) => clinic.id === lastDoc.id
1001
- );
1002
- if (lastIndex !== -1) {
1003
- result = matchingClinics.slice(lastIndex + 1);
1004
- }
1005
- }
1006
-
1007
- const paginatedClinics = result.slice(0, pagination);
1008
- const newLastDoc =
1009
- paginatedClinics.length > 0
1010
- ? paginatedClinics[paginatedClinics.length - 1]
1011
- : null;
1012
-
1013
- return {
1014
- clinics: paginatedClinics,
1015
- lastDoc: newLastDoc,
1016
- };
1017
- }
1018
-
1019
- return {
1020
- clinics: matchingClinics,
1021
- lastDoc: null,
1022
- };
1023
- }
1
+ import {
2
+ collection,
3
+ doc,
4
+ getDoc,
5
+ getDocs,
6
+ query,
7
+ where,
8
+ updateDoc,
9
+ setDoc,
10
+ deleteDoc,
11
+ Timestamp,
12
+ Firestore,
13
+ QueryConstraint,
14
+ addDoc,
15
+ writeBatch,
16
+ limit,
17
+ startAfter,
18
+ } from "firebase/firestore";
19
+ import {
20
+ Clinic,
21
+ CreateClinicData,
22
+ CLINICS_COLLECTION,
23
+ ClinicTag,
24
+ ClinicGroup,
25
+ ClinicBranchSetupData,
26
+ ClinicLocation,
27
+ DoctorInfo,
28
+ } from "../../../types/clinic";
29
+ import {
30
+ geohashForLocation,
31
+ distanceBetween,
32
+ geohashQueryBounds,
33
+ } from "geofire-common";
34
+ import {
35
+ clinicSchema,
36
+ createClinicSchema,
37
+ } from "../../../validations/clinic.schema";
38
+ import { z } from "zod";
39
+ import { FirebaseApp } from "firebase/app";
40
+ import {
41
+ uploadPhoto,
42
+ uploadMultiplePhotos,
43
+ processEntityPhotos,
44
+ } from "./photos.utils";
45
+
46
+ /**
47
+ * Creates a new clinic
48
+ * @param db - Firestore database instance
49
+ * @param data - Clinic data
50
+ * @param creatorAdminId - ID of the admin creating the clinic
51
+ * @param clinicGroupService - Service for clinic group operations
52
+ * @param clinicAdminService - Service for clinic admin operations
53
+ * @param app - Firebase app instance
54
+ * @returns The created clinic
55
+ */
56
+ export async function createClinic(
57
+ db: Firestore,
58
+ data: CreateClinicData,
59
+ creatorAdminId: string,
60
+ clinicGroupService: any,
61
+ clinicAdminService: any,
62
+ app: FirebaseApp
63
+ ): Promise<Clinic> {
64
+ console.log("[CLINIC] Starting clinic creation", { creatorAdminId });
65
+ console.log("[CLINIC] Input data:", JSON.stringify(data, null, 2));
66
+
67
+ // Validacija podataka
68
+ try {
69
+ const validatedData = createClinicSchema.parse(data);
70
+ console.log("[CLINIC] Data validation passed");
71
+ } catch (validationError) {
72
+ console.error("[CLINIC] Data validation failed:", validationError);
73
+ throw validationError;
74
+ }
75
+
76
+ const validatedData = createClinicSchema.parse(data);
77
+
78
+ // Proveravamo da li admin postoji i da li pripada grupi
79
+ try {
80
+ console.log("[CLINIC] Checking if admin exists and belongs to group");
81
+ const admin = await clinicAdminService.getClinicAdmin(creatorAdminId);
82
+ if (!admin) {
83
+ console.error("[CLINIC] Admin not found", { creatorAdminId });
84
+ throw new Error("Admin not found");
85
+ }
86
+
87
+ if (admin.clinicGroupId !== validatedData.clinicGroupId) {
88
+ console.error("[CLINIC] Admin does not belong to this clinic group", {
89
+ adminGroupId: admin.clinicGroupId,
90
+ requestedGroupId: validatedData.clinicGroupId,
91
+ });
92
+ throw new Error("Admin does not belong to this clinic group");
93
+ }
94
+ console.log("[CLINIC] Admin verified");
95
+ } catch (adminError) {
96
+ console.error("[CLINIC] Error verifying admin:", adminError);
97
+ throw adminError;
98
+ }
99
+
100
+ // Proveravamo da li grupa postoji
101
+ try {
102
+ console.log("[CLINIC] Checking if clinic group exists");
103
+ const group = await clinicGroupService.getClinicGroup(
104
+ validatedData.clinicGroupId
105
+ );
106
+ if (!group) {
107
+ console.error("[CLINIC] Clinic group not found", {
108
+ groupId: validatedData.clinicGroupId,
109
+ });
110
+ throw new Error("Clinic group not found");
111
+ }
112
+ console.log("[CLINIC] Clinic group verified");
113
+ } catch (groupError) {
114
+ console.error("[CLINIC] Error verifying clinic group:", groupError);
115
+ throw groupError;
116
+ }
117
+
118
+ // Generišemo geohash za lokaciju
119
+ console.log("[CLINIC] Generating geohash for location");
120
+ if (validatedData.location) {
121
+ try {
122
+ validatedData.location.geohash = geohashForLocation([
123
+ validatedData.location.latitude,
124
+ validatedData.location.longitude,
125
+ ]);
126
+ console.log("[CLINIC] Geohash generated successfully", {
127
+ geohash: validatedData.location.geohash,
128
+ });
129
+ } catch (geohashError) {
130
+ console.error("[CLINIC] Error generating geohash:", geohashError);
131
+ throw geohashError;
132
+ }
133
+ }
134
+
135
+ // Generate a unique ID for the clinic
136
+ const clinicId = doc(collection(db, CLINICS_COLLECTION)).id;
137
+ console.log("[CLINIC] Generated clinic ID:", clinicId);
138
+
139
+ // Process photos
140
+ console.log("[CLINIC] Processing photos");
141
+
142
+ // Handle logo upload if provided
143
+ let logoUrl = null;
144
+ if (validatedData.logo) {
145
+ console.log("[CLINIC] Processing logo");
146
+ try {
147
+ logoUrl = await uploadPhoto(
148
+ validatedData.logo as string,
149
+ "clinics",
150
+ clinicId,
151
+ "logo",
152
+ app
153
+ );
154
+ console.log("[CLINIC] Logo processed", { logoUrl });
155
+ } catch (logoError) {
156
+ console.error("[CLINIC] Error processing logo:", logoError);
157
+ // Continue with clinic creation even if logo upload fails
158
+ }
159
+ }
160
+
161
+ // Handle cover photo upload
162
+ let processedCoverPhoto: string | null = null;
163
+ if (validatedData.coverPhoto) {
164
+ console.log("[CLINIC] Processing cover photo");
165
+ try {
166
+ processedCoverPhoto = await uploadPhoto(
167
+ validatedData.coverPhoto as string,
168
+ "clinics",
169
+ clinicId,
170
+ "cover",
171
+ app
172
+ );
173
+ console.log("[CLINIC] Cover photo processed", {
174
+ coverPhoto: processedCoverPhoto,
175
+ });
176
+ } catch (coverPhotoError) {
177
+ console.error("[CLINIC] Error processing cover photo:", coverPhotoError);
178
+ // Continue with clinic creation even if cover photo upload fails
179
+ const coverPhotoStr = validatedData.coverPhoto as string;
180
+ processedCoverPhoto = coverPhotoStr.startsWith("data:")
181
+ ? null
182
+ : coverPhotoStr;
183
+ }
184
+ }
185
+
186
+ // Handle featured photos upload
187
+ let processedFeaturedPhotos: string[] = [];
188
+ const featuredPhotosStr = (validatedData.featuredPhotos || []) as string[];
189
+ if (featuredPhotosStr.length > 0) {
190
+ console.log("[CLINIC] Processing featured photos");
191
+ try {
192
+ processedFeaturedPhotos = await uploadMultiplePhotos(
193
+ featuredPhotosStr,
194
+ "clinics",
195
+ clinicId,
196
+ "featured",
197
+ app
198
+ );
199
+ console.log("[CLINIC] Featured photos processed", {
200
+ count: processedFeaturedPhotos.length,
201
+ });
202
+ } catch (featuredError) {
203
+ console.error(
204
+ "[CLINIC] Error processing featured photos:",
205
+ featuredError
206
+ );
207
+ // Continue with clinic creation even if featured photos upload fails
208
+ processedFeaturedPhotos = featuredPhotosStr.filter(
209
+ (photo) => !photo.startsWith("data:")
210
+ );
211
+ }
212
+ }
213
+
214
+ // Handle photos with tags
215
+ let processedPhotosWithTags: any[] = validatedData.photosWithTags || [];
216
+ if (processedPhotosWithTags.length > 0) {
217
+ console.log("[CLINIC] Processing photos with tags");
218
+ try {
219
+ const updatedPhotosWithTags: any[] = [];
220
+ for (const photoWithTag of processedPhotosWithTags) {
221
+ const photoUrl = photoWithTag.url as string;
222
+ if (photoUrl && photoUrl.startsWith("data:")) {
223
+ const uploadedUrl = await uploadPhoto(
224
+ photoUrl,
225
+ "clinics",
226
+ clinicId,
227
+ `tagged-${photoWithTag.tag}`,
228
+ app
229
+ );
230
+ if (uploadedUrl) {
231
+ updatedPhotosWithTags.push({
232
+ url: uploadedUrl,
233
+ tag: photoWithTag.tag,
234
+ });
235
+ }
236
+ } else {
237
+ updatedPhotosWithTags.push(photoWithTag);
238
+ }
239
+ }
240
+ processedPhotosWithTags = updatedPhotosWithTags;
241
+ console.log("[CLINIC] Photos with tags processed", {
242
+ count: processedPhotosWithTags.length,
243
+ });
244
+ } catch (tagsError) {
245
+ console.error("[CLINIC] Error processing photos with tags:", tagsError);
246
+ // Continue with clinic creation even if photos with tags upload fails
247
+ processedPhotosWithTags =
248
+ validatedData.photosWithTags?.filter(
249
+ (photo) => !(photo.url as string).startsWith("data:")
250
+ ) || [];
251
+ }
252
+ }
253
+
254
+ const now = Timestamp.now();
255
+ console.log("[CLINIC] Preparing clinic data object");
256
+
257
+ const clinicData: Clinic = {
258
+ ...validatedData,
259
+ id: clinicId,
260
+ nameLower: (validatedData.name || "").toLowerCase(),
261
+ description: validatedData.description || "",
262
+ location: {
263
+ address: validatedData.location.address || "",
264
+ city: validatedData.location.city || "",
265
+ country: validatedData.location.country || "",
266
+ postalCode: validatedData.location.postalCode || "",
267
+ latitude: validatedData.location.latitude || 0,
268
+ longitude: validatedData.location.longitude || 0,
269
+ geohash: validatedData.location.geohash || "",
270
+ },
271
+ contactInfo: {
272
+ email: validatedData.contactInfo.email || "",
273
+ phoneNumber: validatedData.contactInfo.phoneNumber || "",
274
+ alternativePhoneNumber:
275
+ validatedData.contactInfo.alternativePhoneNumber || "",
276
+ website: validatedData.contactInfo.website || "",
277
+ },
278
+ logo: logoUrl || "",
279
+ tags: validatedData.tags || [],
280
+ featuredPhotos: processedFeaturedPhotos || [],
281
+ coverPhoto: processedCoverPhoto,
282
+ photosWithTags: processedPhotosWithTags,
283
+ doctors: [],
284
+ doctorsInfo: [],
285
+ procedures: [],
286
+ proceduresInfo: [],
287
+ reviewInfo: {
288
+ totalReviews: 0,
289
+ averageRating: 0,
290
+ cleanliness: 0,
291
+ facilities: 0,
292
+ staffFriendliness: 0,
293
+ waitingTime: 0,
294
+ accessibility: 0,
295
+ recommendationPercentage: 0,
296
+ },
297
+ admins: [creatorAdminId],
298
+ createdAt: now,
299
+ updatedAt: now,
300
+ isActive: true,
301
+ isVerified: false,
302
+ };
303
+
304
+ try {
305
+ // Validiramo kompletan objekat
306
+ console.log("[CLINIC] Validating complete clinic object");
307
+ try {
308
+ clinicSchema.parse(clinicData);
309
+ console.log("[CLINIC] Clinic validation passed");
310
+ } catch (schemaError) {
311
+ console.error(
312
+ "[CLINIC] Clinic validation failed:",
313
+ JSON.stringify(schemaError, null, 2)
314
+ );
315
+ throw schemaError;
316
+ }
317
+
318
+ // Čuvamo u Firestore
319
+ console.log("[CLINIC] Saving clinic to Firestore", {
320
+ clinicId: clinicData.id,
321
+ });
322
+ try {
323
+ await setDoc(doc(db, CLINICS_COLLECTION, clinicData.id), clinicData);
324
+ console.log("[CLINIC] Clinic saved successfully");
325
+ } catch (firestoreError) {
326
+ console.error("[CLINIC] Error saving to Firestore:", firestoreError);
327
+ throw firestoreError;
328
+ }
329
+
330
+ // Dodajemo kliniku u grupaciju
331
+ console.log("[CLINIC] Adding clinic to clinic group");
332
+ try {
333
+ const group = await clinicGroupService.getClinicGroup(
334
+ validatedData.clinicGroupId
335
+ );
336
+ if (group) {
337
+ await clinicGroupService.updateClinicGroup(
338
+ validatedData.clinicGroupId,
339
+ {
340
+ clinics: [...group.clinics, clinicData.id],
341
+ }
342
+ );
343
+ console.log("[CLINIC] Clinic added to group successfully");
344
+ }
345
+ } catch (groupUpdateError) {
346
+ console.error("[CLINIC] Error adding clinic to group:", groupUpdateError);
347
+ // Continue even if adding to group fails
348
+ }
349
+
350
+ // Dodajemo kliniku adminu koji ju je kreirao
351
+ console.log("[CLINIC] Adding clinic to admin's managed clinics");
352
+ try {
353
+ await clinicAdminService.addClinicToManaged(
354
+ creatorAdminId,
355
+ clinicData.id
356
+ );
357
+ console.log(
358
+ "[CLINIC] Clinic added to admin's managed clinics successfully"
359
+ );
360
+ } catch (adminUpdateError) {
361
+ console.error(
362
+ "[CLINIC] Error adding clinic to admin's managed clinics:",
363
+ adminUpdateError
364
+ );
365
+ // Continue even if adding to admin fails
366
+ }
367
+
368
+ console.log("[CLINIC] Clinic creation completed successfully", {
369
+ clinicId: clinicData.id,
370
+ clinicName: clinicData.name,
371
+ });
372
+ return clinicData;
373
+ } catch (error) {
374
+ if (error instanceof z.ZodError) {
375
+ console.error(
376
+ "[CLINIC] Zod validation error:",
377
+ JSON.stringify(error.errors, null, 2)
378
+ );
379
+ throw new Error("Invalid clinic data: " + error.message);
380
+ }
381
+ console.error("[CLINIC] Unhandled error in createClinic:", error);
382
+ throw error;
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Gets a clinic by ID
388
+ * @param db - Firestore database instance
389
+ * @param clinicId - ID of the clinic to get
390
+ * @returns The clinic or null if not found
391
+ */
392
+ /**
393
+ * Filters out draft/inactive practitioners from doctorsInfo array
394
+ * Note: Since doctorInfo no longer contains status/isActive fields,
395
+ * filtering is handled at the procedure level by ProcedureService.
396
+ * This function now returns all doctors as-is.
397
+ * @param doctorsInfo Array of doctor info objects
398
+ * @returns Array of doctor info objects (no filtering applied)
399
+ */
400
+ function filterDoctorsInfo(doctorsInfo: DoctorInfo[]): DoctorInfo[] {
401
+ if (!doctorsInfo || doctorsInfo.length === 0) {
402
+ return doctorsInfo || [];
403
+ }
404
+
405
+ // Return all doctors - filtering is handled at procedure level
406
+ return doctorsInfo;
407
+ }
408
+
409
+ /**
410
+ * Filters embedded arrays in a Clinic object to exclude draft/inactive practitioners
411
+ * @param clinic Clinic object to filter
412
+ * @param excludeDraftPractitioners If true, filters out draft/inactive practitioners. If false (default), returns all practitioners (for admin views)
413
+ * @returns Clinic object with filtered embedded arrays
414
+ */
415
+ export function filterClinicEmbeddedArrays(clinic: Clinic, excludeDraftPractitioners: boolean = false): Clinic {
416
+ if (!clinic) {
417
+ return clinic;
418
+ }
419
+
420
+ console.log(`[CLINIC_UTILS] filterClinicEmbeddedArrays called for clinic ${clinic.id} with excludeDraftPractitioners: ${excludeDraftPractitioners}`);
421
+
422
+ // If excluding drafts is disabled (default), return clinic as-is (for admin views)
423
+ if (!excludeDraftPractitioners) {
424
+ console.log(`[CLINIC_UTILS] Skipping filtering for clinic ${clinic.id} (excludeDraftPractitioners is false)`);
425
+ return clinic;
426
+ }
427
+
428
+ const filteredClinic = { ...clinic };
429
+
430
+ // Filter doctorsInfo array
431
+ if (filteredClinic.doctorsInfo && filteredClinic.doctorsInfo.length > 0) {
432
+ const originalLength = filteredClinic.doctorsInfo.length;
433
+ filteredClinic.doctorsInfo = filterDoctorsInfo(filteredClinic.doctorsInfo);
434
+ if (filteredClinic.doctorsInfo.length !== originalLength) {
435
+ console.log(
436
+ `[CLINIC_UTILS] Filtered ${originalLength - filteredClinic.doctorsInfo.length} draft/inactive doctors from clinic ${clinic.id}`
437
+ );
438
+ }
439
+ }
440
+
441
+ // Note: proceduresInfo doesn't have embedded status/isActive, so we rely on
442
+ // Cloud Functions to not include procedures from draft/inactive practitioners.
443
+ // If needed, we could add additional filtering here by checking practitioner status.
444
+
445
+ return filteredClinic;
446
+ }
447
+
448
+ export async function getClinic(
449
+ db: Firestore,
450
+ clinicId: string,
451
+ excludeDraftPractitioners: boolean = false
452
+ ): Promise<Clinic | null> {
453
+ const docRef = doc(db, CLINICS_COLLECTION, clinicId);
454
+ const docSnap = await getDoc(docRef);
455
+
456
+ if (docSnap.exists()) {
457
+ const clinicData = docSnap.data() as Clinic;
458
+ return filterClinicEmbeddedArrays(clinicData, excludeDraftPractitioners);
459
+ }
460
+
461
+ return null;
462
+ }
463
+
464
+ /**
465
+ * Gets all clinics in a group
466
+ * @param db - Firestore database instance
467
+ * @param groupId - ID of the clinic group
468
+ * @returns Array of clinics in the group
469
+ */
470
+ export async function getClinicsByGroup(
471
+ db: Firestore,
472
+ groupId: string,
473
+ excludeDraftPractitioners: boolean = false
474
+ ): Promise<Clinic[]> {
475
+ const q = query(
476
+ collection(db, CLINICS_COLLECTION),
477
+ where("clinicGroupId", "==", groupId),
478
+ where("isActive", "==", true)
479
+ );
480
+
481
+ const querySnapshot = await getDocs(q);
482
+ return querySnapshot.docs.map((doc) => {
483
+ const clinicData = doc.data() as Clinic;
484
+ return filterClinicEmbeddedArrays(clinicData, excludeDraftPractitioners);
485
+ });
486
+ }
487
+
488
+ /**
489
+ * Updates a clinic
490
+ * @param db - Firestore database instance
491
+ * @param clinicId - ID of the clinic to update
492
+ * @param data - Data to update
493
+ * @param adminId - ID of the admin making the update
494
+ * @param clinicAdminService - Service for clinic admin operations
495
+ * @param app - Firebase app instance
496
+ * @returns The updated clinic
497
+ */
498
+ export async function updateClinic(
499
+ db: Firestore,
500
+ clinicId: string,
501
+ data: Partial<Clinic>,
502
+ adminId: string,
503
+ clinicAdminService: any,
504
+ app: FirebaseApp
505
+ ): Promise<Clinic> {
506
+ console.log("[CLINIC] Starting clinic update", { clinicId, adminId });
507
+
508
+ const clinic = await getClinic(db, clinicId);
509
+ if (!clinic) {
510
+ console.error("[CLINIC] Clinic not found", { clinicId });
511
+ throw new Error("Clinic not found");
512
+ }
513
+
514
+ // Proveravamo da li admin ima prava da ažurira kliniku
515
+ try {
516
+ console.log("[CLINIC] Checking admin permissions");
517
+ const admin = await clinicAdminService.getClinicAdmin(adminId);
518
+ if (!admin) {
519
+ console.error("[CLINIC] Admin not found", { adminId });
520
+ throw new Error("Admin not found");
521
+ }
522
+
523
+ // Check if admin is either:
524
+ // 1. The owner of the clinic group that this clinic belongs to, OR
525
+ // 2. Has this clinic in their managed clinics list AND is listed in the clinic's admins array
526
+ const hasPermission =
527
+ (admin.isGroupOwner && admin.clinicGroupId === clinic.clinicGroupId) ||
528
+ (admin.clinicsManaged.includes(clinicId) &&
529
+ clinic.admins &&
530
+ clinic.admins.includes(adminId));
531
+
532
+ if (!hasPermission) {
533
+ console.error(
534
+ "[CLINIC] Admin does not have permission to update this clinic",
535
+ {
536
+ adminId,
537
+ clinicId,
538
+ isGroupOwner: admin.isGroupOwner,
539
+ clinicsManaged: admin.clinicsManaged,
540
+ isClinicAdmin: clinic.admins && clinic.admins.includes(adminId),
541
+ }
542
+ );
543
+ throw new Error("Admin does not have permission to update this clinic");
544
+ }
545
+ console.log("[CLINIC] Admin permissions verified");
546
+ } catch (adminError) {
547
+ console.error("[CLINIC] Error verifying admin permissions:", adminError);
548
+ throw adminError;
549
+ }
550
+
551
+ // Process photos in the update data
552
+ let updatedData = { ...data };
553
+
554
+ // Handle logo update if provided
555
+ if (
556
+ data.logo &&
557
+ typeof data.logo === "string" &&
558
+ data.logo.startsWith("data:")
559
+ ) {
560
+ console.log("[CLINIC] Processing logo update");
561
+ try {
562
+ const logoUrl = await uploadPhoto(
563
+ data.logo,
564
+ "clinics",
565
+ clinicId,
566
+ "logo",
567
+ app
568
+ );
569
+ console.log("[CLINIC] Logo update processed", { logoUrl });
570
+
571
+ if (logoUrl !== null) {
572
+ updatedData.logo = logoUrl;
573
+ }
574
+ } catch (logoError) {
575
+ console.error("[CLINIC] Error processing logo update:", logoError);
576
+ // Continue with update even if logo upload fails
577
+ }
578
+ }
579
+
580
+ // Handle cover photo update if provided
581
+ if (data.coverPhoto) {
582
+ console.log("[CLINIC] Processing cover photo update");
583
+ try {
584
+ // Check if it's a data URL that needs to be uploaded
585
+ if (
586
+ typeof data.coverPhoto === "string" &&
587
+ data.coverPhoto.startsWith("data:")
588
+ ) {
589
+ const uploadedPhoto = await uploadPhoto(
590
+ data.coverPhoto,
591
+ "clinics",
592
+ clinicId,
593
+ "cover",
594
+ app
595
+ );
596
+ if (uploadedPhoto) {
597
+ updatedData.coverPhoto = uploadedPhoto;
598
+ }
599
+ } else {
600
+ // Keep existing photo URL
601
+ updatedData.coverPhoto = data.coverPhoto;
602
+ }
603
+ console.log("[CLINIC] Cover photo update processed");
604
+ } catch (photoError) {
605
+ console.error(
606
+ "[CLINIC] Error processing cover photo update:",
607
+ photoError
608
+ );
609
+ // Keep existing photo if upload fails
610
+ if (
611
+ typeof data.coverPhoto === "string" &&
612
+ !data.coverPhoto.startsWith("data:")
613
+ ) {
614
+ updatedData.coverPhoto = data.coverPhoto;
615
+ }
616
+ }
617
+ }
618
+
619
+ // Handle featured photos update if provided
620
+ if (data.featuredPhotos && data.featuredPhotos.length > 0) {
621
+ console.log("[CLINIC] Processing featured photos update");
622
+ try {
623
+ // Filter out only data URLs that need to be uploaded
624
+ const allPhotos = data.featuredPhotos as string[];
625
+ const dataUrlPhotos = allPhotos.filter(
626
+ (photo) => typeof photo === "string" && photo.startsWith("data:")
627
+ );
628
+ const existingPhotos = allPhotos.filter(
629
+ (photo) => typeof photo === "string" && !photo.startsWith("data:")
630
+ );
631
+
632
+ if (dataUrlPhotos.length > 0) {
633
+ const uploadedPhotos = await uploadMultiplePhotos(
634
+ dataUrlPhotos,
635
+ "clinics",
636
+ clinicId,
637
+ "featured",
638
+ app
639
+ );
640
+ console.log("[CLINIC] Featured photos update processed", {
641
+ count: uploadedPhotos.length,
642
+ });
643
+
644
+ // Combine existing photos with newly uploaded ones
645
+ updatedData.featuredPhotos = [...existingPhotos, ...uploadedPhotos] as any;
646
+ } else {
647
+ // If no new photos to upload, just use the existing ones
648
+ updatedData.featuredPhotos = existingPhotos as any;
649
+ }
650
+ } catch (featuredError) {
651
+ console.error(
652
+ "[CLINIC] Error processing featured photos update:",
653
+ featuredError
654
+ );
655
+ // Continue with update even if featured photos upload fails
656
+ updatedData.featuredPhotos = (data.featuredPhotos as string[]).filter(
657
+ (photo) => typeof photo === "string" && !photo.startsWith("data:")
658
+ ) as any;
659
+ }
660
+ }
661
+
662
+ // Handle photos with tags update if provided
663
+ if (data.photosWithTags && data.photosWithTags.length > 0) {
664
+ console.log("[CLINIC] Processing photos with tags update");
665
+ try {
666
+ const updatedPhotosWithTags: any[] = [];
667
+
668
+ // Process each photo with tag
669
+ for (const photoWithTag of data.photosWithTags) {
670
+ const photoUrl = photoWithTag.url as string;
671
+ if (photoUrl && photoUrl.startsWith("data:")) {
672
+ // Upload new photo
673
+ const uploadedUrl = await uploadPhoto(
674
+ photoUrl,
675
+ "clinics",
676
+ clinicId,
677
+ `tagged-${photoWithTag.tag}`,
678
+ app
679
+ );
680
+
681
+ if (uploadedUrl) {
682
+ updatedPhotosWithTags.push({
683
+ url: uploadedUrl,
684
+ tag: photoWithTag.tag,
685
+ });
686
+ }
687
+ } else {
688
+ // Keep existing photo
689
+ updatedPhotosWithTags.push(photoWithTag);
690
+ }
691
+ }
692
+
693
+ updatedData.photosWithTags = updatedPhotosWithTags;
694
+ console.log("[CLINIC] Photos with tags update processed", {
695
+ count: updatedPhotosWithTags.length,
696
+ });
697
+ } catch (tagsError) {
698
+ console.error(
699
+ "[CLINIC] Error processing photos with tags update:",
700
+ tagsError
701
+ );
702
+ // Continue with update even if photos with tags upload fails
703
+ updatedData.photosWithTags = data.photosWithTags.filter(
704
+ (photo) => !(photo.url as string).startsWith("data:")
705
+ );
706
+ }
707
+ }
708
+
709
+ // Add timestamp
710
+ updatedData = {
711
+ ...updatedData,
712
+ updatedAt: Timestamp.now(),
713
+ };
714
+
715
+ console.log("[CLINIC] Updating clinic in Firestore");
716
+ try {
717
+ await updateDoc(doc(db, CLINICS_COLLECTION, clinicId), updatedData);
718
+ console.log("[CLINIC] Clinic updated successfully");
719
+ } catch (updateError) {
720
+ console.error("[CLINIC] Error updating clinic in Firestore:", updateError);
721
+ throw updateError;
722
+ }
723
+
724
+ // Return updated data
725
+ const updatedClinic = await getClinic(db, clinicId);
726
+ if (!updatedClinic) {
727
+ console.error("[CLINIC] Failed to retrieve updated clinic");
728
+ throw new Error("Failed to retrieve updated clinic");
729
+ }
730
+
731
+ console.log("[CLINIC] Clinic update completed successfully");
732
+ return updatedClinic;
733
+ }
734
+
735
+ /**
736
+ * Deactivates a clinic
737
+ * @param db - Firestore database instance
738
+ * @param clinicId - ID of the clinic to deactivate
739
+ * @param adminId - ID of the admin making the deactivation
740
+ * @param clinicAdminService - Service for clinic admin operations
741
+ */
742
+ export async function deactivateClinic(
743
+ db: Firestore,
744
+ clinicId: string,
745
+ adminId: string,
746
+ clinicAdminService: any
747
+ ): Promise<void> {
748
+ const clinic = await getClinic(db, clinicId);
749
+ if (!clinic) {
750
+ throw new Error("Clinic not found");
751
+ }
752
+
753
+ // Proveravamo da li admin ima prava da deaktivira kliniku
754
+ const admin = await clinicAdminService.getClinicAdmin(adminId);
755
+ if (!admin) {
756
+ throw new Error("Admin not found");
757
+ }
758
+
759
+ // Check if admin is either:
760
+ // 1. The owner of the clinic group, OR
761
+ // 2. Has this clinic in their managed clinics list, OR
762
+ // 3. Is listed in the clinic's admins array
763
+ const hasPermission =
764
+ (admin.isGroupOwner && admin.clinicGroupId === clinic.clinicGroupId) ||
765
+ (admin.clinicsManaged.includes(clinicId) &&
766
+ clinic.admins &&
767
+ clinic.admins.includes(adminId));
768
+
769
+ if (!hasPermission) {
770
+ throw new Error("Admin does not have permission to deactivate this clinic");
771
+ }
772
+
773
+ await updateDoc(doc(db, CLINICS_COLLECTION, clinicId), {
774
+ isActive: false,
775
+ updatedAt: Timestamp.now(),
776
+ });
777
+ }
778
+
779
+ /**
780
+ * Gets clinics managed by an admin
781
+ * @param db - Firestore database instance
782
+ * @param adminId - ID of the admin
783
+ * @param options - Options for filtering clinics
784
+ * @param clinicAdminService - Service for clinic admin operations
785
+ * @param clinicGroupService - Service for clinic group operations
786
+ * @returns Array of clinics managed by the admin
787
+ */
788
+ export async function getClinicsByAdmin(
789
+ db: Firestore,
790
+ adminId: string,
791
+ options: {
792
+ isActive?: boolean;
793
+ includeGroupClinics?: boolean;
794
+ } = {},
795
+ clinicAdminService: any,
796
+ clinicGroupService: any
797
+ ): Promise<Clinic[]> {
798
+ const admin = await clinicAdminService.getClinicAdmin(adminId);
799
+ if (!admin) {
800
+ throw new Error("Admin not found");
801
+ }
802
+
803
+ let clinicIds: string[] = [...admin.clinicsManaged];
804
+
805
+ // Ako je admin vlasnik grupe i tražimo sve klinike grupe
806
+ if (admin.isGroupOwner && options.includeGroupClinics) {
807
+ const group = await clinicGroupService.getClinicGroup(admin.clinicGroupId);
808
+ if (group) {
809
+ clinicIds = [...new Set([...clinicIds, ...group.clinics])];
810
+ }
811
+ }
812
+
813
+ if (clinicIds.length === 0) {
814
+ return [];
815
+ }
816
+
817
+ const constraints: QueryConstraint[] = [where("id", "in", clinicIds)];
818
+
819
+ if (options.isActive !== undefined) {
820
+ constraints.push(where("isActive", "==", options.isActive));
821
+ }
822
+
823
+ const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
824
+ const querySnapshot = await getDocs(q);
825
+
826
+ return querySnapshot.docs.map((doc) => doc.data() as Clinic);
827
+ }
828
+
829
+ /**
830
+ * Gets active clinics managed by an admin
831
+ * @param db - Firestore database instance
832
+ * @param adminId - ID of the admin
833
+ * @param clinicAdminService - Service for clinic admin operations
834
+ * @param clinicGroupService - Service for clinic group operations
835
+ * @returns Array of active clinics managed by the admin
836
+ */
837
+ export async function getActiveClinicsByAdmin(
838
+ db: Firestore,
839
+ adminId: string,
840
+ clinicAdminService: any,
841
+ clinicGroupService: any
842
+ ): Promise<Clinic[]> {
843
+ return getClinicsByAdmin(
844
+ db,
845
+ adminId,
846
+ { isActive: true },
847
+ clinicAdminService,
848
+ clinicGroupService
849
+ );
850
+ }
851
+
852
+ /**
853
+ * Retrieves a clinic by its ID
854
+ *
855
+ * @param db - Firestore database instance
856
+ * @param clinicId - ID of the clinic to retrieve
857
+ * @returns The clinic if found, null otherwise
858
+ */
859
+ export async function getClinicById(
860
+ db: Firestore,
861
+ clinicId: string,
862
+ excludeDraftPractitioners: boolean = false
863
+ ): Promise<Clinic | null> {
864
+ try {
865
+ const clinicRef = doc(db, CLINICS_COLLECTION, clinicId);
866
+ const clinicSnapshot = await getDoc(clinicRef);
867
+
868
+ if (!clinicSnapshot.exists()) {
869
+ return null;
870
+ }
871
+
872
+ const clinicData = clinicSnapshot.data() as Clinic;
873
+ const clinicWithId = {
874
+ ...clinicData,
875
+ id: clinicSnapshot.id,
876
+ };
877
+ return filterClinicEmbeddedArrays(clinicWithId, excludeDraftPractitioners);
878
+ } catch (error) {
879
+ console.error("[CLINIC_UTILS] Error getting clinic by ID:", error);
880
+ throw error;
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Retrieves all clinics with optional pagination
886
+ *
887
+ * @param db - Firestore database instance
888
+ * @param pagination - Optional number of clinics per page (0 or undefined returns all)
889
+ * @param lastDoc - Optional last document for pagination (if continuing from a previous page)
890
+ * @returns Array of clinics and the last document for pagination
891
+ */
892
+ export async function getAllClinics(
893
+ db: Firestore,
894
+ pagination?: number,
895
+ lastDoc?: any,
896
+ excludeDraftPractitioners: boolean = false
897
+ ): Promise<{ clinics: Clinic[]; lastDoc: any }> {
898
+ try {
899
+ const clinicsCollection = collection(db, CLINICS_COLLECTION);
900
+ let clinicsQuery = query(clinicsCollection);
901
+
902
+ // If pagination is specified and greater than 0, limit the query
903
+ if (pagination && pagination > 0) {
904
+ if (lastDoc) {
905
+ clinicsQuery = query(
906
+ clinicsCollection,
907
+ startAfter(lastDoc),
908
+ limit(pagination)
909
+ );
910
+ } else {
911
+ clinicsQuery = query(clinicsCollection, limit(pagination));
912
+ }
913
+ }
914
+
915
+ const clinicsSnapshot = await getDocs(clinicsQuery);
916
+ const lastVisible = clinicsSnapshot.docs[clinicsSnapshot.docs.length - 1];
917
+
918
+ const clinics = clinicsSnapshot.docs.map((doc) => {
919
+ const data = doc.data() as Clinic;
920
+ const clinicWithId = {
921
+ ...data,
922
+ id: doc.id,
923
+ };
924
+ return filterClinicEmbeddedArrays(clinicWithId, excludeDraftPractitioners);
925
+ });
926
+
927
+ return {
928
+ clinics,
929
+ lastDoc: lastVisible,
930
+ };
931
+ } catch (error) {
932
+ console.error("[CLINIC_UTILS] Error getting all clinics:", error);
933
+ throw error;
934
+ }
935
+ }
936
+
937
+ /**
938
+ * Retrieves all clinics within a specified range from a location with optional pagination
939
+ *
940
+ * @param db - Firestore database instance
941
+ * @param center - The center location coordinates {latitude, longitude}
942
+ * @param rangeInKm - The range in kilometers to search within
943
+ * @param pagination - Optional number of clinics per page (0 or undefined returns all)
944
+ * @param lastDoc - Optional last document for pagination (if continuing from a previous page)
945
+ * @returns Array of clinics with distance information and the last document for pagination
946
+ */
947
+ export async function getAllClinicsInRange(
948
+ db: Firestore,
949
+ center: { latitude: number; longitude: number },
950
+ rangeInKm: number,
951
+ pagination?: number,
952
+ lastDoc?: any,
953
+ excludeDraftPractitioners: boolean = false
954
+ ): Promise<{ clinics: (Clinic & { distance: number })[]; lastDoc: any }> {
955
+ const bounds = geohashQueryBounds(
956
+ [center.latitude, center.longitude],
957
+ rangeInKm * 1000
958
+ );
959
+
960
+ const matchingClinics: (Clinic & { distance: number })[] = [];
961
+ let lastDocSnapshot = null;
962
+
963
+ for (const b of bounds) {
964
+ const constraints: QueryConstraint[] = [
965
+ where("location.geohash", ">=", b[0]),
966
+ where("location.geohash", "<=", b[1]),
967
+ where("isActive", "==", true),
968
+ ];
969
+
970
+ const q = query(collection(db, CLINICS_COLLECTION), ...constraints);
971
+ const querySnapshot = await getDocs(q);
972
+
973
+ for (const doc of querySnapshot.docs) {
974
+ const clinic = doc.data() as Clinic;
975
+ const distance = distanceBetween(
976
+ [center.latitude, center.longitude],
977
+ [clinic.location.latitude, clinic.location.longitude]
978
+ );
979
+
980
+ const distanceInKm = distance; // distanceBetween already returns km
981
+
982
+ if (distanceInKm <= rangeInKm) {
983
+ const filteredClinic = filterClinicEmbeddedArrays(clinic, excludeDraftPractitioners);
984
+ matchingClinics.push({
985
+ ...filteredClinic,
986
+ distance: distanceInKm,
987
+ });
988
+ }
989
+ }
990
+ }
991
+
992
+ // Sort by distance
993
+ matchingClinics.sort((a, b) => a.distance - b.distance);
994
+
995
+ if (pagination && pagination > 0) {
996
+ // Paginate results
997
+ let result = matchingClinics;
998
+ if (lastDoc && matchingClinics.length > 0) {
999
+ const lastIndex = matchingClinics.findIndex(
1000
+ (clinic) => clinic.id === lastDoc.id
1001
+ );
1002
+ if (lastIndex !== -1) {
1003
+ result = matchingClinics.slice(lastIndex + 1);
1004
+ }
1005
+ }
1006
+
1007
+ const paginatedClinics = result.slice(0, pagination);
1008
+ const newLastDoc =
1009
+ paginatedClinics.length > 0
1010
+ ? paginatedClinics[paginatedClinics.length - 1]
1011
+ : null;
1012
+
1013
+ return {
1014
+ clinics: paginatedClinics,
1015
+ lastDoc: newLastDoc,
1016
+ };
1017
+ }
1018
+
1019
+ return {
1020
+ clinics: matchingClinics,
1021
+ lastDoc: null,
1022
+ };
1023
+ }