@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,1693 +1,1693 @@
1
- import { Auth } from "firebase/auth";
2
- import { Firestore, Timestamp, serverTimestamp } from "firebase/firestore";
3
- import { FirebaseApp } from "firebase/app";
4
- import { BaseService } from "../base.service";
5
- import {
6
- CalendarEvent,
7
- CalendarEventStatus,
8
- CalendarEventTime,
9
- CalendarEventType,
10
- CalendarSyncStatus,
11
- CreateCalendarEventData,
12
- UpdateCalendarEventData,
13
- CALENDAR_COLLECTION,
14
- SyncedCalendarEvent,
15
- ProcedureInfo,
16
- TimeSlot,
17
- CreateAppointmentParams,
18
- UpdateAppointmentParams,
19
- SearchCalendarEventsParams,
20
- SearchLocationEnum,
21
- DateRange,
22
- } from "../../types/calendar";
23
- import {
24
- PRACTITIONERS_COLLECTION,
25
- PractitionerClinicWorkingHours,
26
- } from "../../types/practitioner";
27
- import {
28
- PATIENTS_COLLECTION,
29
- Gender,
30
- PATIENT_SENSITIVE_INFO_COLLECTION,
31
- } from "../../types/patient";
32
- import { CLINICS_COLLECTION } from "../../types/clinic";
33
- import { SyncedCalendarProvider } from "../../types/calendar/synced-calendar.types";
34
- import {
35
- ClinicInfo,
36
- PatientProfileInfo,
37
- PractitionerProfileInfo,
38
- } from "../../types/profile";
39
- import {
40
- doc,
41
- getDoc,
42
- collection,
43
- query,
44
- where,
45
- getDocs,
46
- setDoc,
47
- updateDoc,
48
- QueryConstraint,
49
- CollectionReference,
50
- DocumentData,
51
- } from "firebase/firestore";
52
- import {
53
- createAppointmentSchema,
54
- updateAppointmentSchema,
55
- } from "../../validations/appointment.schema";
56
-
57
- // Import utility functions
58
- import {
59
- createAppointmentUtil,
60
- updateAppointmentUtil,
61
- deleteAppointmentUtil,
62
- } from "./utils/appointment.utils";
63
- import { searchCalendarEventsUtil } from "./utils/calendar-event.utils";
64
- import { SyncedCalendarsService } from "./synced-calendars.service";
65
- import { Timestamp as FirestoreTimestamp } from "firebase-admin/firestore";
66
-
67
- /**
68
- * Minimum appointment duration in minutes
69
- */
70
- const MIN_APPOINTMENT_DURATION = 15;
71
-
72
- /**
73
- * Refactored Calendar Service
74
- * Provides streamlined calendar management with proper access control and scheduling rules
75
- */
76
- export class CalendarServiceV2 extends BaseService {
77
- private syncedCalendarsService: SyncedCalendarsService;
78
-
79
- /**
80
- * Creates a new CalendarService instance
81
- * @param db - Firestore instance
82
- * @param auth - Firebase Auth instance
83
- * @param app - Firebase App instance
84
- */
85
- constructor(db: Firestore, auth: Auth, app: FirebaseApp) {
86
- super(db, auth, app);
87
- this.syncedCalendarsService = new SyncedCalendarsService(db, auth, app);
88
- }
89
-
90
- // #region Public API Methods
91
-
92
- /**
93
- * Creates a new appointment with proper validation and scheduling rules
94
- * @param params - Appointment creation parameters
95
- * @returns Created calendar event
96
- */
97
- async createAppointment(
98
- params: CreateAppointmentParams
99
- ): Promise<CalendarEvent> {
100
- // Validate input parameters
101
- await this.validateAppointmentParams(params);
102
-
103
- // Check clinic working hours
104
- await this.validateClinicWorkingHours(params.clinicId, params.eventTime);
105
-
106
- // Check doctor availability
107
- await this.validateDoctorAvailability(
108
- params.doctorId,
109
- params.eventTime,
110
- params.clinicId
111
- );
112
-
113
- // Fetch profile info cards
114
- const { clinicInfo, practitionerInfo, patientInfo } =
115
- await this.fetchProfileInfoCards(
116
- params.clinicId,
117
- params.doctorId,
118
- params.patientId
119
- );
120
-
121
- // Create the appointment
122
- const appointmentData: Omit<
123
- CreateCalendarEventData,
124
- "id" | "createdAt" | "updatedAt"
125
- > = {
126
- clinicBranchId: params.clinicId,
127
- clinicBranchInfo: clinicInfo,
128
- practitionerProfileId: params.doctorId,
129
- practitionerProfileInfo: practitionerInfo,
130
- patientProfileId: params.patientId,
131
- patientProfileInfo: patientInfo,
132
- procedureId: params.procedureId,
133
- eventLocation: params.eventLocation,
134
- eventName: "Appointment", // TODO: Add procedure name when procedure model is available
135
- eventTime: params.eventTime,
136
- description: params.description || "",
137
- status: CalendarEventStatus.PENDING,
138
- syncStatus: CalendarSyncStatus.INTERNAL,
139
- eventType: CalendarEventType.APPOINTMENT,
140
- };
141
-
142
- const appointment = await createAppointmentUtil(
143
- this.db,
144
- params.clinicId,
145
- params.doctorId,
146
- params.patientId,
147
- appointmentData,
148
- this.generateId.bind(this)
149
- );
150
-
151
- // Sync with external calendars if needed
152
- await this.syncAppointmentWithExternalCalendars(appointment);
153
-
154
- return appointment;
155
- }
156
-
157
- /**
158
- * Updates an existing appointment
159
- * @param params - Appointment update parameters
160
- * @returns Updated calendar event
161
- */
162
- async updateAppointment(
163
- params: UpdateAppointmentParams
164
- ): Promise<CalendarEvent> {
165
- // Validate permissions
166
- await this.validateUpdatePermissions(params);
167
-
168
- const updateData: Omit<UpdateCalendarEventData, "updatedAt"> = {
169
- eventTime: params.eventTime,
170
- description: params.description,
171
- status: params.status,
172
- };
173
-
174
- const appointment = await updateAppointmentUtil(
175
- this.db,
176
- params.clinicId,
177
- params.doctorId,
178
- params.patientId,
179
- params.appointmentId,
180
- updateData
181
- );
182
-
183
- // Sync with external calendars if needed
184
- await this.syncAppointmentWithExternalCalendars(appointment);
185
-
186
- return appointment;
187
- }
188
-
189
- /**
190
- * Gets available appointment slots for a doctor at a clinic
191
- * @param clinicId - ID of the clinic
192
- * @param doctorId - ID of the doctor
193
- * @param date - Date to check availability for
194
- * @returns Array of available time slots
195
- */
196
- async getAvailableSlots(
197
- clinicId: string,
198
- doctorId: string,
199
- date: Date
200
- ): Promise<TimeSlot[]> {
201
- // Get clinic working hours
202
- const workingHours = await this.getClinicWorkingHours(clinicId, date);
203
-
204
- // Get doctor's schedule
205
- const doctorSchedule = await this.getDoctorSchedule(doctorId, date);
206
-
207
- // Get existing appointments
208
- const existingAppointments = await this.getDoctorAppointments(
209
- doctorId,
210
- date
211
- );
212
-
213
- // Calculate available slots
214
- return this.calculateAvailableSlots(
215
- workingHours,
216
- doctorSchedule,
217
- existingAppointments
218
- );
219
- }
220
-
221
- /**
222
- * Confirms an appointment
223
- * @param appointmentId - ID of the appointment
224
- * @param clinicId - ID of the clinic
225
- * @returns Confirmed calendar event
226
- */
227
- async confirmAppointment(
228
- appointmentId: string,
229
- clinicId: string
230
- ): Promise<CalendarEvent> {
231
- return this.updateAppointmentStatus(
232
- appointmentId,
233
- clinicId,
234
- CalendarEventStatus.CONFIRMED
235
- );
236
- }
237
-
238
- /**
239
- * Rejects an appointment
240
- * @param appointmentId - ID of the appointment
241
- * @param clinicId - ID of the clinic
242
- * @returns Rejected calendar event
243
- */
244
- async rejectAppointment(
245
- appointmentId: string,
246
- clinicId: string
247
- ): Promise<CalendarEvent> {
248
- return this.updateAppointmentStatus(
249
- appointmentId,
250
- clinicId,
251
- CalendarEventStatus.REJECTED
252
- );
253
- }
254
-
255
- /**
256
- * Cancels an appointment
257
- * @param appointmentId - ID of the appointment
258
- * @param clinicId - ID of the clinic
259
- * @returns Canceled calendar event
260
- */
261
- async cancelAppointment(
262
- appointmentId: string,
263
- clinicId: string
264
- ): Promise<CalendarEvent> {
265
- return this.updateAppointmentStatus(
266
- appointmentId,
267
- clinicId,
268
- CalendarEventStatus.CANCELED
269
- );
270
- }
271
-
272
- /**
273
- * Imports events from external calendars
274
- * @param entityType - Type of entity (practitioner or patient)
275
- * @param entityId - ID of the entity
276
- * @param startDate - Start date for fetching events
277
- * @param endDate - End date for fetching events
278
- * @returns Number of events imported
279
- */
280
- async importEventsFromExternalCalendars(
281
- entityType: "doctor" | "patient",
282
- entityId: string,
283
- startDate: Date,
284
- endDate: Date
285
- ): Promise<number> {
286
- // Only practitioners (doctors) should sync two-way
287
- // Patients only sync outwards (from our system to external calendars)
288
- if (entityType === "patient") {
289
- return 0;
290
- }
291
-
292
- // For doctors, get their synced calendars
293
- const syncedCalendars =
294
- await this.syncedCalendarsService.getPractitionerSyncedCalendars(
295
- entityId
296
- );
297
-
298
- // Filter active calendars
299
- const activeCalendars = syncedCalendars.filter((cal) => cal.isActive);
300
-
301
- if (activeCalendars.length === 0) {
302
- return 0;
303
- }
304
-
305
- let importedEventsCount = 0;
306
- const currentTime = Timestamp.now();
307
-
308
- // Import from each calendar
309
- for (const calendar of activeCalendars) {
310
- try {
311
- let externalEvents: any[] = [];
312
-
313
- // Fetch events based on provider and entity type
314
- if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
315
- externalEvents =
316
- await this.syncedCalendarsService.fetchEventsFromPractitionerGoogleCalendar(
317
- entityId,
318
- calendar.id,
319
- startDate,
320
- endDate
321
- );
322
- }
323
- // Add other providers as needed
324
-
325
- // Process and import each event
326
- for (const externalEvent of externalEvents) {
327
- try {
328
- // Convert the external event to our format
329
- const convertedEvent =
330
- this.syncedCalendarsService.convertGoogleEventsToPractitionerEvents(
331
- entityId,
332
- [externalEvent]
333
- )[0];
334
-
335
- // Skip events without valid time data
336
- if (!convertedEvent.eventTime) {
337
- continue;
338
- }
339
-
340
- // Create event data from external event
341
- const eventData: Omit<
342
- CreateCalendarEventData,
343
- "id" | "createdAt" | "updatedAt"
344
- > = {
345
- // Ensure all required fields are set
346
- eventName: convertedEvent.eventName || "External Event",
347
- eventTime: convertedEvent.eventTime,
348
- description: convertedEvent.description || "",
349
- status: CalendarEventStatus.CONFIRMED,
350
- syncStatus: CalendarSyncStatus.EXTERNAL,
351
- eventType: CalendarEventType.BLOCKING,
352
- practitionerProfileId: entityId,
353
- syncedCalendarEventId: [
354
- {
355
- eventId: externalEvent.id,
356
- syncedCalendarProvider: calendar.provider,
357
- syncedAt: currentTime,
358
- },
359
- ],
360
- };
361
-
362
- // Create the event in the doctor's calendar
363
- const doctorEvent = await this.createDoctorBlockingEvent(
364
- entityId,
365
- eventData
366
- );
367
-
368
- if (doctorEvent) {
369
- importedEventsCount++;
370
- }
371
- } catch (eventError) {
372
- console.error("Error importing event:", eventError);
373
- // Continue with other events even if one fails
374
- }
375
- }
376
- } catch (calendarError) {
377
- console.error(
378
- `Error fetching events from calendar ${calendar.id}:`,
379
- calendarError
380
- );
381
- // Continue with other calendars even if one fails
382
- }
383
- }
384
-
385
- return importedEventsCount;
386
- }
387
-
388
- /**
389
- * Creates a blocking event in a doctor's calendar
390
- * @param doctorId - ID of the doctor
391
- * @param eventData - Calendar event data
392
- * @returns Created calendar event
393
- */
394
- private async createDoctorBlockingEvent(
395
- doctorId: string,
396
- eventData: Omit<CreateCalendarEventData, "id" | "createdAt" | "updatedAt">
397
- ): Promise<CalendarEvent | null> {
398
- try {
399
- // Generate a unique ID for the event
400
- const eventId = this.generateId();
401
-
402
- // Create the event document reference
403
- const eventRef = doc(
404
- this.db,
405
- PRACTITIONERS_COLLECTION,
406
- doctorId,
407
- CALENDAR_COLLECTION,
408
- eventId
409
- );
410
-
411
- // Prepare the event data
412
- const newEvent: CreateCalendarEventData = {
413
- id: eventId,
414
- ...eventData,
415
- createdAt: serverTimestamp(),
416
- updatedAt: serverTimestamp(),
417
- };
418
-
419
- // Set the document
420
- await setDoc(eventRef, newEvent);
421
-
422
- // Return the event
423
- return {
424
- ...newEvent,
425
- createdAt: Timestamp.now(),
426
- updatedAt: Timestamp.now(),
427
- } as CalendarEvent;
428
- } catch (error) {
429
- console.error(
430
- `Error creating blocking event for doctor ${doctorId}:`,
431
- error
432
- );
433
- return null;
434
- }
435
- }
436
-
437
- /**
438
- * Periodically syncs events from external calendars for doctors
439
- * This would be called via a scheduled Cloud Function
440
- * @param lookbackDays - Number of days to look back for events
441
- * @param lookforwardDays - Number of days to look forward for events
442
- */
443
- async synchronizeExternalCalendars(
444
- lookbackDays: number = 7,
445
- lookforwardDays: number = 30
446
- ): Promise<void> {
447
- try {
448
- // Get all doctors who have active synced calendars
449
- const practitionersRef = collection(this.db, PRACTITIONERS_COLLECTION);
450
- const practitionersSnapshot = await getDocs(practitionersRef);
451
-
452
- // Prepare date range
453
- const startDate = new Date();
454
- startDate.setDate(startDate.getDate() - lookbackDays);
455
-
456
- const endDate = new Date();
457
- endDate.setDate(endDate.getDate() + lookforwardDays);
458
-
459
- // For each doctor, check their synced calendars
460
- const syncPromises = [];
461
- for (const docSnapshot of practitionersSnapshot.docs) {
462
- const practitionerId = docSnapshot.id;
463
-
464
- // Import events from external calendars
465
- syncPromises.push(
466
- this.importEventsFromExternalCalendars(
467
- "doctor",
468
- practitionerId,
469
- startDate,
470
- endDate
471
- )
472
- .then((count) => {
473
- console.log(
474
- `Imported ${count} events for doctor ${practitionerId}`
475
- );
476
- })
477
- .catch((error) => {
478
- console.error(
479
- `Error importing events for doctor ${practitionerId}:`,
480
- error
481
- );
482
- })
483
- );
484
-
485
- // Also update existing events that might have changed
486
- syncPromises.push(
487
- this.updateExistingEventsFromExternalCalendars(
488
- practitionerId,
489
- startDate,
490
- endDate
491
- )
492
- .then((count) => {
493
- console.log(
494
- `Updated ${count} events for doctor ${practitionerId}`
495
- );
496
- })
497
- .catch((error) => {
498
- console.error(
499
- `Error updating events for doctor ${practitionerId}:`,
500
- error
501
- );
502
- })
503
- );
504
- }
505
-
506
- // Wait for all sync operations to complete
507
- await Promise.all(syncPromises);
508
- console.log("Completed external calendar synchronization");
509
- } catch (error) {
510
- console.error("Error synchronizing external calendars:", error);
511
- }
512
- }
513
-
514
- /**
515
- * Updates existing events that were synced from external calendars
516
- * @param doctorId - ID of the doctor
517
- * @param startDate - Start date for fetching events
518
- * @param endDate - End date for fetching events
519
- * @returns Number of events updated
520
- */
521
- private async updateExistingEventsFromExternalCalendars(
522
- doctorId: string,
523
- startDate: Date,
524
- endDate: Date
525
- ): Promise<number> {
526
- try {
527
- // Get all EXTERNAL events for this doctor within the date range
528
- const eventsRef = collection(
529
- this.db,
530
- PRACTITIONERS_COLLECTION,
531
- doctorId,
532
- CALENDAR_COLLECTION
533
- );
534
- const q = query(
535
- eventsRef,
536
- where("syncStatus", "==", CalendarSyncStatus.EXTERNAL),
537
- where("eventTime.start", ">=", Timestamp.fromDate(startDate)),
538
- where("eventTime.start", "<=", Timestamp.fromDate(endDate))
539
- );
540
-
541
- const eventsSnapshot = await getDocs(q);
542
- const events = eventsSnapshot.docs.map((doc) => ({
543
- id: doc.id,
544
- ...doc.data(),
545
- })) as CalendarEvent[];
546
-
547
- // Get the doctor's synced calendars
548
- const calendars =
549
- await this.syncedCalendarsService.getPractitionerSyncedCalendars(
550
- doctorId
551
- );
552
- const activeCalendars = calendars.filter((cal) => cal.isActive);
553
-
554
- if (activeCalendars.length === 0 || events.length === 0) {
555
- return 0;
556
- }
557
-
558
- let updatedCount = 0;
559
-
560
- // For each external event, check if it needs updating
561
- for (const event of events) {
562
- // Skip events without sync IDs
563
- if (!event.syncedCalendarEventId?.length) continue;
564
-
565
- for (const syncId of event.syncedCalendarEventId) {
566
- // Find the calendar for this sync ID
567
- const calendar = activeCalendars.find(
568
- (cal) => cal.provider === syncId.syncedCalendarProvider
569
- );
570
- if (!calendar) continue;
571
-
572
- // Check if the event exists and needs updating
573
- if (syncId.syncedCalendarProvider === SyncedCalendarProvider.GOOGLE) {
574
- try {
575
- // Fetch the external event
576
- const externalEvent = await this.fetchExternalEvent(
577
- doctorId,
578
- calendar,
579
- syncId.eventId
580
- );
581
-
582
- // If the event was found, check if it's different from our local copy
583
- if (externalEvent) {
584
- // Compare basic properties (time, title, description)
585
- const externalStartTime = new Date(
586
- externalEvent.start.dateTime || externalEvent.start.date
587
- ).getTime();
588
- const externalEndTime = new Date(
589
- externalEvent.end.dateTime || externalEvent.end.date
590
- ).getTime();
591
- const localStartTime = event.eventTime.start.toDate().getTime();
592
- const localEndTime = event.eventTime.end.toDate().getTime();
593
-
594
- // If times or title/description have changed, update our local copy
595
- if (
596
- externalStartTime !== localStartTime ||
597
- externalEndTime !== localEndTime ||
598
- externalEvent.summary !== event.eventName ||
599
- externalEvent.description !== event.description
600
- ) {
601
- // Update our local copy
602
- await this.updateLocalEventFromExternal(
603
- doctorId,
604
- event.id,
605
- externalEvent
606
- );
607
- updatedCount++;
608
- }
609
- } else {
610
- // The event was deleted in the external calendar, mark it as canceled
611
- await this.updateEventStatus(
612
- doctorId,
613
- event.id,
614
- CalendarEventStatus.CANCELED
615
- );
616
- updatedCount++;
617
- }
618
- } catch (error) {
619
- console.error(
620
- `Error updating external event ${event.id}:`,
621
- error
622
- );
623
- }
624
- }
625
- }
626
- }
627
-
628
- return updatedCount;
629
- } catch (error) {
630
- console.error(
631
- "Error updating existing events from external calendars:",
632
- error
633
- );
634
- return 0;
635
- }
636
- }
637
-
638
- /**
639
- * Fetches a single external event from Google Calendar
640
- * @param doctorId - ID of the doctor
641
- * @param calendar - Calendar information
642
- * @param externalEventId - ID of the external event
643
- * @returns External event data or null if not found
644
- */
645
- private async fetchExternalEvent(
646
- doctorId: string,
647
- calendar: any,
648
- externalEventId: string
649
- ): Promise<any | null> {
650
- try {
651
- if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
652
- // Refresh token if needed
653
- // We're using the syncPractitionerEventsToGoogleCalendar to get the calendar with a refreshed token
654
- const result =
655
- await this.syncedCalendarsService.fetchEventFromPractitionerGoogleCalendar(
656
- doctorId,
657
- calendar.id,
658
- externalEventId
659
- );
660
-
661
- return result;
662
- }
663
- return null;
664
- } catch (error) {
665
- console.error(`Error fetching external event ${externalEventId}:`, error);
666
- return null;
667
- }
668
- }
669
-
670
- /**
671
- * Updates a local event with data from an external event
672
- * @param doctorId - ID of the doctor
673
- * @param eventId - ID of the local event
674
- * @param externalEvent - External event data
675
- */
676
- private async updateLocalEventFromExternal(
677
- doctorId: string,
678
- eventId: string,
679
- externalEvent: any
680
- ): Promise<void> {
681
- try {
682
- // Create event time from external event
683
- const startTime = new Date(
684
- externalEvent.start.dateTime || externalEvent.start.date
685
- );
686
- const endTime = new Date(
687
- externalEvent.end.dateTime || externalEvent.end.date
688
- );
689
-
690
- // Update the local event
691
- const eventRef = doc(
692
- this.db,
693
- PRACTITIONERS_COLLECTION,
694
- doctorId,
695
- CALENDAR_COLLECTION,
696
- eventId
697
- );
698
-
699
- await updateDoc(eventRef, {
700
- eventName: externalEvent.summary || "External Event",
701
- eventTime: {
702
- start: Timestamp.fromDate(startTime),
703
- end: Timestamp.fromDate(endTime),
704
- },
705
- description: externalEvent.description || "",
706
- updatedAt: serverTimestamp(),
707
- });
708
-
709
- console.log(`Updated local event ${eventId} from external event`);
710
- } catch (error) {
711
- console.error(
712
- `Error updating local event ${eventId} from external:`,
713
- error
714
- );
715
- }
716
- }
717
-
718
- /**
719
- * Updates an event's status
720
- * @param doctorId - ID of the doctor
721
- * @param eventId - ID of the event
722
- * @param status - New status
723
- */
724
- private async updateEventStatus(
725
- doctorId: string,
726
- eventId: string,
727
- status: CalendarEventStatus
728
- ): Promise<void> {
729
- try {
730
- const eventRef = doc(
731
- this.db,
732
- PRACTITIONERS_COLLECTION,
733
- doctorId,
734
- CALENDAR_COLLECTION,
735
- eventId
736
- );
737
-
738
- await updateDoc(eventRef, {
739
- status,
740
- updatedAt: serverTimestamp(),
741
- });
742
-
743
- console.log(`Updated event ${eventId} status to ${status}`);
744
- } catch (error) {
745
- console.error(`Error updating event ${eventId} status:`, error);
746
- }
747
- }
748
-
749
- /**
750
- * Creates a scheduled job to periodically sync external calendars
751
- * Note: This would be implemented using Cloud Functions in a real application
752
- * This is a sample implementation to show how it could be set up
753
- * @param interval - Interval in hours
754
- */
755
- createScheduledSyncJob(interval: number = 3): void {
756
- // This is a simplified implementation
757
- // In a real application, you would use Cloud Functions with Pub/Sub
758
- console.log(
759
- `Setting up scheduled calendar sync job every ${interval} hours`
760
- );
761
-
762
- // Example cloud function implementation:
763
- /*
764
- // Using Firebase Cloud Functions (in index.ts)
765
- export const syncExternalCalendars = functions.pubsub
766
- .schedule('every 3 hours')
767
- .onRun(async (context) => {
768
- try {
769
- const db = admin.firestore();
770
- const auth = admin.auth();
771
- const app = admin.app();
772
-
773
- const calendarService = new CalendarServiceV2(db, auth, app);
774
- await calendarService.synchronizeExternalCalendars();
775
-
776
- console.log('External calendar sync completed successfully');
777
- return null;
778
- } catch (error) {
779
- console.error('Error in calendar sync job:', error);
780
- return null;
781
- }
782
- });
783
- */
784
- }
785
-
786
- /**
787
- * Searches for calendar events based on specified criteria.
788
- *
789
- * @param {SearchCalendarEventsParams} params - The search parameters.
790
- * @param {SearchLocationEnum} params.searchLocation - The primary location to search (practitioner, patient, or clinic).
791
- * @param {string} params.entityId - The ID of the entity (practitioner, patient, or clinic) to search within/for.
792
- * @param {string} [params.clinicId] - Optional clinic ID to filter by.
793
- * @param {string} [params.practitionerId] - Optional practitioner ID to filter by.
794
- * @param {string} [params.patientId] - Optional patient ID to filter by.
795
- * @param {string} [params.procedureId] - Optional procedure ID to filter by.
796
- * @param {DateRange} [params.dateRange] - Optional date range to filter by (event start time).
797
- * @param {CalendarEventStatus} [params.eventStatus] - Optional event status to filter by.
798
- * @param {CalendarEventType} [params.eventType] - Optional event type to filter by.
799
- * @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of matching calendar events.
800
- * @throws {Error} If the search location requires an entity ID that is not provided.
801
- */
802
- async searchCalendarEvents(
803
- params: SearchCalendarEventsParams
804
- ): Promise<CalendarEvent[]> {
805
- // Use the utility function to perform the search
806
- return searchCalendarEventsUtil(this.db, params);
807
- }
808
-
809
- /**
810
- * Gets a doctor's upcoming appointments for a specific date range
811
- *
812
- * @param {string} doctorId - ID of the practitioner
813
- * @param {Date} startDate - Start date of the range
814
- * @param {Date} endDate - End date of the range
815
- * @param {CalendarEventStatus} [status] - Optional status filter (defaults to CONFIRMED)
816
- * @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
817
- */
818
- async getPractitionerUpcomingAppointments(
819
- doctorId: string,
820
- startDate: Date,
821
- endDate: Date,
822
- status: CalendarEventStatus = CalendarEventStatus.CONFIRMED
823
- ): Promise<CalendarEvent[]> {
824
- // Create a date range for the query
825
- const dateRange: DateRange = {
826
- start: Timestamp.fromDate(startDate),
827
- end: Timestamp.fromDate(endDate),
828
- };
829
-
830
- // Create the search parameters
831
- const searchParams: SearchCalendarEventsParams = {
832
- searchLocation: SearchLocationEnum.PRACTITIONER,
833
- entityId: doctorId,
834
- dateRange,
835
- eventStatus: status,
836
- eventType: CalendarEventType.APPOINTMENT,
837
- };
838
-
839
- // Search for the appointments
840
- return this.searchCalendarEvents(searchParams);
841
- }
842
-
843
- /**
844
- * Gets a patient's appointments for a specific date range
845
- *
846
- * @param {string} patientId - ID of the patient
847
- * @param {Date} startDate - Start date of the range
848
- * @param {Date} endDate - End date of the range
849
- * @param {CalendarEventStatus} [status] - Optional status filter (defaults to all non-canceled appointments)
850
- * @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
851
- */
852
- async getPatientAppointments(
853
- patientId: string,
854
- startDate: Date,
855
- endDate: Date,
856
- status?: CalendarEventStatus
857
- ): Promise<CalendarEvent[]> {
858
- // Create a date range for the query
859
- const dateRange: DateRange = {
860
- start: Timestamp.fromDate(startDate),
861
- end: Timestamp.fromDate(endDate),
862
- };
863
-
864
- // Create the search parameters
865
- const searchParams: SearchCalendarEventsParams = {
866
- searchLocation: SearchLocationEnum.PATIENT,
867
- entityId: patientId,
868
- dateRange,
869
- eventType: CalendarEventType.APPOINTMENT,
870
- };
871
-
872
- // Add status filter if provided
873
- if (status) {
874
- searchParams.eventStatus = status;
875
- }
876
-
877
- // Search for the appointments
878
- return this.searchCalendarEvents(searchParams);
879
- }
880
-
881
- /**
882
- * Gets all appointments for a clinic within a specific date range
883
- *
884
- * @param {string} clinicId - ID of the clinic
885
- * @param {Date} startDate - Start date of the range
886
- * @param {Date} endDate - End date of the range
887
- * @param {string} [doctorId] - Optional doctor ID to filter by
888
- * @param {CalendarEventStatus} [status] - Optional status filter
889
- * @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
890
- */
891
- async getClinicAppointments(
892
- clinicId: string,
893
- startDate: Date,
894
- endDate: Date,
895
- doctorId?: string,
896
- status?: CalendarEventStatus
897
- ): Promise<CalendarEvent[]> {
898
- // Create a date range for the query
899
- const dateRange: DateRange = {
900
- start: Timestamp.fromDate(startDate),
901
- end: Timestamp.fromDate(endDate),
902
- };
903
-
904
- // Create the search parameters
905
- const searchParams: SearchCalendarEventsParams = {
906
- searchLocation: SearchLocationEnum.CLINIC,
907
- entityId: clinicId,
908
- dateRange,
909
- eventType: CalendarEventType.APPOINTMENT,
910
- };
911
-
912
- // Add doctor filter if provided
913
- if (doctorId) {
914
- searchParams.practitionerId = doctorId;
915
- }
916
-
917
- // Add status filter if provided
918
- if (status) {
919
- searchParams.eventStatus = status;
920
- }
921
-
922
- // Search for the appointments
923
- return this.searchCalendarEvents(searchParams);
924
- }
925
-
926
- // #endregion
927
-
928
- // #region Private Helper Methods
929
-
930
- /**
931
- * Validates appointment creation parameters
932
- * @param params - Appointment parameters to validate
933
- * @throws Error if validation fails
934
- */
935
- private async validateAppointmentParams(
936
- params: CreateAppointmentParams
937
- ): Promise<void> {
938
- // TODO: Add custom validation logic after Zod schema validation
939
- // - Check if doctor works at the clinic
940
- // - Check if procedure is available at the clinic
941
- // - Check if patient is eligible for the procedure
942
- // - Validate time slot (15-minute increments)
943
- // - Check clinic's subscription status
944
- // - Check if auto-confirm is enabled
945
-
946
- // Validate basic parameters using Zod schema
947
- await createAppointmentSchema.parseAsync(params);
948
- }
949
-
950
- /**
951
- * Validates if the event time falls within clinic working hours
952
- * @param clinicId - ID of the clinic
953
- * @param eventTime - Event time to validate
954
- * @throws Error if validation fails
955
- */
956
- private async validateClinicWorkingHours(
957
- clinicId: string,
958
- eventTime: CalendarEventTime
959
- ): Promise<void> {
960
- // Get clinic working hours for the day
961
- const startDate = eventTime.start.toDate();
962
- const workingHours = await this.getClinicWorkingHours(clinicId, startDate);
963
-
964
- if (workingHours.length === 0) {
965
- throw new Error("Clinic is not open on this day");
966
- }
967
-
968
- // Find if the appointment time falls within any working hours slot
969
- const startTime = startDate;
970
- const endTime = eventTime.end.toDate();
971
- const isWithinWorkingHours = workingHours.some((slot) => {
972
- return slot.start <= startTime && slot.end >= endTime && slot.isAvailable;
973
- });
974
-
975
- if (!isWithinWorkingHours) {
976
- throw new Error("Appointment time is outside clinic working hours");
977
- }
978
- }
979
-
980
- /**
981
- * Validates if the doctor is available during the event time
982
- * @param doctorId - ID of the doctor
983
- * @param eventTime - Event time to validate
984
- * @param clinicId - ID of the clinic where the appointment is being booked
985
- * @throws Error if validation fails
986
- */
987
- private async validateDoctorAvailability(
988
- doctorId: string,
989
- eventTime: CalendarEventTime,
990
- clinicId: string
991
- ): Promise<void> {
992
- const startDate = eventTime.start.toDate();
993
- const startTime = startDate;
994
- const endTime = eventTime.end.toDate();
995
-
996
- // Get doctor's document to check clinic-specific working hours
997
- const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
998
- const practitionerDoc = await getDoc(practitionerRef);
999
-
1000
- if (!practitionerDoc.exists()) {
1001
- throw new Error(`Doctor with ID ${doctorId} not found`);
1002
- }
1003
-
1004
- const practitioner = practitionerDoc.data();
1005
-
1006
- // Check if doctor works at the specified clinic
1007
- if (!practitioner.clinics.includes(clinicId)) {
1008
- throw new Error("Doctor does not work at this clinic");
1009
- }
1010
-
1011
- // Get doctor's clinic-specific working hours
1012
- const clinicWorkingHours = practitioner.clinicWorkingHours?.find(
1013
- (hours: PractitionerClinicWorkingHours) =>
1014
- hours.clinicId === clinicId && hours.isActive
1015
- );
1016
-
1017
- if (!clinicWorkingHours) {
1018
- throw new Error("Doctor does not have working hours set for this clinic");
1019
- }
1020
-
1021
- // Get the day of the week (0 = Sunday, 1 = Monday, etc.)
1022
- const dayOfWeek = startDate.getDay();
1023
- const dayKey = [
1024
- "sunday",
1025
- "monday",
1026
- "tuesday",
1027
- "wednesday",
1028
- "thursday",
1029
- "friday",
1030
- "saturday",
1031
- ][dayOfWeek];
1032
- const daySchedule = clinicWorkingHours.workingHours[dayKey];
1033
-
1034
- if (!daySchedule) {
1035
- throw new Error("Doctor is not working on this day at this clinic");
1036
- }
1037
-
1038
- // Convert working hours to Date objects for comparison
1039
- const [startHour, startMinute] = daySchedule.start.split(":").map(Number);
1040
- const [endHour, endMinute] = daySchedule.end.split(":").map(Number);
1041
-
1042
- const scheduleStart = new Date(startDate);
1043
- scheduleStart.setHours(startHour, startMinute, 0, 0);
1044
-
1045
- const scheduleEnd = new Date(startDate);
1046
- scheduleEnd.setHours(endHour, endMinute, 0, 0);
1047
-
1048
- // Check if the appointment time is within doctor's working hours
1049
- if (startTime < scheduleStart || endTime > scheduleEnd) {
1050
- throw new Error(
1051
- "Appointment time is outside doctor's working hours at this clinic"
1052
- );
1053
- }
1054
-
1055
- // Get existing appointments
1056
- const appointments = await this.getDoctorAppointments(doctorId, startDate);
1057
-
1058
- // Check for overlapping appointments
1059
- const hasOverlap = appointments.some((appointment) => {
1060
- const appointmentStart = appointment.eventTime.start.toDate();
1061
- const appointmentEnd = appointment.eventTime.end.toDate();
1062
- return (
1063
- (startTime >= appointmentStart && startTime < appointmentEnd) ||
1064
- (endTime > appointmentStart && endTime <= appointmentEnd) ||
1065
- (startTime <= appointmentStart && endTime >= appointmentEnd)
1066
- );
1067
- });
1068
-
1069
- if (hasOverlap) {
1070
- throw new Error("Doctor has another appointment during this time");
1071
- }
1072
- }
1073
-
1074
- /**
1075
- * Updates appointment status
1076
- * @param appointmentId - ID of the appointment
1077
- * @param clinicId - ID of the clinic
1078
- * @param status - New status
1079
- * @returns Updated calendar event
1080
- */
1081
- private async updateAppointmentStatus(
1082
- appointmentId: string,
1083
- clinicId: string,
1084
- status: CalendarEventStatus
1085
- ): Promise<CalendarEvent> {
1086
- // Get the appointment
1087
- const baseCollectionPath = `${CLINICS_COLLECTION}/${clinicId}/${CALENDAR_COLLECTION}`;
1088
- const appointmentRef = doc(this.db, baseCollectionPath, appointmentId);
1089
- const appointmentDoc = await getDoc(appointmentRef);
1090
-
1091
- if (!appointmentDoc.exists()) {
1092
- throw new Error(`Appointment with ID ${appointmentId} not found`);
1093
- }
1094
-
1095
- const appointment = appointmentDoc.data() as CalendarEvent;
1096
-
1097
- // Validate that the appointment belongs to the specified clinic
1098
- if (appointment.clinicBranchId !== clinicId) {
1099
- throw new Error("Appointment does not belong to the specified clinic");
1100
- }
1101
-
1102
- // Validate the status transition
1103
- this.validateStatusTransition(appointment.status, status);
1104
-
1105
- // Update the appointment
1106
- const updateParams: UpdateAppointmentParams = {
1107
- appointmentId,
1108
- clinicId,
1109
- eventTime: appointment.eventTime,
1110
- description: appointment.description || "",
1111
- doctorId: appointment.practitionerProfileId || "",
1112
- patientId: appointment.patientProfileId || "",
1113
- status,
1114
- };
1115
-
1116
- // Validate update parameters
1117
- await this.validateUpdatePermissions(updateParams);
1118
-
1119
- // Update the appointment
1120
- return this.updateAppointment(updateParams);
1121
- }
1122
-
1123
- /**
1124
- * Validates status transition
1125
- * @param currentStatus - Current status
1126
- * @param newStatus - New status
1127
- * @throws Error if transition is invalid
1128
- */
1129
- private validateStatusTransition(
1130
- currentStatus: CalendarEventStatus,
1131
- newStatus: CalendarEventStatus
1132
- ): void {
1133
- // Define valid status transitions
1134
- const validTransitions: Record<CalendarEventStatus, CalendarEventStatus[]> =
1135
- {
1136
- [CalendarEventStatus.PENDING]: [
1137
- CalendarEventStatus.CONFIRMED,
1138
- CalendarEventStatus.REJECTED,
1139
- CalendarEventStatus.CANCELED,
1140
- ],
1141
- [CalendarEventStatus.CONFIRMED]: [
1142
- CalendarEventStatus.CANCELED,
1143
- CalendarEventStatus.CHECKED_IN,
1144
- CalendarEventStatus.COMPLETED,
1145
- CalendarEventStatus.RESCHEDULED,
1146
- CalendarEventStatus.NO_SHOW,
1147
- ],
1148
- [CalendarEventStatus.CHECKED_IN]: [
1149
- CalendarEventStatus.IN_PROGRESS,
1150
- CalendarEventStatus.COMPLETED,
1151
- CalendarEventStatus.CANCELED,
1152
- ],
1153
- [CalendarEventStatus.IN_PROGRESS]: [
1154
- CalendarEventStatus.COMPLETED,
1155
- CalendarEventStatus.CANCELED,
1156
- ],
1157
- [CalendarEventStatus.REJECTED]: [],
1158
- [CalendarEventStatus.CANCELED]: [],
1159
- [CalendarEventStatus.RESCHEDULED]: [
1160
- CalendarEventStatus.CONFIRMED,
1161
- CalendarEventStatus.CANCELED,
1162
- ],
1163
- [CalendarEventStatus.COMPLETED]: [],
1164
- [CalendarEventStatus.NO_SHOW]: [],
1165
- };
1166
-
1167
- // Check if transition is valid
1168
- if (!validTransitions[currentStatus].includes(newStatus)) {
1169
- throw new Error(
1170
- `Invalid status transition from ${currentStatus} to ${newStatus}`
1171
- );
1172
- }
1173
- }
1174
-
1175
- /**
1176
- * Syncs appointment with external calendars based on entity type and status
1177
- * @param appointment - Calendar event to sync
1178
- */
1179
- private async syncAppointmentWithExternalCalendars(
1180
- appointment: CalendarEvent
1181
- ): Promise<void> {
1182
- if (!appointment.practitionerProfileId || !appointment.patientProfileId) {
1183
- return;
1184
- }
1185
-
1186
- try {
1187
- // Get synced calendars for doctor and patient (no longer sync with clinic)
1188
- const [doctorCalendars, patientCalendars] = await Promise.all([
1189
- this.syncedCalendarsService.getPractitionerSyncedCalendars(
1190
- appointment.practitionerProfileId
1191
- ),
1192
- this.syncedCalendarsService.getPatientSyncedCalendars(
1193
- appointment.patientProfileId
1194
- ),
1195
- ]);
1196
-
1197
- // Filter active calendars
1198
- const activeDoctorCalendars = doctorCalendars.filter(
1199
- (cal) => cal.isActive
1200
- );
1201
- const activePatientCalendars = patientCalendars.filter(
1202
- (cal) => cal.isActive
1203
- );
1204
-
1205
- // Skip if there are no active calendars
1206
- if (
1207
- activeDoctorCalendars.length === 0 &&
1208
- activePatientCalendars.length === 0
1209
- ) {
1210
- return;
1211
- }
1212
-
1213
- // Only sync INTERNAL events (those created within our system)
1214
- if (appointment.syncStatus !== CalendarSyncStatus.INTERNAL) {
1215
- return;
1216
- }
1217
-
1218
- // For doctors: Only sync CONFIRMED status events
1219
- if (
1220
- appointment.status === CalendarEventStatus.CONFIRMED &&
1221
- activeDoctorCalendars.length > 0
1222
- ) {
1223
- await Promise.all(
1224
- activeDoctorCalendars.map((calendar) =>
1225
- this.syncEventToExternalCalendar(appointment, calendar, "doctor")
1226
- )
1227
- );
1228
- }
1229
-
1230
- // For patients: Sync all events EXCEPT CANCELED and REJECTED
1231
- if (
1232
- appointment.status !== CalendarEventStatus.CANCELED &&
1233
- appointment.status !== CalendarEventStatus.REJECTED &&
1234
- activePatientCalendars.length > 0
1235
- ) {
1236
- await Promise.all(
1237
- activePatientCalendars.map((calendar) =>
1238
- this.syncEventToExternalCalendar(appointment, calendar, "patient")
1239
- )
1240
- );
1241
- }
1242
- } catch (error) {
1243
- console.error("Error syncing with external calendars:", error);
1244
- // Don't throw error as this is not critical for appointment creation
1245
- }
1246
- }
1247
-
1248
- /**
1249
- * Syncs a single event to an external calendar
1250
- * @param appointment - Calendar event to sync
1251
- * @param calendar - External calendar to sync with
1252
- * @param entityType - Type of entity owning the calendar
1253
- */
1254
- private async syncEventToExternalCalendar(
1255
- appointment: CalendarEvent,
1256
- calendar: any,
1257
- entityType: "doctor" | "patient"
1258
- ): Promise<void> {
1259
- try {
1260
- // Create a copy of the appointment to modify for external syncing
1261
- const eventToSync = { ...appointment };
1262
-
1263
- // Prepare event title based on status and entity type
1264
- let eventTitle = appointment.eventName;
1265
- const clinicName = appointment.clinicBranchInfo?.name || "Clinic";
1266
-
1267
- // Format title appropriately
1268
- if (entityType === "patient") {
1269
- eventTitle = `[${appointment.status}] ${eventTitle} @ ${clinicName}`;
1270
- } else {
1271
- eventTitle = `${eventTitle} - Patient: ${
1272
- appointment.patientProfileInfo?.fullName || "Unknown"
1273
- } @ ${clinicName}`;
1274
- }
1275
-
1276
- // Update the event name for external sync
1277
- eventToSync.eventName = eventTitle;
1278
-
1279
- // Check if this event was previously synced with this calendar
1280
- const existingSyncId = appointment.syncedCalendarEventId?.find(
1281
- (sync) => sync.syncedCalendarProvider === calendar.provider
1282
- )?.eventId;
1283
-
1284
- // If we have a synced event ID, we should update the existing event
1285
- // If not, create a new event
1286
-
1287
- if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
1288
- const result =
1289
- await this.syncedCalendarsService.syncPractitionerEventsToGoogleCalendar(
1290
- entityType === "doctor"
1291
- ? appointment.practitionerProfileId!
1292
- : appointment.patientProfileId!,
1293
- calendar.id,
1294
- [eventToSync],
1295
- existingSyncId // Pass existing sync ID if we have one
1296
- );
1297
-
1298
- // If sync was successful and we've created a new event (no existing sync),
1299
- // we should update our local event with the new sync ID
1300
- if (result.success && result.eventIds?.length && !existingSyncId) {
1301
- // Update the appointment with the new sync ID
1302
- const newSyncEvent: SyncedCalendarEvent = {
1303
- eventId: result.eventIds[0],
1304
- syncedCalendarProvider: calendar.provider,
1305
- syncedAt: Timestamp.now(),
1306
- };
1307
-
1308
- // Update the event in the database with the new sync ID
1309
- await this.updateEventWithSyncId(
1310
- entityType === "doctor"
1311
- ? appointment.practitionerProfileId!
1312
- : appointment.patientProfileId!,
1313
- entityType,
1314
- appointment.id,
1315
- newSyncEvent
1316
- );
1317
- }
1318
- }
1319
- } catch (error) {
1320
- console.error(`Error syncing with ${entityType}'s calendar:`, error);
1321
- // Don't throw error as this is not critical
1322
- }
1323
- }
1324
-
1325
- /**
1326
- * Updates an event with a new sync ID
1327
- * @param entityId - ID of the entity (doctor or patient)
1328
- * @param entityType - Type of entity
1329
- * @param eventId - ID of the event
1330
- * @param syncEvent - Sync event information
1331
- */
1332
- private async updateEventWithSyncId(
1333
- entityId: string,
1334
- entityType: "doctor" | "patient",
1335
- eventId: string,
1336
- syncEvent: SyncedCalendarEvent
1337
- ): Promise<void> {
1338
- try {
1339
- // Determine the collection path based on entity type
1340
- const collectionPath =
1341
- entityType === "doctor"
1342
- ? `${PRACTITIONERS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`
1343
- : `${PATIENTS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`;
1344
-
1345
- // Get the event reference
1346
- const eventRef = doc(this.db, collectionPath, eventId);
1347
- const eventDoc = await getDoc(eventRef);
1348
-
1349
- if (eventDoc.exists()) {
1350
- const event = eventDoc.data() as CalendarEvent;
1351
- const syncIds = [...(event.syncedCalendarEventId || [])];
1352
-
1353
- // Check if we already have this sync ID
1354
- const existingSyncIndex = syncIds.findIndex(
1355
- (sync) =>
1356
- sync.syncedCalendarProvider === syncEvent.syncedCalendarProvider
1357
- );
1358
-
1359
- if (existingSyncIndex >= 0) {
1360
- // Update the existing sync ID
1361
- syncIds[existingSyncIndex] = syncEvent;
1362
- } else {
1363
- // Add the new sync ID
1364
- syncIds.push(syncEvent);
1365
- }
1366
-
1367
- // Update the event
1368
- await updateDoc(eventRef, {
1369
- syncedCalendarEventId: syncIds,
1370
- updatedAt: serverTimestamp(),
1371
- });
1372
-
1373
- console.log(
1374
- `Updated event ${eventId} with sync ID ${syncEvent.eventId}`
1375
- );
1376
- }
1377
- } catch (error) {
1378
- console.error("Error updating event with sync ID:", error);
1379
- }
1380
- }
1381
-
1382
- /**
1383
- * Validates update permissions and parameters
1384
- * @param params - Update parameters to validate
1385
- */
1386
- private async validateUpdatePermissions(
1387
- params: UpdateAppointmentParams
1388
- ): Promise<void> {
1389
- // TODO: Add custom validation logic after Zod schema validation
1390
- // - Check if user has permission to update the appointment
1391
- // - Check if the appointment exists
1392
- // - Check if the new status transition is valid
1393
- // - Check if the new time slot is valid
1394
- // - Validate against clinic's business rules
1395
-
1396
- // Validate basic parameters using Zod schema
1397
- await updateAppointmentSchema.parseAsync(params);
1398
- }
1399
-
1400
- /**
1401
- * Gets clinic working hours for a specific date
1402
- * @param clinicId - ID of the clinic
1403
- * @param date - Date to get working hours for
1404
- * @returns Working hours for the clinic
1405
- */
1406
- private async getClinicWorkingHours(
1407
- clinicId: string,
1408
- date: Date
1409
- ): Promise<TimeSlot[]> {
1410
- // Get clinic document
1411
- const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
1412
- const clinicDoc = await getDoc(clinicRef);
1413
-
1414
- if (!clinicDoc.exists()) {
1415
- throw new Error(`Clinic with ID ${clinicId} not found`);
1416
- }
1417
-
1418
- // TODO: Implement proper working hours retrieval from clinic data model
1419
- // For now, return default working hours (9 AM - 5 PM)
1420
- const workingHours: TimeSlot[] = [];
1421
- const dayOfWeek = date.getDay();
1422
-
1423
- // Skip weekends (0 = Sunday, 6 = Saturday)
1424
- if (dayOfWeek === 0 || dayOfWeek === 6) {
1425
- return workingHours;
1426
- }
1427
-
1428
- // Create working hours slot (9 AM - 5 PM)
1429
- const workingDate = new Date(date);
1430
- workingDate.setHours(9, 0, 0, 0);
1431
- const startTime = new Date(workingDate);
1432
-
1433
- workingDate.setHours(17, 0, 0, 0);
1434
- const endTime = new Date(workingDate);
1435
-
1436
- workingHours.push({
1437
- start: startTime,
1438
- end: endTime,
1439
- isAvailable: true,
1440
- });
1441
-
1442
- return workingHours;
1443
- }
1444
-
1445
- /**
1446
- * Gets doctor's schedule for a specific date
1447
- * @param doctorId - ID of the doctor
1448
- * @param date - Date to get schedule for
1449
- * @returns Doctor's schedule
1450
- */
1451
- private async getDoctorSchedule(
1452
- doctorId: string,
1453
- date: Date
1454
- ): Promise<TimeSlot[]> {
1455
- // Get doctor document
1456
- const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
1457
- const practitionerDoc = await getDoc(practitionerRef);
1458
-
1459
- if (!practitionerDoc.exists()) {
1460
- throw new Error(`Doctor with ID ${doctorId} not found`);
1461
- }
1462
-
1463
- // TODO: Implement proper schedule retrieval from practitioner data model
1464
- // For now, return default schedule (9 AM - 5 PM)
1465
- const schedule: TimeSlot[] = [];
1466
- const dayOfWeek = date.getDay();
1467
-
1468
- // Skip weekends (0 = Sunday, 6 = Saturday)
1469
- if (dayOfWeek === 0 || dayOfWeek === 6) {
1470
- return schedule;
1471
- }
1472
-
1473
- // Create schedule slot (9 AM - 5 PM)
1474
- const scheduleDate = new Date(date);
1475
- scheduleDate.setHours(9, 0, 0, 0);
1476
- const startTime = new Date(scheduleDate);
1477
-
1478
- scheduleDate.setHours(17, 0, 0, 0);
1479
- const endTime = new Date(scheduleDate);
1480
-
1481
- schedule.push({
1482
- start: startTime,
1483
- end: endTime,
1484
- isAvailable: true,
1485
- });
1486
-
1487
- return schedule;
1488
- }
1489
-
1490
- /**
1491
- * Gets doctor's appointments for a specific date
1492
- * @param doctorId - ID of the doctor
1493
- * @param date - Date to get appointments for
1494
- * @returns Array of calendar events
1495
- */
1496
- private async getDoctorAppointments(
1497
- doctorId: string,
1498
- date: Date
1499
- ): Promise<CalendarEvent[]> {
1500
- // Create start and end timestamps for the day
1501
- const startOfDay = new Date(date);
1502
- startOfDay.setHours(0, 0, 0, 0);
1503
- const endOfDay = new Date(date);
1504
- endOfDay.setHours(23, 59, 59, 999);
1505
-
1506
- // Query appointments for the doctor on the specified date
1507
- const appointmentsRef = collection(this.db, CALENDAR_COLLECTION);
1508
- const q = query(
1509
- appointmentsRef,
1510
- where("practitionerProfileId", "==", doctorId),
1511
- where("eventTime.start", ">=", Timestamp.fromDate(startOfDay)),
1512
- where("eventTime.start", "<=", Timestamp.fromDate(endOfDay)),
1513
- where("status", "in", [
1514
- CalendarEventStatus.CONFIRMED,
1515
- CalendarEventStatus.PENDING,
1516
- ])
1517
- );
1518
-
1519
- const querySnapshot = await getDocs(q);
1520
- return querySnapshot.docs.map((doc) => doc.data() as CalendarEvent);
1521
- }
1522
-
1523
- /**
1524
- * Calculates available time slots based on working hours, schedule and existing appointments
1525
- * @param workingHours - Clinic working hours
1526
- * @param doctorSchedule - Doctor's schedule
1527
- * @param existingAppointments - Existing appointments
1528
- * @returns Array of available time slots
1529
- */
1530
- private calculateAvailableSlots(
1531
- workingHours: TimeSlot[],
1532
- doctorSchedule: TimeSlot[],
1533
- existingAppointments: CalendarEvent[]
1534
- ): TimeSlot[] {
1535
- const availableSlots: TimeSlot[] = [];
1536
-
1537
- // First, find overlapping time slots between clinic hours and doctor schedule
1538
- for (const workingHour of workingHours) {
1539
- for (const scheduleSlot of doctorSchedule) {
1540
- // Find overlap between working hours and doctor schedule
1541
- const overlapStart = new Date(
1542
- Math.max(workingHour.start.getTime(), scheduleSlot.start.getTime())
1543
- );
1544
- const overlapEnd = new Date(
1545
- Math.min(workingHour.end.getTime(), scheduleSlot.end.getTime())
1546
- );
1547
-
1548
- // If there is an overlap and both slots are available
1549
- if (
1550
- overlapStart < overlapEnd &&
1551
- workingHour.isAvailable &&
1552
- scheduleSlot.isAvailable
1553
- ) {
1554
- // Create 15-minute slots within the overlap period
1555
- let slotStart = new Date(overlapStart);
1556
- while (slotStart < overlapEnd) {
1557
- const slotEnd = new Date(
1558
- slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
1559
- );
1560
-
1561
- // Check if this slot overlaps with any existing appointments
1562
- const hasOverlap = existingAppointments.some((appointment) => {
1563
- const appointmentStart = appointment.eventTime.start.toDate();
1564
- const appointmentEnd = appointment.eventTime.end.toDate();
1565
- return (
1566
- (slotStart >= appointmentStart && slotStart < appointmentEnd) ||
1567
- (slotEnd > appointmentStart && slotEnd <= appointmentEnd)
1568
- );
1569
- });
1570
-
1571
- if (!hasOverlap && slotEnd <= overlapEnd) {
1572
- availableSlots.push({
1573
- start: new Date(slotStart),
1574
- end: new Date(slotEnd),
1575
- isAvailable: true,
1576
- });
1577
- }
1578
-
1579
- // Move to next slot
1580
- slotStart = new Date(
1581
- slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
1582
- );
1583
- }
1584
- }
1585
- }
1586
- }
1587
-
1588
- return availableSlots;
1589
- }
1590
-
1591
- /**
1592
- * Fetches and creates info cards for clinic, doctor, and patient profiles
1593
- * @param clinicId - ID of the clinic
1594
- * @param doctorId - ID of the doctor
1595
- * @param patientId - ID of the patient
1596
- * @returns Object containing info cards for all profiles
1597
- */
1598
- private async fetchProfileInfoCards(
1599
- clinicId: string,
1600
- doctorId: string,
1601
- patientId: string
1602
- ): Promise<{
1603
- clinicInfo: ClinicInfo | null;
1604
- practitionerInfo: PractitionerProfileInfo | null;
1605
- patientInfo: PatientProfileInfo | null;
1606
- }> {
1607
- try {
1608
- // Fetch all profiles concurrently
1609
- const [clinicDoc, practitionerDoc, patientDoc, patientSensitiveInfoDoc] =
1610
- await Promise.all([
1611
- getDoc(doc(this.db, CLINICS_COLLECTION, clinicId)),
1612
- getDoc(doc(this.db, PRACTITIONERS_COLLECTION, doctorId)),
1613
- getDoc(doc(this.db, PATIENTS_COLLECTION, patientId)),
1614
- getDoc(
1615
- doc(
1616
- this.db,
1617
- PATIENTS_COLLECTION,
1618
- patientId,
1619
- PATIENT_SENSITIVE_INFO_COLLECTION,
1620
- patientId
1621
- )
1622
- ),
1623
- ]);
1624
-
1625
- // Create info cards
1626
- const clinicInfo: ClinicInfo | null = clinicDoc.exists()
1627
- ? {
1628
- id: clinicDoc.id,
1629
- featuredPhoto: clinicDoc.data().featuredPhoto || "",
1630
- name: clinicDoc.data().name,
1631
- description: clinicDoc.data().description || "",
1632
- location: clinicDoc.data().location,
1633
- contactInfo: clinicDoc.data().contactInfo,
1634
- }
1635
- : null;
1636
-
1637
- const practitionerInfo: PractitionerProfileInfo | null =
1638
- practitionerDoc.exists()
1639
- ? {
1640
- id: practitionerDoc.id,
1641
- practitionerPhoto:
1642
- practitionerDoc.data().basicInfo.profileImageUrl || null,
1643
- name: `${practitionerDoc.data().basicInfo.firstName} ${
1644
- practitionerDoc.data().basicInfo.lastName
1645
- }`,
1646
- email: practitionerDoc.data().basicInfo.email,
1647
- phone: practitionerDoc.data().basicInfo.phoneNumber || null,
1648
- certification: practitionerDoc.data().certification,
1649
- }
1650
- : null;
1651
-
1652
- // First try to get data from sensitive-info subcollection
1653
- let patientInfo: PatientProfileInfo | null = null;
1654
-
1655
- if (patientSensitiveInfoDoc.exists()) {
1656
- const sensitiveData = patientSensitiveInfoDoc.data();
1657
- patientInfo = {
1658
- id: patientId,
1659
- fullName: `${sensitiveData.firstName} ${sensitiveData.lastName}`,
1660
- email: sensitiveData.email || "",
1661
- phone: sensitiveData.phoneNumber || null,
1662
- dateOfBirth: sensitiveData.dateOfBirth || Timestamp.now(),
1663
- gender: sensitiveData.gender || Gender.OTHER,
1664
- };
1665
- } else if (patientDoc.exists()) {
1666
- // Fall back to patient document if sensitive info not available
1667
- patientInfo = {
1668
- id: patientDoc.id,
1669
- fullName: patientDoc.data().displayName,
1670
- email: patientDoc.data().contactInfo?.email || "",
1671
- phone: patientDoc.data().phoneNumber || null,
1672
- dateOfBirth: patientDoc.data().dateOfBirth || Timestamp.now(),
1673
- gender: patientDoc.data().gender || Gender.OTHER,
1674
- };
1675
- }
1676
-
1677
- return {
1678
- clinicInfo,
1679
- practitionerInfo,
1680
- patientInfo,
1681
- };
1682
- } catch (error) {
1683
- console.error("Error fetching profile info cards:", error);
1684
- return {
1685
- clinicInfo: null,
1686
- practitionerInfo: null,
1687
- patientInfo: null,
1688
- };
1689
- }
1690
- }
1691
-
1692
- // #endregion
1693
- }
1
+ import { Auth } from "firebase/auth";
2
+ import { Firestore, Timestamp, serverTimestamp } from "firebase/firestore";
3
+ import { FirebaseApp } from "firebase/app";
4
+ import { BaseService } from "../base.service";
5
+ import {
6
+ CalendarEvent,
7
+ CalendarEventStatus,
8
+ CalendarEventTime,
9
+ CalendarEventType,
10
+ CalendarSyncStatus,
11
+ CreateCalendarEventData,
12
+ UpdateCalendarEventData,
13
+ CALENDAR_COLLECTION,
14
+ SyncedCalendarEvent,
15
+ ProcedureInfo,
16
+ TimeSlot,
17
+ CreateAppointmentParams,
18
+ UpdateAppointmentParams,
19
+ SearchCalendarEventsParams,
20
+ SearchLocationEnum,
21
+ DateRange,
22
+ } from "../../types/calendar";
23
+ import {
24
+ PRACTITIONERS_COLLECTION,
25
+ PractitionerClinicWorkingHours,
26
+ } from "../../types/practitioner";
27
+ import {
28
+ PATIENTS_COLLECTION,
29
+ Gender,
30
+ PATIENT_SENSITIVE_INFO_COLLECTION,
31
+ } from "../../types/patient";
32
+ import { CLINICS_COLLECTION } from "../../types/clinic";
33
+ import { SyncedCalendarProvider } from "../../types/calendar/synced-calendar.types";
34
+ import {
35
+ ClinicInfo,
36
+ PatientProfileInfo,
37
+ PractitionerProfileInfo,
38
+ } from "../../types/profile";
39
+ import {
40
+ doc,
41
+ getDoc,
42
+ collection,
43
+ query,
44
+ where,
45
+ getDocs,
46
+ setDoc,
47
+ updateDoc,
48
+ QueryConstraint,
49
+ CollectionReference,
50
+ DocumentData,
51
+ } from "firebase/firestore";
52
+ import {
53
+ createAppointmentSchema,
54
+ updateAppointmentSchema,
55
+ } from "../../validations/appointment.schema";
56
+
57
+ // Import utility functions
58
+ import {
59
+ createAppointmentUtil,
60
+ updateAppointmentUtil,
61
+ deleteAppointmentUtil,
62
+ } from "./utils/appointment.utils";
63
+ import { searchCalendarEventsUtil } from "./utils/calendar-event.utils";
64
+ import { SyncedCalendarsService } from "./synced-calendars.service";
65
+ import { Timestamp as FirestoreTimestamp } from "firebase-admin/firestore";
66
+
67
+ /**
68
+ * Minimum appointment duration in minutes
69
+ */
70
+ const MIN_APPOINTMENT_DURATION = 15;
71
+
72
+ /**
73
+ * Refactored Calendar Service
74
+ * Provides streamlined calendar management with proper access control and scheduling rules
75
+ */
76
+ export class CalendarServiceV2 extends BaseService {
77
+ private syncedCalendarsService: SyncedCalendarsService;
78
+
79
+ /**
80
+ * Creates a new CalendarService instance
81
+ * @param db - Firestore instance
82
+ * @param auth - Firebase Auth instance
83
+ * @param app - Firebase App instance
84
+ */
85
+ constructor(db: Firestore, auth: Auth, app: FirebaseApp) {
86
+ super(db, auth, app);
87
+ this.syncedCalendarsService = new SyncedCalendarsService(db, auth, app);
88
+ }
89
+
90
+ // #region Public API Methods
91
+
92
+ /**
93
+ * Creates a new appointment with proper validation and scheduling rules
94
+ * @param params - Appointment creation parameters
95
+ * @returns Created calendar event
96
+ */
97
+ async createAppointment(
98
+ params: CreateAppointmentParams
99
+ ): Promise<CalendarEvent> {
100
+ // Validate input parameters
101
+ await this.validateAppointmentParams(params);
102
+
103
+ // Check clinic working hours
104
+ await this.validateClinicWorkingHours(params.clinicId, params.eventTime);
105
+
106
+ // Check doctor availability
107
+ await this.validateDoctorAvailability(
108
+ params.doctorId,
109
+ params.eventTime,
110
+ params.clinicId
111
+ );
112
+
113
+ // Fetch profile info cards
114
+ const { clinicInfo, practitionerInfo, patientInfo } =
115
+ await this.fetchProfileInfoCards(
116
+ params.clinicId,
117
+ params.doctorId,
118
+ params.patientId
119
+ );
120
+
121
+ // Create the appointment
122
+ const appointmentData: Omit<
123
+ CreateCalendarEventData,
124
+ "id" | "createdAt" | "updatedAt"
125
+ > = {
126
+ clinicBranchId: params.clinicId,
127
+ clinicBranchInfo: clinicInfo,
128
+ practitionerProfileId: params.doctorId,
129
+ practitionerProfileInfo: practitionerInfo,
130
+ patientProfileId: params.patientId,
131
+ patientProfileInfo: patientInfo,
132
+ procedureId: params.procedureId,
133
+ eventLocation: params.eventLocation,
134
+ eventName: "Appointment", // TODO: Add procedure name when procedure model is available
135
+ eventTime: params.eventTime,
136
+ description: params.description || "",
137
+ status: CalendarEventStatus.PENDING,
138
+ syncStatus: CalendarSyncStatus.INTERNAL,
139
+ eventType: CalendarEventType.APPOINTMENT,
140
+ };
141
+
142
+ const appointment = await createAppointmentUtil(
143
+ this.db,
144
+ params.clinicId,
145
+ params.doctorId,
146
+ params.patientId,
147
+ appointmentData,
148
+ this.generateId.bind(this)
149
+ );
150
+
151
+ // Sync with external calendars if needed
152
+ await this.syncAppointmentWithExternalCalendars(appointment);
153
+
154
+ return appointment;
155
+ }
156
+
157
+ /**
158
+ * Updates an existing appointment
159
+ * @param params - Appointment update parameters
160
+ * @returns Updated calendar event
161
+ */
162
+ async updateAppointment(
163
+ params: UpdateAppointmentParams
164
+ ): Promise<CalendarEvent> {
165
+ // Validate permissions
166
+ await this.validateUpdatePermissions(params);
167
+
168
+ const updateData: Omit<UpdateCalendarEventData, "updatedAt"> = {
169
+ eventTime: params.eventTime,
170
+ description: params.description,
171
+ status: params.status,
172
+ };
173
+
174
+ const appointment = await updateAppointmentUtil(
175
+ this.db,
176
+ params.clinicId,
177
+ params.doctorId,
178
+ params.patientId,
179
+ params.appointmentId,
180
+ updateData
181
+ );
182
+
183
+ // Sync with external calendars if needed
184
+ await this.syncAppointmentWithExternalCalendars(appointment);
185
+
186
+ return appointment;
187
+ }
188
+
189
+ /**
190
+ * Gets available appointment slots for a doctor at a clinic
191
+ * @param clinicId - ID of the clinic
192
+ * @param doctorId - ID of the doctor
193
+ * @param date - Date to check availability for
194
+ * @returns Array of available time slots
195
+ */
196
+ async getAvailableSlots(
197
+ clinicId: string,
198
+ doctorId: string,
199
+ date: Date
200
+ ): Promise<TimeSlot[]> {
201
+ // Get clinic working hours
202
+ const workingHours = await this.getClinicWorkingHours(clinicId, date);
203
+
204
+ // Get doctor's schedule
205
+ const doctorSchedule = await this.getDoctorSchedule(doctorId, date);
206
+
207
+ // Get existing appointments
208
+ const existingAppointments = await this.getDoctorAppointments(
209
+ doctorId,
210
+ date
211
+ );
212
+
213
+ // Calculate available slots
214
+ return this.calculateAvailableSlots(
215
+ workingHours,
216
+ doctorSchedule,
217
+ existingAppointments
218
+ );
219
+ }
220
+
221
+ /**
222
+ * Confirms an appointment
223
+ * @param appointmentId - ID of the appointment
224
+ * @param clinicId - ID of the clinic
225
+ * @returns Confirmed calendar event
226
+ */
227
+ async confirmAppointment(
228
+ appointmentId: string,
229
+ clinicId: string
230
+ ): Promise<CalendarEvent> {
231
+ return this.updateAppointmentStatus(
232
+ appointmentId,
233
+ clinicId,
234
+ CalendarEventStatus.CONFIRMED
235
+ );
236
+ }
237
+
238
+ /**
239
+ * Rejects an appointment
240
+ * @param appointmentId - ID of the appointment
241
+ * @param clinicId - ID of the clinic
242
+ * @returns Rejected calendar event
243
+ */
244
+ async rejectAppointment(
245
+ appointmentId: string,
246
+ clinicId: string
247
+ ): Promise<CalendarEvent> {
248
+ return this.updateAppointmentStatus(
249
+ appointmentId,
250
+ clinicId,
251
+ CalendarEventStatus.REJECTED
252
+ );
253
+ }
254
+
255
+ /**
256
+ * Cancels an appointment
257
+ * @param appointmentId - ID of the appointment
258
+ * @param clinicId - ID of the clinic
259
+ * @returns Canceled calendar event
260
+ */
261
+ async cancelAppointment(
262
+ appointmentId: string,
263
+ clinicId: string
264
+ ): Promise<CalendarEvent> {
265
+ return this.updateAppointmentStatus(
266
+ appointmentId,
267
+ clinicId,
268
+ CalendarEventStatus.CANCELED
269
+ );
270
+ }
271
+
272
+ /**
273
+ * Imports events from external calendars
274
+ * @param entityType - Type of entity (practitioner or patient)
275
+ * @param entityId - ID of the entity
276
+ * @param startDate - Start date for fetching events
277
+ * @param endDate - End date for fetching events
278
+ * @returns Number of events imported
279
+ */
280
+ async importEventsFromExternalCalendars(
281
+ entityType: "doctor" | "patient",
282
+ entityId: string,
283
+ startDate: Date,
284
+ endDate: Date
285
+ ): Promise<number> {
286
+ // Only practitioners (doctors) should sync two-way
287
+ // Patients only sync outwards (from our system to external calendars)
288
+ if (entityType === "patient") {
289
+ return 0;
290
+ }
291
+
292
+ // For doctors, get their synced calendars
293
+ const syncedCalendars =
294
+ await this.syncedCalendarsService.getPractitionerSyncedCalendars(
295
+ entityId
296
+ );
297
+
298
+ // Filter active calendars
299
+ const activeCalendars = syncedCalendars.filter((cal) => cal.isActive);
300
+
301
+ if (activeCalendars.length === 0) {
302
+ return 0;
303
+ }
304
+
305
+ let importedEventsCount = 0;
306
+ const currentTime = Timestamp.now();
307
+
308
+ // Import from each calendar
309
+ for (const calendar of activeCalendars) {
310
+ try {
311
+ let externalEvents: any[] = [];
312
+
313
+ // Fetch events based on provider and entity type
314
+ if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
315
+ externalEvents =
316
+ await this.syncedCalendarsService.fetchEventsFromPractitionerGoogleCalendar(
317
+ entityId,
318
+ calendar.id,
319
+ startDate,
320
+ endDate
321
+ );
322
+ }
323
+ // Add other providers as needed
324
+
325
+ // Process and import each event
326
+ for (const externalEvent of externalEvents) {
327
+ try {
328
+ // Convert the external event to our format
329
+ const convertedEvent =
330
+ this.syncedCalendarsService.convertGoogleEventsToPractitionerEvents(
331
+ entityId,
332
+ [externalEvent]
333
+ )[0];
334
+
335
+ // Skip events without valid time data
336
+ if (!convertedEvent.eventTime) {
337
+ continue;
338
+ }
339
+
340
+ // Create event data from external event
341
+ const eventData: Omit<
342
+ CreateCalendarEventData,
343
+ "id" | "createdAt" | "updatedAt"
344
+ > = {
345
+ // Ensure all required fields are set
346
+ eventName: convertedEvent.eventName || "External Event",
347
+ eventTime: convertedEvent.eventTime,
348
+ description: convertedEvent.description || "",
349
+ status: CalendarEventStatus.CONFIRMED,
350
+ syncStatus: CalendarSyncStatus.EXTERNAL,
351
+ eventType: CalendarEventType.BLOCKING,
352
+ practitionerProfileId: entityId,
353
+ syncedCalendarEventId: [
354
+ {
355
+ eventId: externalEvent.id,
356
+ syncedCalendarProvider: calendar.provider,
357
+ syncedAt: currentTime,
358
+ },
359
+ ],
360
+ };
361
+
362
+ // Create the event in the doctor's calendar
363
+ const doctorEvent = await this.createDoctorBlockingEvent(
364
+ entityId,
365
+ eventData
366
+ );
367
+
368
+ if (doctorEvent) {
369
+ importedEventsCount++;
370
+ }
371
+ } catch (eventError) {
372
+ console.error("Error importing event:", eventError);
373
+ // Continue with other events even if one fails
374
+ }
375
+ }
376
+ } catch (calendarError) {
377
+ console.error(
378
+ `Error fetching events from calendar ${calendar.id}:`,
379
+ calendarError
380
+ );
381
+ // Continue with other calendars even if one fails
382
+ }
383
+ }
384
+
385
+ return importedEventsCount;
386
+ }
387
+
388
+ /**
389
+ * Creates a blocking event in a doctor's calendar
390
+ * @param doctorId - ID of the doctor
391
+ * @param eventData - Calendar event data
392
+ * @returns Created calendar event
393
+ */
394
+ private async createDoctorBlockingEvent(
395
+ doctorId: string,
396
+ eventData: Omit<CreateCalendarEventData, "id" | "createdAt" | "updatedAt">
397
+ ): Promise<CalendarEvent | null> {
398
+ try {
399
+ // Generate a unique ID for the event
400
+ const eventId = this.generateId();
401
+
402
+ // Create the event document reference
403
+ const eventRef = doc(
404
+ this.db,
405
+ PRACTITIONERS_COLLECTION,
406
+ doctorId,
407
+ CALENDAR_COLLECTION,
408
+ eventId
409
+ );
410
+
411
+ // Prepare the event data
412
+ const newEvent: CreateCalendarEventData = {
413
+ id: eventId,
414
+ ...eventData,
415
+ createdAt: serverTimestamp(),
416
+ updatedAt: serverTimestamp(),
417
+ };
418
+
419
+ // Set the document
420
+ await setDoc(eventRef, newEvent);
421
+
422
+ // Return the event
423
+ return {
424
+ ...newEvent,
425
+ createdAt: Timestamp.now(),
426
+ updatedAt: Timestamp.now(),
427
+ } as CalendarEvent;
428
+ } catch (error) {
429
+ console.error(
430
+ `Error creating blocking event for doctor ${doctorId}:`,
431
+ error
432
+ );
433
+ return null;
434
+ }
435
+ }
436
+
437
+ /**
438
+ * Periodically syncs events from external calendars for doctors
439
+ * This would be called via a scheduled Cloud Function
440
+ * @param lookbackDays - Number of days to look back for events
441
+ * @param lookforwardDays - Number of days to look forward for events
442
+ */
443
+ async synchronizeExternalCalendars(
444
+ lookbackDays: number = 7,
445
+ lookforwardDays: number = 30
446
+ ): Promise<void> {
447
+ try {
448
+ // Get all doctors who have active synced calendars
449
+ const practitionersRef = collection(this.db, PRACTITIONERS_COLLECTION);
450
+ const practitionersSnapshot = await getDocs(practitionersRef);
451
+
452
+ // Prepare date range
453
+ const startDate = new Date();
454
+ startDate.setDate(startDate.getDate() - lookbackDays);
455
+
456
+ const endDate = new Date();
457
+ endDate.setDate(endDate.getDate() + lookforwardDays);
458
+
459
+ // For each doctor, check their synced calendars
460
+ const syncPromises = [];
461
+ for (const docSnapshot of practitionersSnapshot.docs) {
462
+ const practitionerId = docSnapshot.id;
463
+
464
+ // Import events from external calendars
465
+ syncPromises.push(
466
+ this.importEventsFromExternalCalendars(
467
+ "doctor",
468
+ practitionerId,
469
+ startDate,
470
+ endDate
471
+ )
472
+ .then((count) => {
473
+ console.log(
474
+ `Imported ${count} events for doctor ${practitionerId}`
475
+ );
476
+ })
477
+ .catch((error) => {
478
+ console.error(
479
+ `Error importing events for doctor ${practitionerId}:`,
480
+ error
481
+ );
482
+ })
483
+ );
484
+
485
+ // Also update existing events that might have changed
486
+ syncPromises.push(
487
+ this.updateExistingEventsFromExternalCalendars(
488
+ practitionerId,
489
+ startDate,
490
+ endDate
491
+ )
492
+ .then((count) => {
493
+ console.log(
494
+ `Updated ${count} events for doctor ${practitionerId}`
495
+ );
496
+ })
497
+ .catch((error) => {
498
+ console.error(
499
+ `Error updating events for doctor ${practitionerId}:`,
500
+ error
501
+ );
502
+ })
503
+ );
504
+ }
505
+
506
+ // Wait for all sync operations to complete
507
+ await Promise.all(syncPromises);
508
+ console.log("Completed external calendar synchronization");
509
+ } catch (error) {
510
+ console.error("Error synchronizing external calendars:", error);
511
+ }
512
+ }
513
+
514
+ /**
515
+ * Updates existing events that were synced from external calendars
516
+ * @param doctorId - ID of the doctor
517
+ * @param startDate - Start date for fetching events
518
+ * @param endDate - End date for fetching events
519
+ * @returns Number of events updated
520
+ */
521
+ private async updateExistingEventsFromExternalCalendars(
522
+ doctorId: string,
523
+ startDate: Date,
524
+ endDate: Date
525
+ ): Promise<number> {
526
+ try {
527
+ // Get all EXTERNAL events for this doctor within the date range
528
+ const eventsRef = collection(
529
+ this.db,
530
+ PRACTITIONERS_COLLECTION,
531
+ doctorId,
532
+ CALENDAR_COLLECTION
533
+ );
534
+ const q = query(
535
+ eventsRef,
536
+ where("syncStatus", "==", CalendarSyncStatus.EXTERNAL),
537
+ where("eventTime.start", ">=", Timestamp.fromDate(startDate)),
538
+ where("eventTime.start", "<=", Timestamp.fromDate(endDate))
539
+ );
540
+
541
+ const eventsSnapshot = await getDocs(q);
542
+ const events = eventsSnapshot.docs.map((doc) => ({
543
+ id: doc.id,
544
+ ...doc.data(),
545
+ })) as CalendarEvent[];
546
+
547
+ // Get the doctor's synced calendars
548
+ const calendars =
549
+ await this.syncedCalendarsService.getPractitionerSyncedCalendars(
550
+ doctorId
551
+ );
552
+ const activeCalendars = calendars.filter((cal) => cal.isActive);
553
+
554
+ if (activeCalendars.length === 0 || events.length === 0) {
555
+ return 0;
556
+ }
557
+
558
+ let updatedCount = 0;
559
+
560
+ // For each external event, check if it needs updating
561
+ for (const event of events) {
562
+ // Skip events without sync IDs
563
+ if (!event.syncedCalendarEventId?.length) continue;
564
+
565
+ for (const syncId of event.syncedCalendarEventId) {
566
+ // Find the calendar for this sync ID
567
+ const calendar = activeCalendars.find(
568
+ (cal) => cal.provider === syncId.syncedCalendarProvider
569
+ );
570
+ if (!calendar) continue;
571
+
572
+ // Check if the event exists and needs updating
573
+ if (syncId.syncedCalendarProvider === SyncedCalendarProvider.GOOGLE) {
574
+ try {
575
+ // Fetch the external event
576
+ const externalEvent = await this.fetchExternalEvent(
577
+ doctorId,
578
+ calendar,
579
+ syncId.eventId
580
+ );
581
+
582
+ // If the event was found, check if it's different from our local copy
583
+ if (externalEvent) {
584
+ // Compare basic properties (time, title, description)
585
+ const externalStartTime = new Date(
586
+ externalEvent.start.dateTime || externalEvent.start.date
587
+ ).getTime();
588
+ const externalEndTime = new Date(
589
+ externalEvent.end.dateTime || externalEvent.end.date
590
+ ).getTime();
591
+ const localStartTime = event.eventTime.start.toDate().getTime();
592
+ const localEndTime = event.eventTime.end.toDate().getTime();
593
+
594
+ // If times or title/description have changed, update our local copy
595
+ if (
596
+ externalStartTime !== localStartTime ||
597
+ externalEndTime !== localEndTime ||
598
+ externalEvent.summary !== event.eventName ||
599
+ externalEvent.description !== event.description
600
+ ) {
601
+ // Update our local copy
602
+ await this.updateLocalEventFromExternal(
603
+ doctorId,
604
+ event.id,
605
+ externalEvent
606
+ );
607
+ updatedCount++;
608
+ }
609
+ } else {
610
+ // The event was deleted in the external calendar, mark it as canceled
611
+ await this.updateEventStatus(
612
+ doctorId,
613
+ event.id,
614
+ CalendarEventStatus.CANCELED
615
+ );
616
+ updatedCount++;
617
+ }
618
+ } catch (error) {
619
+ console.error(
620
+ `Error updating external event ${event.id}:`,
621
+ error
622
+ );
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ return updatedCount;
629
+ } catch (error) {
630
+ console.error(
631
+ "Error updating existing events from external calendars:",
632
+ error
633
+ );
634
+ return 0;
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Fetches a single external event from Google Calendar
640
+ * @param doctorId - ID of the doctor
641
+ * @param calendar - Calendar information
642
+ * @param externalEventId - ID of the external event
643
+ * @returns External event data or null if not found
644
+ */
645
+ private async fetchExternalEvent(
646
+ doctorId: string,
647
+ calendar: any,
648
+ externalEventId: string
649
+ ): Promise<any | null> {
650
+ try {
651
+ if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
652
+ // Refresh token if needed
653
+ // We're using the syncPractitionerEventsToGoogleCalendar to get the calendar with a refreshed token
654
+ const result =
655
+ await this.syncedCalendarsService.fetchEventFromPractitionerGoogleCalendar(
656
+ doctorId,
657
+ calendar.id,
658
+ externalEventId
659
+ );
660
+
661
+ return result;
662
+ }
663
+ return null;
664
+ } catch (error) {
665
+ console.error(`Error fetching external event ${externalEventId}:`, error);
666
+ return null;
667
+ }
668
+ }
669
+
670
+ /**
671
+ * Updates a local event with data from an external event
672
+ * @param doctorId - ID of the doctor
673
+ * @param eventId - ID of the local event
674
+ * @param externalEvent - External event data
675
+ */
676
+ private async updateLocalEventFromExternal(
677
+ doctorId: string,
678
+ eventId: string,
679
+ externalEvent: any
680
+ ): Promise<void> {
681
+ try {
682
+ // Create event time from external event
683
+ const startTime = new Date(
684
+ externalEvent.start.dateTime || externalEvent.start.date
685
+ );
686
+ const endTime = new Date(
687
+ externalEvent.end.dateTime || externalEvent.end.date
688
+ );
689
+
690
+ // Update the local event
691
+ const eventRef = doc(
692
+ this.db,
693
+ PRACTITIONERS_COLLECTION,
694
+ doctorId,
695
+ CALENDAR_COLLECTION,
696
+ eventId
697
+ );
698
+
699
+ await updateDoc(eventRef, {
700
+ eventName: externalEvent.summary || "External Event",
701
+ eventTime: {
702
+ start: Timestamp.fromDate(startTime),
703
+ end: Timestamp.fromDate(endTime),
704
+ },
705
+ description: externalEvent.description || "",
706
+ updatedAt: serverTimestamp(),
707
+ });
708
+
709
+ console.log(`Updated local event ${eventId} from external event`);
710
+ } catch (error) {
711
+ console.error(
712
+ `Error updating local event ${eventId} from external:`,
713
+ error
714
+ );
715
+ }
716
+ }
717
+
718
+ /**
719
+ * Updates an event's status
720
+ * @param doctorId - ID of the doctor
721
+ * @param eventId - ID of the event
722
+ * @param status - New status
723
+ */
724
+ private async updateEventStatus(
725
+ doctorId: string,
726
+ eventId: string,
727
+ status: CalendarEventStatus
728
+ ): Promise<void> {
729
+ try {
730
+ const eventRef = doc(
731
+ this.db,
732
+ PRACTITIONERS_COLLECTION,
733
+ doctorId,
734
+ CALENDAR_COLLECTION,
735
+ eventId
736
+ );
737
+
738
+ await updateDoc(eventRef, {
739
+ status,
740
+ updatedAt: serverTimestamp(),
741
+ });
742
+
743
+ console.log(`Updated event ${eventId} status to ${status}`);
744
+ } catch (error) {
745
+ console.error(`Error updating event ${eventId} status:`, error);
746
+ }
747
+ }
748
+
749
+ /**
750
+ * Creates a scheduled job to periodically sync external calendars
751
+ * Note: This would be implemented using Cloud Functions in a real application
752
+ * This is a sample implementation to show how it could be set up
753
+ * @param interval - Interval in hours
754
+ */
755
+ createScheduledSyncJob(interval: number = 3): void {
756
+ // This is a simplified implementation
757
+ // In a real application, you would use Cloud Functions with Pub/Sub
758
+ console.log(
759
+ `Setting up scheduled calendar sync job every ${interval} hours`
760
+ );
761
+
762
+ // Example cloud function implementation:
763
+ /*
764
+ // Using Firebase Cloud Functions (in index.ts)
765
+ export const syncExternalCalendars = functions.pubsub
766
+ .schedule('every 3 hours')
767
+ .onRun(async (context) => {
768
+ try {
769
+ const db = admin.firestore();
770
+ const auth = admin.auth();
771
+ const app = admin.app();
772
+
773
+ const calendarService = new CalendarServiceV2(db, auth, app);
774
+ await calendarService.synchronizeExternalCalendars();
775
+
776
+ console.log('External calendar sync completed successfully');
777
+ return null;
778
+ } catch (error) {
779
+ console.error('Error in calendar sync job:', error);
780
+ return null;
781
+ }
782
+ });
783
+ */
784
+ }
785
+
786
+ /**
787
+ * Searches for calendar events based on specified criteria.
788
+ *
789
+ * @param {SearchCalendarEventsParams} params - The search parameters.
790
+ * @param {SearchLocationEnum} params.searchLocation - The primary location to search (practitioner, patient, or clinic).
791
+ * @param {string} params.entityId - The ID of the entity (practitioner, patient, or clinic) to search within/for.
792
+ * @param {string} [params.clinicId] - Optional clinic ID to filter by.
793
+ * @param {string} [params.practitionerId] - Optional practitioner ID to filter by.
794
+ * @param {string} [params.patientId] - Optional patient ID to filter by.
795
+ * @param {string} [params.procedureId] - Optional procedure ID to filter by.
796
+ * @param {DateRange} [params.dateRange] - Optional date range to filter by (event start time).
797
+ * @param {CalendarEventStatus} [params.eventStatus] - Optional event status to filter by.
798
+ * @param {CalendarEventType} [params.eventType] - Optional event type to filter by.
799
+ * @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of matching calendar events.
800
+ * @throws {Error} If the search location requires an entity ID that is not provided.
801
+ */
802
+ async searchCalendarEvents(
803
+ params: SearchCalendarEventsParams
804
+ ): Promise<CalendarEvent[]> {
805
+ // Use the utility function to perform the search
806
+ return searchCalendarEventsUtil(this.db, params);
807
+ }
808
+
809
+ /**
810
+ * Gets a doctor's upcoming appointments for a specific date range
811
+ *
812
+ * @param {string} doctorId - ID of the practitioner
813
+ * @param {Date} startDate - Start date of the range
814
+ * @param {Date} endDate - End date of the range
815
+ * @param {CalendarEventStatus} [status] - Optional status filter (defaults to CONFIRMED)
816
+ * @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
817
+ */
818
+ async getPractitionerUpcomingAppointments(
819
+ doctorId: string,
820
+ startDate: Date,
821
+ endDate: Date,
822
+ status: CalendarEventStatus = CalendarEventStatus.CONFIRMED
823
+ ): Promise<CalendarEvent[]> {
824
+ // Create a date range for the query
825
+ const dateRange: DateRange = {
826
+ start: Timestamp.fromDate(startDate),
827
+ end: Timestamp.fromDate(endDate),
828
+ };
829
+
830
+ // Create the search parameters
831
+ const searchParams: SearchCalendarEventsParams = {
832
+ searchLocation: SearchLocationEnum.PRACTITIONER,
833
+ entityId: doctorId,
834
+ dateRange,
835
+ eventStatus: status,
836
+ eventType: CalendarEventType.APPOINTMENT,
837
+ };
838
+
839
+ // Search for the appointments
840
+ return this.searchCalendarEvents(searchParams);
841
+ }
842
+
843
+ /**
844
+ * Gets a patient's appointments for a specific date range
845
+ *
846
+ * @param {string} patientId - ID of the patient
847
+ * @param {Date} startDate - Start date of the range
848
+ * @param {Date} endDate - End date of the range
849
+ * @param {CalendarEventStatus} [status] - Optional status filter (defaults to all non-canceled appointments)
850
+ * @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
851
+ */
852
+ async getPatientAppointments(
853
+ patientId: string,
854
+ startDate: Date,
855
+ endDate: Date,
856
+ status?: CalendarEventStatus
857
+ ): Promise<CalendarEvent[]> {
858
+ // Create a date range for the query
859
+ const dateRange: DateRange = {
860
+ start: Timestamp.fromDate(startDate),
861
+ end: Timestamp.fromDate(endDate),
862
+ };
863
+
864
+ // Create the search parameters
865
+ const searchParams: SearchCalendarEventsParams = {
866
+ searchLocation: SearchLocationEnum.PATIENT,
867
+ entityId: patientId,
868
+ dateRange,
869
+ eventType: CalendarEventType.APPOINTMENT,
870
+ };
871
+
872
+ // Add status filter if provided
873
+ if (status) {
874
+ searchParams.eventStatus = status;
875
+ }
876
+
877
+ // Search for the appointments
878
+ return this.searchCalendarEvents(searchParams);
879
+ }
880
+
881
+ /**
882
+ * Gets all appointments for a clinic within a specific date range
883
+ *
884
+ * @param {string} clinicId - ID of the clinic
885
+ * @param {Date} startDate - Start date of the range
886
+ * @param {Date} endDate - End date of the range
887
+ * @param {string} [doctorId] - Optional doctor ID to filter by
888
+ * @param {CalendarEventStatus} [status] - Optional status filter
889
+ * @returns {Promise<CalendarEvent[]>} A promise that resolves to an array of appointments
890
+ */
891
+ async getClinicAppointments(
892
+ clinicId: string,
893
+ startDate: Date,
894
+ endDate: Date,
895
+ doctorId?: string,
896
+ status?: CalendarEventStatus
897
+ ): Promise<CalendarEvent[]> {
898
+ // Create a date range for the query
899
+ const dateRange: DateRange = {
900
+ start: Timestamp.fromDate(startDate),
901
+ end: Timestamp.fromDate(endDate),
902
+ };
903
+
904
+ // Create the search parameters
905
+ const searchParams: SearchCalendarEventsParams = {
906
+ searchLocation: SearchLocationEnum.CLINIC,
907
+ entityId: clinicId,
908
+ dateRange,
909
+ eventType: CalendarEventType.APPOINTMENT,
910
+ };
911
+
912
+ // Add doctor filter if provided
913
+ if (doctorId) {
914
+ searchParams.practitionerId = doctorId;
915
+ }
916
+
917
+ // Add status filter if provided
918
+ if (status) {
919
+ searchParams.eventStatus = status;
920
+ }
921
+
922
+ // Search for the appointments
923
+ return this.searchCalendarEvents(searchParams);
924
+ }
925
+
926
+ // #endregion
927
+
928
+ // #region Private Helper Methods
929
+
930
+ /**
931
+ * Validates appointment creation parameters
932
+ * @param params - Appointment parameters to validate
933
+ * @throws Error if validation fails
934
+ */
935
+ private async validateAppointmentParams(
936
+ params: CreateAppointmentParams
937
+ ): Promise<void> {
938
+ // TODO: Add custom validation logic after Zod schema validation
939
+ // - Check if doctor works at the clinic
940
+ // - Check if procedure is available at the clinic
941
+ // - Check if patient is eligible for the procedure
942
+ // - Validate time slot (15-minute increments)
943
+ // - Check clinic's subscription status
944
+ // - Check if auto-confirm is enabled
945
+
946
+ // Validate basic parameters using Zod schema
947
+ await createAppointmentSchema.parseAsync(params);
948
+ }
949
+
950
+ /**
951
+ * Validates if the event time falls within clinic working hours
952
+ * @param clinicId - ID of the clinic
953
+ * @param eventTime - Event time to validate
954
+ * @throws Error if validation fails
955
+ */
956
+ private async validateClinicWorkingHours(
957
+ clinicId: string,
958
+ eventTime: CalendarEventTime
959
+ ): Promise<void> {
960
+ // Get clinic working hours for the day
961
+ const startDate = eventTime.start.toDate();
962
+ const workingHours = await this.getClinicWorkingHours(clinicId, startDate);
963
+
964
+ if (workingHours.length === 0) {
965
+ throw new Error("Clinic is not open on this day");
966
+ }
967
+
968
+ // Find if the appointment time falls within any working hours slot
969
+ const startTime = startDate;
970
+ const endTime = eventTime.end.toDate();
971
+ const isWithinWorkingHours = workingHours.some((slot) => {
972
+ return slot.start <= startTime && slot.end >= endTime && slot.isAvailable;
973
+ });
974
+
975
+ if (!isWithinWorkingHours) {
976
+ throw new Error("Appointment time is outside clinic working hours");
977
+ }
978
+ }
979
+
980
+ /**
981
+ * Validates if the doctor is available during the event time
982
+ * @param doctorId - ID of the doctor
983
+ * @param eventTime - Event time to validate
984
+ * @param clinicId - ID of the clinic where the appointment is being booked
985
+ * @throws Error if validation fails
986
+ */
987
+ private async validateDoctorAvailability(
988
+ doctorId: string,
989
+ eventTime: CalendarEventTime,
990
+ clinicId: string
991
+ ): Promise<void> {
992
+ const startDate = eventTime.start.toDate();
993
+ const startTime = startDate;
994
+ const endTime = eventTime.end.toDate();
995
+
996
+ // Get doctor's document to check clinic-specific working hours
997
+ const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
998
+ const practitionerDoc = await getDoc(practitionerRef);
999
+
1000
+ if (!practitionerDoc.exists()) {
1001
+ throw new Error(`Doctor with ID ${doctorId} not found`);
1002
+ }
1003
+
1004
+ const practitioner = practitionerDoc.data();
1005
+
1006
+ // Check if doctor works at the specified clinic
1007
+ if (!practitioner.clinics.includes(clinicId)) {
1008
+ throw new Error("Doctor does not work at this clinic");
1009
+ }
1010
+
1011
+ // Get doctor's clinic-specific working hours
1012
+ const clinicWorkingHours = practitioner.clinicWorkingHours?.find(
1013
+ (hours: PractitionerClinicWorkingHours) =>
1014
+ hours.clinicId === clinicId && hours.isActive
1015
+ );
1016
+
1017
+ if (!clinicWorkingHours) {
1018
+ throw new Error("Doctor does not have working hours set for this clinic");
1019
+ }
1020
+
1021
+ // Get the day of the week (0 = Sunday, 1 = Monday, etc.)
1022
+ const dayOfWeek = startDate.getDay();
1023
+ const dayKey = [
1024
+ "sunday",
1025
+ "monday",
1026
+ "tuesday",
1027
+ "wednesday",
1028
+ "thursday",
1029
+ "friday",
1030
+ "saturday",
1031
+ ][dayOfWeek];
1032
+ const daySchedule = clinicWorkingHours.workingHours[dayKey];
1033
+
1034
+ if (!daySchedule) {
1035
+ throw new Error("Doctor is not working on this day at this clinic");
1036
+ }
1037
+
1038
+ // Convert working hours to Date objects for comparison
1039
+ const [startHour, startMinute] = daySchedule.start.split(":").map(Number);
1040
+ const [endHour, endMinute] = daySchedule.end.split(":").map(Number);
1041
+
1042
+ const scheduleStart = new Date(startDate);
1043
+ scheduleStart.setHours(startHour, startMinute, 0, 0);
1044
+
1045
+ const scheduleEnd = new Date(startDate);
1046
+ scheduleEnd.setHours(endHour, endMinute, 0, 0);
1047
+
1048
+ // Check if the appointment time is within doctor's working hours
1049
+ if (startTime < scheduleStart || endTime > scheduleEnd) {
1050
+ throw new Error(
1051
+ "Appointment time is outside doctor's working hours at this clinic"
1052
+ );
1053
+ }
1054
+
1055
+ // Get existing appointments
1056
+ const appointments = await this.getDoctorAppointments(doctorId, startDate);
1057
+
1058
+ // Check for overlapping appointments
1059
+ const hasOverlap = appointments.some((appointment) => {
1060
+ const appointmentStart = appointment.eventTime.start.toDate();
1061
+ const appointmentEnd = appointment.eventTime.end.toDate();
1062
+ return (
1063
+ (startTime >= appointmentStart && startTime < appointmentEnd) ||
1064
+ (endTime > appointmentStart && endTime <= appointmentEnd) ||
1065
+ (startTime <= appointmentStart && endTime >= appointmentEnd)
1066
+ );
1067
+ });
1068
+
1069
+ if (hasOverlap) {
1070
+ throw new Error("Doctor has another appointment during this time");
1071
+ }
1072
+ }
1073
+
1074
+ /**
1075
+ * Updates appointment status
1076
+ * @param appointmentId - ID of the appointment
1077
+ * @param clinicId - ID of the clinic
1078
+ * @param status - New status
1079
+ * @returns Updated calendar event
1080
+ */
1081
+ private async updateAppointmentStatus(
1082
+ appointmentId: string,
1083
+ clinicId: string,
1084
+ status: CalendarEventStatus
1085
+ ): Promise<CalendarEvent> {
1086
+ // Get the appointment
1087
+ const baseCollectionPath = `${CLINICS_COLLECTION}/${clinicId}/${CALENDAR_COLLECTION}`;
1088
+ const appointmentRef = doc(this.db, baseCollectionPath, appointmentId);
1089
+ const appointmentDoc = await getDoc(appointmentRef);
1090
+
1091
+ if (!appointmentDoc.exists()) {
1092
+ throw new Error(`Appointment with ID ${appointmentId} not found`);
1093
+ }
1094
+
1095
+ const appointment = appointmentDoc.data() as CalendarEvent;
1096
+
1097
+ // Validate that the appointment belongs to the specified clinic
1098
+ if (appointment.clinicBranchId !== clinicId) {
1099
+ throw new Error("Appointment does not belong to the specified clinic");
1100
+ }
1101
+
1102
+ // Validate the status transition
1103
+ this.validateStatusTransition(appointment.status, status);
1104
+
1105
+ // Update the appointment
1106
+ const updateParams: UpdateAppointmentParams = {
1107
+ appointmentId,
1108
+ clinicId,
1109
+ eventTime: appointment.eventTime,
1110
+ description: appointment.description || "",
1111
+ doctorId: appointment.practitionerProfileId || "",
1112
+ patientId: appointment.patientProfileId || "",
1113
+ status,
1114
+ };
1115
+
1116
+ // Validate update parameters
1117
+ await this.validateUpdatePermissions(updateParams);
1118
+
1119
+ // Update the appointment
1120
+ return this.updateAppointment(updateParams);
1121
+ }
1122
+
1123
+ /**
1124
+ * Validates status transition
1125
+ * @param currentStatus - Current status
1126
+ * @param newStatus - New status
1127
+ * @throws Error if transition is invalid
1128
+ */
1129
+ private validateStatusTransition(
1130
+ currentStatus: CalendarEventStatus,
1131
+ newStatus: CalendarEventStatus
1132
+ ): void {
1133
+ // Define valid status transitions
1134
+ const validTransitions: Record<CalendarEventStatus, CalendarEventStatus[]> =
1135
+ {
1136
+ [CalendarEventStatus.PENDING]: [
1137
+ CalendarEventStatus.CONFIRMED,
1138
+ CalendarEventStatus.REJECTED,
1139
+ CalendarEventStatus.CANCELED,
1140
+ ],
1141
+ [CalendarEventStatus.CONFIRMED]: [
1142
+ CalendarEventStatus.CANCELED,
1143
+ CalendarEventStatus.CHECKED_IN,
1144
+ CalendarEventStatus.COMPLETED,
1145
+ CalendarEventStatus.RESCHEDULED,
1146
+ CalendarEventStatus.NO_SHOW,
1147
+ ],
1148
+ [CalendarEventStatus.CHECKED_IN]: [
1149
+ CalendarEventStatus.IN_PROGRESS,
1150
+ CalendarEventStatus.COMPLETED,
1151
+ CalendarEventStatus.CANCELED,
1152
+ ],
1153
+ [CalendarEventStatus.IN_PROGRESS]: [
1154
+ CalendarEventStatus.COMPLETED,
1155
+ CalendarEventStatus.CANCELED,
1156
+ ],
1157
+ [CalendarEventStatus.REJECTED]: [],
1158
+ [CalendarEventStatus.CANCELED]: [],
1159
+ [CalendarEventStatus.RESCHEDULED]: [
1160
+ CalendarEventStatus.CONFIRMED,
1161
+ CalendarEventStatus.CANCELED,
1162
+ ],
1163
+ [CalendarEventStatus.COMPLETED]: [],
1164
+ [CalendarEventStatus.NO_SHOW]: [],
1165
+ };
1166
+
1167
+ // Check if transition is valid
1168
+ if (!validTransitions[currentStatus].includes(newStatus)) {
1169
+ throw new Error(
1170
+ `Invalid status transition from ${currentStatus} to ${newStatus}`
1171
+ );
1172
+ }
1173
+ }
1174
+
1175
+ /**
1176
+ * Syncs appointment with external calendars based on entity type and status
1177
+ * @param appointment - Calendar event to sync
1178
+ */
1179
+ private async syncAppointmentWithExternalCalendars(
1180
+ appointment: CalendarEvent
1181
+ ): Promise<void> {
1182
+ if (!appointment.practitionerProfileId || !appointment.patientProfileId) {
1183
+ return;
1184
+ }
1185
+
1186
+ try {
1187
+ // Get synced calendars for doctor and patient (no longer sync with clinic)
1188
+ const [doctorCalendars, patientCalendars] = await Promise.all([
1189
+ this.syncedCalendarsService.getPractitionerSyncedCalendars(
1190
+ appointment.practitionerProfileId
1191
+ ),
1192
+ this.syncedCalendarsService.getPatientSyncedCalendars(
1193
+ appointment.patientProfileId
1194
+ ),
1195
+ ]);
1196
+
1197
+ // Filter active calendars
1198
+ const activeDoctorCalendars = doctorCalendars.filter(
1199
+ (cal) => cal.isActive
1200
+ );
1201
+ const activePatientCalendars = patientCalendars.filter(
1202
+ (cal) => cal.isActive
1203
+ );
1204
+
1205
+ // Skip if there are no active calendars
1206
+ if (
1207
+ activeDoctorCalendars.length === 0 &&
1208
+ activePatientCalendars.length === 0
1209
+ ) {
1210
+ return;
1211
+ }
1212
+
1213
+ // Only sync INTERNAL events (those created within our system)
1214
+ if (appointment.syncStatus !== CalendarSyncStatus.INTERNAL) {
1215
+ return;
1216
+ }
1217
+
1218
+ // For doctors: Only sync CONFIRMED status events
1219
+ if (
1220
+ appointment.status === CalendarEventStatus.CONFIRMED &&
1221
+ activeDoctorCalendars.length > 0
1222
+ ) {
1223
+ await Promise.all(
1224
+ activeDoctorCalendars.map((calendar) =>
1225
+ this.syncEventToExternalCalendar(appointment, calendar, "doctor")
1226
+ )
1227
+ );
1228
+ }
1229
+
1230
+ // For patients: Sync all events EXCEPT CANCELED and REJECTED
1231
+ if (
1232
+ appointment.status !== CalendarEventStatus.CANCELED &&
1233
+ appointment.status !== CalendarEventStatus.REJECTED &&
1234
+ activePatientCalendars.length > 0
1235
+ ) {
1236
+ await Promise.all(
1237
+ activePatientCalendars.map((calendar) =>
1238
+ this.syncEventToExternalCalendar(appointment, calendar, "patient")
1239
+ )
1240
+ );
1241
+ }
1242
+ } catch (error) {
1243
+ console.error("Error syncing with external calendars:", error);
1244
+ // Don't throw error as this is not critical for appointment creation
1245
+ }
1246
+ }
1247
+
1248
+ /**
1249
+ * Syncs a single event to an external calendar
1250
+ * @param appointment - Calendar event to sync
1251
+ * @param calendar - External calendar to sync with
1252
+ * @param entityType - Type of entity owning the calendar
1253
+ */
1254
+ private async syncEventToExternalCalendar(
1255
+ appointment: CalendarEvent,
1256
+ calendar: any,
1257
+ entityType: "doctor" | "patient"
1258
+ ): Promise<void> {
1259
+ try {
1260
+ // Create a copy of the appointment to modify for external syncing
1261
+ const eventToSync = { ...appointment };
1262
+
1263
+ // Prepare event title based on status and entity type
1264
+ let eventTitle = appointment.eventName;
1265
+ const clinicName = appointment.clinicBranchInfo?.name || "Clinic";
1266
+
1267
+ // Format title appropriately
1268
+ if (entityType === "patient") {
1269
+ eventTitle = `[${appointment.status}] ${eventTitle} @ ${clinicName}`;
1270
+ } else {
1271
+ eventTitle = `${eventTitle} - Patient: ${
1272
+ appointment.patientProfileInfo?.fullName || "Unknown"
1273
+ } @ ${clinicName}`;
1274
+ }
1275
+
1276
+ // Update the event name for external sync
1277
+ eventToSync.eventName = eventTitle;
1278
+
1279
+ // Check if this event was previously synced with this calendar
1280
+ const existingSyncId = appointment.syncedCalendarEventId?.find(
1281
+ (sync) => sync.syncedCalendarProvider === calendar.provider
1282
+ )?.eventId;
1283
+
1284
+ // If we have a synced event ID, we should update the existing event
1285
+ // If not, create a new event
1286
+
1287
+ if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
1288
+ const result =
1289
+ await this.syncedCalendarsService.syncPractitionerEventsToGoogleCalendar(
1290
+ entityType === "doctor"
1291
+ ? appointment.practitionerProfileId!
1292
+ : appointment.patientProfileId!,
1293
+ calendar.id,
1294
+ [eventToSync],
1295
+ existingSyncId // Pass existing sync ID if we have one
1296
+ );
1297
+
1298
+ // If sync was successful and we've created a new event (no existing sync),
1299
+ // we should update our local event with the new sync ID
1300
+ if (result.success && result.eventIds?.length && !existingSyncId) {
1301
+ // Update the appointment with the new sync ID
1302
+ const newSyncEvent: SyncedCalendarEvent = {
1303
+ eventId: result.eventIds[0],
1304
+ syncedCalendarProvider: calendar.provider,
1305
+ syncedAt: Timestamp.now(),
1306
+ };
1307
+
1308
+ // Update the event in the database with the new sync ID
1309
+ await this.updateEventWithSyncId(
1310
+ entityType === "doctor"
1311
+ ? appointment.practitionerProfileId!
1312
+ : appointment.patientProfileId!,
1313
+ entityType,
1314
+ appointment.id,
1315
+ newSyncEvent
1316
+ );
1317
+ }
1318
+ }
1319
+ } catch (error) {
1320
+ console.error(`Error syncing with ${entityType}'s calendar:`, error);
1321
+ // Don't throw error as this is not critical
1322
+ }
1323
+ }
1324
+
1325
+ /**
1326
+ * Updates an event with a new sync ID
1327
+ * @param entityId - ID of the entity (doctor or patient)
1328
+ * @param entityType - Type of entity
1329
+ * @param eventId - ID of the event
1330
+ * @param syncEvent - Sync event information
1331
+ */
1332
+ private async updateEventWithSyncId(
1333
+ entityId: string,
1334
+ entityType: "doctor" | "patient",
1335
+ eventId: string,
1336
+ syncEvent: SyncedCalendarEvent
1337
+ ): Promise<void> {
1338
+ try {
1339
+ // Determine the collection path based on entity type
1340
+ const collectionPath =
1341
+ entityType === "doctor"
1342
+ ? `${PRACTITIONERS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`
1343
+ : `${PATIENTS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`;
1344
+
1345
+ // Get the event reference
1346
+ const eventRef = doc(this.db, collectionPath, eventId);
1347
+ const eventDoc = await getDoc(eventRef);
1348
+
1349
+ if (eventDoc.exists()) {
1350
+ const event = eventDoc.data() as CalendarEvent;
1351
+ const syncIds = [...(event.syncedCalendarEventId || [])];
1352
+
1353
+ // Check if we already have this sync ID
1354
+ const existingSyncIndex = syncIds.findIndex(
1355
+ (sync) =>
1356
+ sync.syncedCalendarProvider === syncEvent.syncedCalendarProvider
1357
+ );
1358
+
1359
+ if (existingSyncIndex >= 0) {
1360
+ // Update the existing sync ID
1361
+ syncIds[existingSyncIndex] = syncEvent;
1362
+ } else {
1363
+ // Add the new sync ID
1364
+ syncIds.push(syncEvent);
1365
+ }
1366
+
1367
+ // Update the event
1368
+ await updateDoc(eventRef, {
1369
+ syncedCalendarEventId: syncIds,
1370
+ updatedAt: serverTimestamp(),
1371
+ });
1372
+
1373
+ console.log(
1374
+ `Updated event ${eventId} with sync ID ${syncEvent.eventId}`
1375
+ );
1376
+ }
1377
+ } catch (error) {
1378
+ console.error("Error updating event with sync ID:", error);
1379
+ }
1380
+ }
1381
+
1382
+ /**
1383
+ * Validates update permissions and parameters
1384
+ * @param params - Update parameters to validate
1385
+ */
1386
+ private async validateUpdatePermissions(
1387
+ params: UpdateAppointmentParams
1388
+ ): Promise<void> {
1389
+ // TODO: Add custom validation logic after Zod schema validation
1390
+ // - Check if user has permission to update the appointment
1391
+ // - Check if the appointment exists
1392
+ // - Check if the new status transition is valid
1393
+ // - Check if the new time slot is valid
1394
+ // - Validate against clinic's business rules
1395
+
1396
+ // Validate basic parameters using Zod schema
1397
+ await updateAppointmentSchema.parseAsync(params);
1398
+ }
1399
+
1400
+ /**
1401
+ * Gets clinic working hours for a specific date
1402
+ * @param clinicId - ID of the clinic
1403
+ * @param date - Date to get working hours for
1404
+ * @returns Working hours for the clinic
1405
+ */
1406
+ private async getClinicWorkingHours(
1407
+ clinicId: string,
1408
+ date: Date
1409
+ ): Promise<TimeSlot[]> {
1410
+ // Get clinic document
1411
+ const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
1412
+ const clinicDoc = await getDoc(clinicRef);
1413
+
1414
+ if (!clinicDoc.exists()) {
1415
+ throw new Error(`Clinic with ID ${clinicId} not found`);
1416
+ }
1417
+
1418
+ // TODO: Implement proper working hours retrieval from clinic data model
1419
+ // For now, return default working hours (9 AM - 5 PM)
1420
+ const workingHours: TimeSlot[] = [];
1421
+ const dayOfWeek = date.getDay();
1422
+
1423
+ // Skip weekends (0 = Sunday, 6 = Saturday)
1424
+ if (dayOfWeek === 0 || dayOfWeek === 6) {
1425
+ return workingHours;
1426
+ }
1427
+
1428
+ // Create working hours slot (9 AM - 5 PM)
1429
+ const workingDate = new Date(date);
1430
+ workingDate.setHours(9, 0, 0, 0);
1431
+ const startTime = new Date(workingDate);
1432
+
1433
+ workingDate.setHours(17, 0, 0, 0);
1434
+ const endTime = new Date(workingDate);
1435
+
1436
+ workingHours.push({
1437
+ start: startTime,
1438
+ end: endTime,
1439
+ isAvailable: true,
1440
+ });
1441
+
1442
+ return workingHours;
1443
+ }
1444
+
1445
+ /**
1446
+ * Gets doctor's schedule for a specific date
1447
+ * @param doctorId - ID of the doctor
1448
+ * @param date - Date to get schedule for
1449
+ * @returns Doctor's schedule
1450
+ */
1451
+ private async getDoctorSchedule(
1452
+ doctorId: string,
1453
+ date: Date
1454
+ ): Promise<TimeSlot[]> {
1455
+ // Get doctor document
1456
+ const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
1457
+ const practitionerDoc = await getDoc(practitionerRef);
1458
+
1459
+ if (!practitionerDoc.exists()) {
1460
+ throw new Error(`Doctor with ID ${doctorId} not found`);
1461
+ }
1462
+
1463
+ // TODO: Implement proper schedule retrieval from practitioner data model
1464
+ // For now, return default schedule (9 AM - 5 PM)
1465
+ const schedule: TimeSlot[] = [];
1466
+ const dayOfWeek = date.getDay();
1467
+
1468
+ // Skip weekends (0 = Sunday, 6 = Saturday)
1469
+ if (dayOfWeek === 0 || dayOfWeek === 6) {
1470
+ return schedule;
1471
+ }
1472
+
1473
+ // Create schedule slot (9 AM - 5 PM)
1474
+ const scheduleDate = new Date(date);
1475
+ scheduleDate.setHours(9, 0, 0, 0);
1476
+ const startTime = new Date(scheduleDate);
1477
+
1478
+ scheduleDate.setHours(17, 0, 0, 0);
1479
+ const endTime = new Date(scheduleDate);
1480
+
1481
+ schedule.push({
1482
+ start: startTime,
1483
+ end: endTime,
1484
+ isAvailable: true,
1485
+ });
1486
+
1487
+ return schedule;
1488
+ }
1489
+
1490
+ /**
1491
+ * Gets doctor's appointments for a specific date
1492
+ * @param doctorId - ID of the doctor
1493
+ * @param date - Date to get appointments for
1494
+ * @returns Array of calendar events
1495
+ */
1496
+ private async getDoctorAppointments(
1497
+ doctorId: string,
1498
+ date: Date
1499
+ ): Promise<CalendarEvent[]> {
1500
+ // Create start and end timestamps for the day
1501
+ const startOfDay = new Date(date);
1502
+ startOfDay.setHours(0, 0, 0, 0);
1503
+ const endOfDay = new Date(date);
1504
+ endOfDay.setHours(23, 59, 59, 999);
1505
+
1506
+ // Query appointments for the doctor on the specified date
1507
+ const appointmentsRef = collection(this.db, CALENDAR_COLLECTION);
1508
+ const q = query(
1509
+ appointmentsRef,
1510
+ where("practitionerProfileId", "==", doctorId),
1511
+ where("eventTime.start", ">=", Timestamp.fromDate(startOfDay)),
1512
+ where("eventTime.start", "<=", Timestamp.fromDate(endOfDay)),
1513
+ where("status", "in", [
1514
+ CalendarEventStatus.CONFIRMED,
1515
+ CalendarEventStatus.PENDING,
1516
+ ])
1517
+ );
1518
+
1519
+ const querySnapshot = await getDocs(q);
1520
+ return querySnapshot.docs.map((doc) => doc.data() as CalendarEvent);
1521
+ }
1522
+
1523
+ /**
1524
+ * Calculates available time slots based on working hours, schedule and existing appointments
1525
+ * @param workingHours - Clinic working hours
1526
+ * @param doctorSchedule - Doctor's schedule
1527
+ * @param existingAppointments - Existing appointments
1528
+ * @returns Array of available time slots
1529
+ */
1530
+ private calculateAvailableSlots(
1531
+ workingHours: TimeSlot[],
1532
+ doctorSchedule: TimeSlot[],
1533
+ existingAppointments: CalendarEvent[]
1534
+ ): TimeSlot[] {
1535
+ const availableSlots: TimeSlot[] = [];
1536
+
1537
+ // First, find overlapping time slots between clinic hours and doctor schedule
1538
+ for (const workingHour of workingHours) {
1539
+ for (const scheduleSlot of doctorSchedule) {
1540
+ // Find overlap between working hours and doctor schedule
1541
+ const overlapStart = new Date(
1542
+ Math.max(workingHour.start.getTime(), scheduleSlot.start.getTime())
1543
+ );
1544
+ const overlapEnd = new Date(
1545
+ Math.min(workingHour.end.getTime(), scheduleSlot.end.getTime())
1546
+ );
1547
+
1548
+ // If there is an overlap and both slots are available
1549
+ if (
1550
+ overlapStart < overlapEnd &&
1551
+ workingHour.isAvailable &&
1552
+ scheduleSlot.isAvailable
1553
+ ) {
1554
+ // Create 15-minute slots within the overlap period
1555
+ let slotStart = new Date(overlapStart);
1556
+ while (slotStart < overlapEnd) {
1557
+ const slotEnd = new Date(
1558
+ slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
1559
+ );
1560
+
1561
+ // Check if this slot overlaps with any existing appointments
1562
+ const hasOverlap = existingAppointments.some((appointment) => {
1563
+ const appointmentStart = appointment.eventTime.start.toDate();
1564
+ const appointmentEnd = appointment.eventTime.end.toDate();
1565
+ return (
1566
+ (slotStart >= appointmentStart && slotStart < appointmentEnd) ||
1567
+ (slotEnd > appointmentStart && slotEnd <= appointmentEnd)
1568
+ );
1569
+ });
1570
+
1571
+ if (!hasOverlap && slotEnd <= overlapEnd) {
1572
+ availableSlots.push({
1573
+ start: new Date(slotStart),
1574
+ end: new Date(slotEnd),
1575
+ isAvailable: true,
1576
+ });
1577
+ }
1578
+
1579
+ // Move to next slot
1580
+ slotStart = new Date(
1581
+ slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
1582
+ );
1583
+ }
1584
+ }
1585
+ }
1586
+ }
1587
+
1588
+ return availableSlots;
1589
+ }
1590
+
1591
+ /**
1592
+ * Fetches and creates info cards for clinic, doctor, and patient profiles
1593
+ * @param clinicId - ID of the clinic
1594
+ * @param doctorId - ID of the doctor
1595
+ * @param patientId - ID of the patient
1596
+ * @returns Object containing info cards for all profiles
1597
+ */
1598
+ private async fetchProfileInfoCards(
1599
+ clinicId: string,
1600
+ doctorId: string,
1601
+ patientId: string
1602
+ ): Promise<{
1603
+ clinicInfo: ClinicInfo | null;
1604
+ practitionerInfo: PractitionerProfileInfo | null;
1605
+ patientInfo: PatientProfileInfo | null;
1606
+ }> {
1607
+ try {
1608
+ // Fetch all profiles concurrently
1609
+ const [clinicDoc, practitionerDoc, patientDoc, patientSensitiveInfoDoc] =
1610
+ await Promise.all([
1611
+ getDoc(doc(this.db, CLINICS_COLLECTION, clinicId)),
1612
+ getDoc(doc(this.db, PRACTITIONERS_COLLECTION, doctorId)),
1613
+ getDoc(doc(this.db, PATIENTS_COLLECTION, patientId)),
1614
+ getDoc(
1615
+ doc(
1616
+ this.db,
1617
+ PATIENTS_COLLECTION,
1618
+ patientId,
1619
+ PATIENT_SENSITIVE_INFO_COLLECTION,
1620
+ patientId
1621
+ )
1622
+ ),
1623
+ ]);
1624
+
1625
+ // Create info cards
1626
+ const clinicInfo: ClinicInfo | null = clinicDoc.exists()
1627
+ ? {
1628
+ id: clinicDoc.id,
1629
+ featuredPhoto: clinicDoc.data().featuredPhoto || "",
1630
+ name: clinicDoc.data().name,
1631
+ description: clinicDoc.data().description || "",
1632
+ location: clinicDoc.data().location,
1633
+ contactInfo: clinicDoc.data().contactInfo,
1634
+ }
1635
+ : null;
1636
+
1637
+ const practitionerInfo: PractitionerProfileInfo | null =
1638
+ practitionerDoc.exists()
1639
+ ? {
1640
+ id: practitionerDoc.id,
1641
+ practitionerPhoto:
1642
+ practitionerDoc.data().basicInfo.profileImageUrl || null,
1643
+ name: `${practitionerDoc.data().basicInfo.firstName} ${
1644
+ practitionerDoc.data().basicInfo.lastName
1645
+ }`,
1646
+ email: practitionerDoc.data().basicInfo.email,
1647
+ phone: practitionerDoc.data().basicInfo.phoneNumber || null,
1648
+ certification: practitionerDoc.data().certification,
1649
+ }
1650
+ : null;
1651
+
1652
+ // First try to get data from sensitive-info subcollection
1653
+ let patientInfo: PatientProfileInfo | null = null;
1654
+
1655
+ if (patientSensitiveInfoDoc.exists()) {
1656
+ const sensitiveData = patientSensitiveInfoDoc.data();
1657
+ patientInfo = {
1658
+ id: patientId,
1659
+ fullName: `${sensitiveData.firstName} ${sensitiveData.lastName}`,
1660
+ email: sensitiveData.email || "",
1661
+ phone: sensitiveData.phoneNumber || null,
1662
+ dateOfBirth: sensitiveData.dateOfBirth || Timestamp.now(),
1663
+ gender: sensitiveData.gender || Gender.OTHER,
1664
+ };
1665
+ } else if (patientDoc.exists()) {
1666
+ // Fall back to patient document if sensitive info not available
1667
+ patientInfo = {
1668
+ id: patientDoc.id,
1669
+ fullName: patientDoc.data().displayName,
1670
+ email: patientDoc.data().contactInfo?.email || "",
1671
+ phone: patientDoc.data().phoneNumber || null,
1672
+ dateOfBirth: patientDoc.data().dateOfBirth || Timestamp.now(),
1673
+ gender: patientDoc.data().gender || Gender.OTHER,
1674
+ };
1675
+ }
1676
+
1677
+ return {
1678
+ clinicInfo,
1679
+ practitionerInfo,
1680
+ patientInfo,
1681
+ };
1682
+ } catch (error) {
1683
+ console.error("Error fetching profile info cards:", error);
1684
+ return {
1685
+ clinicInfo: null,
1686
+ practitionerInfo: null,
1687
+ patientInfo: null,
1688
+ };
1689
+ }
1690
+ }
1691
+
1692
+ // #endregion
1693
+ }