@blackcode_sa/metaestetics-api 1.13.4 → 1.13.5

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 (293) hide show
  1. package/dist/admin/index.d.mts +15 -28
  2. package/dist/admin/index.d.ts +15 -28
  3. package/dist/index.d.mts +16 -29
  4. package/dist/index.d.ts +16 -29
  5. package/dist/index.js +1 -0
  6. package/dist/index.mjs +1 -0
  7. package/package.json +121 -119
  8. package/src/__mocks__/firstore.ts +10 -10
  9. package/src/admin/aggregation/README.md +79 -79
  10. package/src/admin/aggregation/appointment/README.md +128 -128
  11. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1984 -1984
  12. package/src/admin/aggregation/appointment/index.ts +1 -1
  13. package/src/admin/aggregation/clinic/README.md +52 -52
  14. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
  15. package/src/admin/aggregation/clinic/index.ts +1 -1
  16. package/src/admin/aggregation/forms/README.md +13 -13
  17. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  18. package/src/admin/aggregation/forms/index.ts +1 -1
  19. package/src/admin/aggregation/index.ts +8 -8
  20. package/src/admin/aggregation/patient/README.md +27 -27
  21. package/src/admin/aggregation/patient/index.ts +1 -1
  22. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  23. package/src/admin/aggregation/practitioner/README.md +42 -42
  24. package/src/admin/aggregation/practitioner/index.ts +1 -1
  25. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  26. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  27. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  28. package/src/admin/aggregation/procedure/README.md +43 -43
  29. package/src/admin/aggregation/procedure/index.ts +1 -1
  30. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  31. package/src/admin/aggregation/reviews/index.ts +1 -1
  32. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
  33. package/src/admin/analytics/analytics.admin.service.ts +278 -278
  34. package/src/admin/analytics/index.ts +2 -2
  35. package/src/admin/booking/README.md +125 -125
  36. package/src/admin/booking/booking.admin.ts +1037 -1037
  37. package/src/admin/booking/booking.calculator.ts +712 -712
  38. package/src/admin/booking/booking.types.ts +59 -59
  39. package/src/admin/booking/index.ts +3 -3
  40. package/src/admin/booking/timezones-problem.md +185 -185
  41. package/src/admin/calendar/README.md +7 -7
  42. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  43. package/src/admin/calendar/index.ts +1 -1
  44. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  45. package/src/admin/documentation-templates/index.ts +1 -1
  46. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  47. package/src/admin/free-consultation/index.ts +1 -1
  48. package/src/admin/index.ts +81 -81
  49. package/src/admin/logger/index.ts +78 -78
  50. package/src/admin/mailing/README.md +95 -95
  51. package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
  52. package/src/admin/mailing/appointment/index.ts +1 -1
  53. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  54. package/src/admin/mailing/base.mailing.service.ts +208 -208
  55. package/src/admin/mailing/index.ts +3 -3
  56. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  57. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  58. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  59. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  60. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  61. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  62. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  63. package/src/admin/notifications/index.ts +1 -1
  64. package/src/admin/notifications/notifications.admin.ts +710 -710
  65. package/src/admin/requirements/README.md +128 -128
  66. package/src/admin/requirements/index.ts +1 -1
  67. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  68. package/src/admin/users/index.ts +1 -1
  69. package/src/admin/users/user-profile.admin.ts +405 -405
  70. package/src/backoffice/constants/certification.constants.ts +13 -13
  71. package/src/backoffice/constants/index.ts +1 -1
  72. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  73. package/src/backoffice/errors/index.ts +1 -1
  74. package/src/backoffice/expo-safe/README.md +26 -26
  75. package/src/backoffice/expo-safe/index.ts +41 -41
  76. package/src/backoffice/index.ts +5 -5
  77. package/src/backoffice/services/FIXES_README.md +102 -102
  78. package/src/backoffice/services/README.md +57 -57
  79. package/src/backoffice/services/analytics.service.proposal.md +863 -863
  80. package/src/backoffice/services/analytics.service.summary.md +143 -143
  81. package/src/backoffice/services/brand.service.ts +256 -256
  82. package/src/backoffice/services/category.service.ts +384 -384
  83. package/src/backoffice/services/constants.service.ts +385 -385
  84. package/src/backoffice/services/documentation-template.service.ts +202 -202
  85. package/src/backoffice/services/index.ts +10 -10
  86. package/src/backoffice/services/migrate-products.ts +116 -116
  87. package/src/backoffice/services/product.service.ts +553 -553
  88. package/src/backoffice/services/requirement.service.ts +235 -235
  89. package/src/backoffice/services/subcategory.service.ts +461 -461
  90. package/src/backoffice/services/technology.service.ts +1151 -1151
  91. package/src/backoffice/types/README.md +12 -12
  92. package/src/backoffice/types/admin-constants.types.ts +69 -69
  93. package/src/backoffice/types/brand.types.ts +29 -29
  94. package/src/backoffice/types/category.types.ts +67 -67
  95. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  96. package/src/backoffice/types/index.ts +10 -10
  97. package/src/backoffice/types/procedure-product.types.ts +38 -38
  98. package/src/backoffice/types/product.types.ts +240 -240
  99. package/src/backoffice/types/requirement.types.ts +63 -63
  100. package/src/backoffice/types/static/README.md +18 -18
  101. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  102. package/src/backoffice/types/static/certification.types.ts +37 -37
  103. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  104. package/src/backoffice/types/static/index.ts +6 -6
  105. package/src/backoffice/types/static/pricing.types.ts +16 -16
  106. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  107. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  108. package/src/backoffice/types/subcategory.types.ts +34 -34
  109. package/src/backoffice/types/technology.types.ts +168 -168
  110. package/src/backoffice/validations/index.ts +1 -1
  111. package/src/backoffice/validations/schemas.ts +164 -164
  112. package/src/config/__mocks__/firebase.ts +99 -99
  113. package/src/config/firebase.ts +78 -78
  114. package/src/config/index.ts +9 -9
  115. package/src/errors/auth.error.ts +6 -6
  116. package/src/errors/auth.errors.ts +200 -200
  117. package/src/errors/clinic.errors.ts +32 -32
  118. package/src/errors/firebase.errors.ts +47 -47
  119. package/src/errors/user.errors.ts +99 -99
  120. package/src/index.backup.ts +407 -407
  121. package/src/index.ts +6 -6
  122. package/src/locales/en.ts +31 -31
  123. package/src/recommender/admin/index.ts +1 -1
  124. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  125. package/src/recommender/front/index.ts +1 -1
  126. package/src/recommender/front/services/onboarding.service.ts +5 -5
  127. package/src/recommender/front/services/recommender.service.ts +3 -3
  128. package/src/recommender/index.ts +1 -1
  129. package/src/services/PATIENTAUTH.MD +197 -197
  130. package/src/services/README.md +106 -106
  131. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  132. package/src/services/__tests__/auth/auth.setup.ts +293 -293
  133. package/src/services/__tests__/auth.service.test.ts +346 -346
  134. package/src/services/__tests__/base.service.test.ts +77 -77
  135. package/src/services/__tests__/user.service.test.ts +528 -528
  136. package/src/services/analytics/ARCHITECTURE.md +199 -199
  137. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
  138. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
  139. package/src/services/analytics/QUICK_START.md +393 -393
  140. package/src/services/analytics/README.md +304 -304
  141. package/src/services/analytics/SUMMARY.md +141 -141
  142. package/src/services/analytics/TRENDS.md +380 -380
  143. package/src/services/analytics/USAGE_GUIDE.md +518 -518
  144. package/src/services/analytics/analytics-cloud.service.ts +222 -222
  145. package/src/services/analytics/analytics.service.ts +2142 -2142
  146. package/src/services/analytics/index.ts +4 -4
  147. package/src/services/analytics/review-analytics.service.ts +941 -941
  148. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
  149. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
  150. package/src/services/analytics/utils/grouping.utils.ts +434 -434
  151. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
  152. package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
  153. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
  154. package/src/services/appointment/README.md +17 -17
  155. package/src/services/appointment/appointment.service.ts +2558 -2558
  156. package/src/services/appointment/index.ts +1 -1
  157. package/src/services/appointment/utils/appointment.utils.ts +552 -552
  158. package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
  159. package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
  160. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  161. package/src/services/appointment/utils/zone-management.utils.ts +353 -353
  162. package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
  163. package/src/services/auth/auth.service.ts +989 -989
  164. package/src/services/auth/auth.v2.service.ts +961 -961
  165. package/src/services/auth/index.ts +7 -7
  166. package/src/services/auth/utils/error.utils.ts +90 -90
  167. package/src/services/auth/utils/firebase.utils.ts +49 -49
  168. package/src/services/auth/utils/index.ts +21 -21
  169. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  170. package/src/services/base.service.ts +41 -41
  171. package/src/services/calendar/calendar.service.ts +1077 -1077
  172. package/src/services/calendar/calendar.v2.service.ts +1683 -1683
  173. package/src/services/calendar/calendar.v3.service.ts +313 -313
  174. package/src/services/calendar/externalCalendar.service.ts +178 -178
  175. package/src/services/calendar/index.ts +5 -5
  176. package/src/services/calendar/synced-calendars.service.ts +743 -743
  177. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  178. package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
  179. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  180. package/src/services/calendar/utils/docs.utils.ts +157 -157
  181. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  182. package/src/services/calendar/utils/index.ts +8 -8
  183. package/src/services/calendar/utils/patient.utils.ts +198 -198
  184. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  185. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  186. package/src/services/clinic/README.md +204 -204
  187. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
  188. package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
  189. package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
  190. package/src/services/clinic/billing-transactions.service.ts +217 -217
  191. package/src/services/clinic/clinic-admin.service.ts +202 -202
  192. package/src/services/clinic/clinic-group.service.ts +310 -310
  193. package/src/services/clinic/clinic.service.ts +708 -708
  194. package/src/services/clinic/index.ts +5 -5
  195. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  196. package/src/services/clinic/utils/admin.utils.ts +551 -551
  197. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  198. package/src/services/clinic/utils/clinic.utils.ts +949 -949
  199. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  200. package/src/services/clinic/utils/filter.utils.ts +446 -446
  201. package/src/services/clinic/utils/index.ts +11 -11
  202. package/src/services/clinic/utils/photos.utils.ts +188 -188
  203. package/src/services/clinic/utils/search.utils.ts +84 -84
  204. package/src/services/clinic/utils/tag.utils.ts +124 -124
  205. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  206. package/src/services/documentation-templates/filled-document.service.ts +587 -587
  207. package/src/services/documentation-templates/index.ts +2 -2
  208. package/src/services/index.ts +14 -14
  209. package/src/services/media/index.ts +1 -1
  210. package/src/services/media/media.service.ts +418 -418
  211. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  212. package/src/services/notifications/index.ts +1 -1
  213. package/src/services/notifications/notification.service.ts +215 -215
  214. package/src/services/patient/README.md +48 -48
  215. package/src/services/patient/To-Do.md +43 -43
  216. package/src/services/patient/__tests__/patient.service.test.ts +294 -294
  217. package/src/services/patient/index.ts +2 -2
  218. package/src/services/patient/patient.service.ts +883 -883
  219. package/src/services/patient/patientRequirements.service.ts +285 -285
  220. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  221. package/src/services/patient/utils/clinic.utils.ts +80 -80
  222. package/src/services/patient/utils/docs.utils.ts +142 -142
  223. package/src/services/patient/utils/index.ts +9 -9
  224. package/src/services/patient/utils/location.utils.ts +126 -126
  225. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  226. package/src/services/patient/utils/medical.utils.ts +458 -458
  227. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  228. package/src/services/patient/utils/profile.utils.ts +510 -510
  229. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  230. package/src/services/patient/utils/token.utils.ts +211 -211
  231. package/src/services/practitioner/README.md +145 -145
  232. package/src/services/practitioner/index.ts +1 -1
  233. package/src/services/practitioner/practitioner.service.ts +1742 -1742
  234. package/src/services/procedure/README.md +163 -163
  235. package/src/services/procedure/index.ts +1 -1
  236. package/src/services/procedure/procedure.service.ts +2200 -2200
  237. package/src/services/reviews/index.ts +1 -1
  238. package/src/services/reviews/reviews.service.ts +734 -734
  239. package/src/services/user/index.ts +1 -1
  240. package/src/services/user/user.service.ts +489 -489
  241. package/src/services/user/user.v2.service.ts +466 -466
  242. package/src/types/analytics/analytics.types.ts +597 -597
  243. package/src/types/analytics/grouped-analytics.types.ts +173 -173
  244. package/src/types/analytics/index.ts +4 -4
  245. package/src/types/analytics/stored-analytics.types.ts +137 -137
  246. package/src/types/appointment/index.ts +480 -480
  247. package/src/types/calendar/index.ts +258 -258
  248. package/src/types/calendar/synced-calendar.types.ts +66 -66
  249. package/src/types/clinic/index.ts +498 -489
  250. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  251. package/src/types/clinic/preferences.types.ts +159 -159
  252. package/src/types/clinic/to-do +3 -3
  253. package/src/types/documentation-templates/index.ts +308 -308
  254. package/src/types/index.ts +47 -47
  255. package/src/types/notifications/README.md +77 -77
  256. package/src/types/notifications/index.ts +286 -286
  257. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  258. package/src/types/patient/allergies.ts +58 -58
  259. package/src/types/patient/index.ts +275 -275
  260. package/src/types/patient/medical-info.types.ts +152 -152
  261. package/src/types/patient/patient-requirements.ts +92 -92
  262. package/src/types/patient/token.types.ts +61 -61
  263. package/src/types/practitioner/index.ts +206 -206
  264. package/src/types/procedure/index.ts +181 -181
  265. package/src/types/profile/index.ts +39 -39
  266. package/src/types/reviews/index.ts +132 -132
  267. package/src/types/tz-lookup.d.ts +4 -4
  268. package/src/types/user/index.ts +38 -38
  269. package/src/utils/TIMESTAMPS.md +176 -176
  270. package/src/utils/TimestampUtils.ts +241 -241
  271. package/src/utils/index.ts +1 -1
  272. package/src/validations/appointment.schema.ts +574 -574
  273. package/src/validations/calendar.schema.ts +225 -225
  274. package/src/validations/clinic.schema.ts +494 -493
  275. package/src/validations/common.schema.ts +25 -25
  276. package/src/validations/documentation-templates/index.ts +1 -1
  277. package/src/validations/documentation-templates/template.schema.ts +220 -220
  278. package/src/validations/documentation-templates.schema.ts +10 -10
  279. package/src/validations/index.ts +20 -20
  280. package/src/validations/media.schema.ts +10 -10
  281. package/src/validations/notification.schema.ts +90 -90
  282. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  283. package/src/validations/patient/medical-info.schema.ts +125 -125
  284. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  285. package/src/validations/patient/token.schema.ts +29 -29
  286. package/src/validations/patient.schema.ts +217 -217
  287. package/src/validations/practitioner.schema.ts +222 -222
  288. package/src/validations/procedure-product.schema.ts +41 -41
  289. package/src/validations/procedure.schema.ts +124 -124
  290. package/src/validations/profile-info.schema.ts +41 -41
  291. package/src/validations/reviews.schema.ts +195 -195
  292. package/src/validations/schemas.ts +104 -104
  293. package/src/validations/shared.schema.ts +78 -78
@@ -1,1683 +1,1683 @@
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.COMPLETED,
1144
- CalendarEventStatus.RESCHEDULED,
1145
- CalendarEventStatus.NO_SHOW,
1146
- ],
1147
- [CalendarEventStatus.REJECTED]: [],
1148
- [CalendarEventStatus.CANCELED]: [],
1149
- [CalendarEventStatus.RESCHEDULED]: [
1150
- CalendarEventStatus.CONFIRMED,
1151
- CalendarEventStatus.CANCELED,
1152
- ],
1153
- [CalendarEventStatus.COMPLETED]: [],
1154
- [CalendarEventStatus.NO_SHOW]: [],
1155
- };
1156
-
1157
- // Check if transition is valid
1158
- if (!validTransitions[currentStatus].includes(newStatus)) {
1159
- throw new Error(
1160
- `Invalid status transition from ${currentStatus} to ${newStatus}`
1161
- );
1162
- }
1163
- }
1164
-
1165
- /**
1166
- * Syncs appointment with external calendars based on entity type and status
1167
- * @param appointment - Calendar event to sync
1168
- */
1169
- private async syncAppointmentWithExternalCalendars(
1170
- appointment: CalendarEvent
1171
- ): Promise<void> {
1172
- if (!appointment.practitionerProfileId || !appointment.patientProfileId) {
1173
- return;
1174
- }
1175
-
1176
- try {
1177
- // Get synced calendars for doctor and patient (no longer sync with clinic)
1178
- const [doctorCalendars, patientCalendars] = await Promise.all([
1179
- this.syncedCalendarsService.getPractitionerSyncedCalendars(
1180
- appointment.practitionerProfileId
1181
- ),
1182
- this.syncedCalendarsService.getPatientSyncedCalendars(
1183
- appointment.patientProfileId
1184
- ),
1185
- ]);
1186
-
1187
- // Filter active calendars
1188
- const activeDoctorCalendars = doctorCalendars.filter(
1189
- (cal) => cal.isActive
1190
- );
1191
- const activePatientCalendars = patientCalendars.filter(
1192
- (cal) => cal.isActive
1193
- );
1194
-
1195
- // Skip if there are no active calendars
1196
- if (
1197
- activeDoctorCalendars.length === 0 &&
1198
- activePatientCalendars.length === 0
1199
- ) {
1200
- return;
1201
- }
1202
-
1203
- // Only sync INTERNAL events (those created within our system)
1204
- if (appointment.syncStatus !== CalendarSyncStatus.INTERNAL) {
1205
- return;
1206
- }
1207
-
1208
- // For doctors: Only sync CONFIRMED status events
1209
- if (
1210
- appointment.status === CalendarEventStatus.CONFIRMED &&
1211
- activeDoctorCalendars.length > 0
1212
- ) {
1213
- await Promise.all(
1214
- activeDoctorCalendars.map((calendar) =>
1215
- this.syncEventToExternalCalendar(appointment, calendar, "doctor")
1216
- )
1217
- );
1218
- }
1219
-
1220
- // For patients: Sync all events EXCEPT CANCELED and REJECTED
1221
- if (
1222
- appointment.status !== CalendarEventStatus.CANCELED &&
1223
- appointment.status !== CalendarEventStatus.REJECTED &&
1224
- activePatientCalendars.length > 0
1225
- ) {
1226
- await Promise.all(
1227
- activePatientCalendars.map((calendar) =>
1228
- this.syncEventToExternalCalendar(appointment, calendar, "patient")
1229
- )
1230
- );
1231
- }
1232
- } catch (error) {
1233
- console.error("Error syncing with external calendars:", error);
1234
- // Don't throw error as this is not critical for appointment creation
1235
- }
1236
- }
1237
-
1238
- /**
1239
- * Syncs a single event to an external calendar
1240
- * @param appointment - Calendar event to sync
1241
- * @param calendar - External calendar to sync with
1242
- * @param entityType - Type of entity owning the calendar
1243
- */
1244
- private async syncEventToExternalCalendar(
1245
- appointment: CalendarEvent,
1246
- calendar: any,
1247
- entityType: "doctor" | "patient"
1248
- ): Promise<void> {
1249
- try {
1250
- // Create a copy of the appointment to modify for external syncing
1251
- const eventToSync = { ...appointment };
1252
-
1253
- // Prepare event title based on status and entity type
1254
- let eventTitle = appointment.eventName;
1255
- const clinicName = appointment.clinicBranchInfo?.name || "Clinic";
1256
-
1257
- // Format title appropriately
1258
- if (entityType === "patient") {
1259
- eventTitle = `[${appointment.status}] ${eventTitle} @ ${clinicName}`;
1260
- } else {
1261
- eventTitle = `${eventTitle} - Patient: ${
1262
- appointment.patientProfileInfo?.fullName || "Unknown"
1263
- } @ ${clinicName}`;
1264
- }
1265
-
1266
- // Update the event name for external sync
1267
- eventToSync.eventName = eventTitle;
1268
-
1269
- // Check if this event was previously synced with this calendar
1270
- const existingSyncId = appointment.syncedCalendarEventId?.find(
1271
- (sync) => sync.syncedCalendarProvider === calendar.provider
1272
- )?.eventId;
1273
-
1274
- // If we have a synced event ID, we should update the existing event
1275
- // If not, create a new event
1276
-
1277
- if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
1278
- const result =
1279
- await this.syncedCalendarsService.syncPractitionerEventsToGoogleCalendar(
1280
- entityType === "doctor"
1281
- ? appointment.practitionerProfileId!
1282
- : appointment.patientProfileId!,
1283
- calendar.id,
1284
- [eventToSync],
1285
- existingSyncId // Pass existing sync ID if we have one
1286
- );
1287
-
1288
- // If sync was successful and we've created a new event (no existing sync),
1289
- // we should update our local event with the new sync ID
1290
- if (result.success && result.eventIds?.length && !existingSyncId) {
1291
- // Update the appointment with the new sync ID
1292
- const newSyncEvent: SyncedCalendarEvent = {
1293
- eventId: result.eventIds[0],
1294
- syncedCalendarProvider: calendar.provider,
1295
- syncedAt: Timestamp.now(),
1296
- };
1297
-
1298
- // Update the event in the database with the new sync ID
1299
- await this.updateEventWithSyncId(
1300
- entityType === "doctor"
1301
- ? appointment.practitionerProfileId!
1302
- : appointment.patientProfileId!,
1303
- entityType,
1304
- appointment.id,
1305
- newSyncEvent
1306
- );
1307
- }
1308
- }
1309
- } catch (error) {
1310
- console.error(`Error syncing with ${entityType}'s calendar:`, error);
1311
- // Don't throw error as this is not critical
1312
- }
1313
- }
1314
-
1315
- /**
1316
- * Updates an event with a new sync ID
1317
- * @param entityId - ID of the entity (doctor or patient)
1318
- * @param entityType - Type of entity
1319
- * @param eventId - ID of the event
1320
- * @param syncEvent - Sync event information
1321
- */
1322
- private async updateEventWithSyncId(
1323
- entityId: string,
1324
- entityType: "doctor" | "patient",
1325
- eventId: string,
1326
- syncEvent: SyncedCalendarEvent
1327
- ): Promise<void> {
1328
- try {
1329
- // Determine the collection path based on entity type
1330
- const collectionPath =
1331
- entityType === "doctor"
1332
- ? `${PRACTITIONERS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`
1333
- : `${PATIENTS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`;
1334
-
1335
- // Get the event reference
1336
- const eventRef = doc(this.db, collectionPath, eventId);
1337
- const eventDoc = await getDoc(eventRef);
1338
-
1339
- if (eventDoc.exists()) {
1340
- const event = eventDoc.data() as CalendarEvent;
1341
- const syncIds = [...(event.syncedCalendarEventId || [])];
1342
-
1343
- // Check if we already have this sync ID
1344
- const existingSyncIndex = syncIds.findIndex(
1345
- (sync) =>
1346
- sync.syncedCalendarProvider === syncEvent.syncedCalendarProvider
1347
- );
1348
-
1349
- if (existingSyncIndex >= 0) {
1350
- // Update the existing sync ID
1351
- syncIds[existingSyncIndex] = syncEvent;
1352
- } else {
1353
- // Add the new sync ID
1354
- syncIds.push(syncEvent);
1355
- }
1356
-
1357
- // Update the event
1358
- await updateDoc(eventRef, {
1359
- syncedCalendarEventId: syncIds,
1360
- updatedAt: serverTimestamp(),
1361
- });
1362
-
1363
- console.log(
1364
- `Updated event ${eventId} with sync ID ${syncEvent.eventId}`
1365
- );
1366
- }
1367
- } catch (error) {
1368
- console.error("Error updating event with sync ID:", error);
1369
- }
1370
- }
1371
-
1372
- /**
1373
- * Validates update permissions and parameters
1374
- * @param params - Update parameters to validate
1375
- */
1376
- private async validateUpdatePermissions(
1377
- params: UpdateAppointmentParams
1378
- ): Promise<void> {
1379
- // TODO: Add custom validation logic after Zod schema validation
1380
- // - Check if user has permission to update the appointment
1381
- // - Check if the appointment exists
1382
- // - Check if the new status transition is valid
1383
- // - Check if the new time slot is valid
1384
- // - Validate against clinic's business rules
1385
-
1386
- // Validate basic parameters using Zod schema
1387
- await updateAppointmentSchema.parseAsync(params);
1388
- }
1389
-
1390
- /**
1391
- * Gets clinic working hours for a specific date
1392
- * @param clinicId - ID of the clinic
1393
- * @param date - Date to get working hours for
1394
- * @returns Working hours for the clinic
1395
- */
1396
- private async getClinicWorkingHours(
1397
- clinicId: string,
1398
- date: Date
1399
- ): Promise<TimeSlot[]> {
1400
- // Get clinic document
1401
- const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
1402
- const clinicDoc = await getDoc(clinicRef);
1403
-
1404
- if (!clinicDoc.exists()) {
1405
- throw new Error(`Clinic with ID ${clinicId} not found`);
1406
- }
1407
-
1408
- // TODO: Implement proper working hours retrieval from clinic data model
1409
- // For now, return default working hours (9 AM - 5 PM)
1410
- const workingHours: TimeSlot[] = [];
1411
- const dayOfWeek = date.getDay();
1412
-
1413
- // Skip weekends (0 = Sunday, 6 = Saturday)
1414
- if (dayOfWeek === 0 || dayOfWeek === 6) {
1415
- return workingHours;
1416
- }
1417
-
1418
- // Create working hours slot (9 AM - 5 PM)
1419
- const workingDate = new Date(date);
1420
- workingDate.setHours(9, 0, 0, 0);
1421
- const startTime = new Date(workingDate);
1422
-
1423
- workingDate.setHours(17, 0, 0, 0);
1424
- const endTime = new Date(workingDate);
1425
-
1426
- workingHours.push({
1427
- start: startTime,
1428
- end: endTime,
1429
- isAvailable: true,
1430
- });
1431
-
1432
- return workingHours;
1433
- }
1434
-
1435
- /**
1436
- * Gets doctor's schedule for a specific date
1437
- * @param doctorId - ID of the doctor
1438
- * @param date - Date to get schedule for
1439
- * @returns Doctor's schedule
1440
- */
1441
- private async getDoctorSchedule(
1442
- doctorId: string,
1443
- date: Date
1444
- ): Promise<TimeSlot[]> {
1445
- // Get doctor document
1446
- const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
1447
- const practitionerDoc = await getDoc(practitionerRef);
1448
-
1449
- if (!practitionerDoc.exists()) {
1450
- throw new Error(`Doctor with ID ${doctorId} not found`);
1451
- }
1452
-
1453
- // TODO: Implement proper schedule retrieval from practitioner data model
1454
- // For now, return default schedule (9 AM - 5 PM)
1455
- const schedule: TimeSlot[] = [];
1456
- const dayOfWeek = date.getDay();
1457
-
1458
- // Skip weekends (0 = Sunday, 6 = Saturday)
1459
- if (dayOfWeek === 0 || dayOfWeek === 6) {
1460
- return schedule;
1461
- }
1462
-
1463
- // Create schedule slot (9 AM - 5 PM)
1464
- const scheduleDate = new Date(date);
1465
- scheduleDate.setHours(9, 0, 0, 0);
1466
- const startTime = new Date(scheduleDate);
1467
-
1468
- scheduleDate.setHours(17, 0, 0, 0);
1469
- const endTime = new Date(scheduleDate);
1470
-
1471
- schedule.push({
1472
- start: startTime,
1473
- end: endTime,
1474
- isAvailable: true,
1475
- });
1476
-
1477
- return schedule;
1478
- }
1479
-
1480
- /**
1481
- * Gets doctor's appointments for a specific date
1482
- * @param doctorId - ID of the doctor
1483
- * @param date - Date to get appointments for
1484
- * @returns Array of calendar events
1485
- */
1486
- private async getDoctorAppointments(
1487
- doctorId: string,
1488
- date: Date
1489
- ): Promise<CalendarEvent[]> {
1490
- // Create start and end timestamps for the day
1491
- const startOfDay = new Date(date);
1492
- startOfDay.setHours(0, 0, 0, 0);
1493
- const endOfDay = new Date(date);
1494
- endOfDay.setHours(23, 59, 59, 999);
1495
-
1496
- // Query appointments for the doctor on the specified date
1497
- const appointmentsRef = collection(this.db, CALENDAR_COLLECTION);
1498
- const q = query(
1499
- appointmentsRef,
1500
- where("practitionerProfileId", "==", doctorId),
1501
- where("eventTime.start", ">=", Timestamp.fromDate(startOfDay)),
1502
- where("eventTime.start", "<=", Timestamp.fromDate(endOfDay)),
1503
- where("status", "in", [
1504
- CalendarEventStatus.CONFIRMED,
1505
- CalendarEventStatus.PENDING,
1506
- ])
1507
- );
1508
-
1509
- const querySnapshot = await getDocs(q);
1510
- return querySnapshot.docs.map((doc) => doc.data() as CalendarEvent);
1511
- }
1512
-
1513
- /**
1514
- * Calculates available time slots based on working hours, schedule and existing appointments
1515
- * @param workingHours - Clinic working hours
1516
- * @param doctorSchedule - Doctor's schedule
1517
- * @param existingAppointments - Existing appointments
1518
- * @returns Array of available time slots
1519
- */
1520
- private calculateAvailableSlots(
1521
- workingHours: TimeSlot[],
1522
- doctorSchedule: TimeSlot[],
1523
- existingAppointments: CalendarEvent[]
1524
- ): TimeSlot[] {
1525
- const availableSlots: TimeSlot[] = [];
1526
-
1527
- // First, find overlapping time slots between clinic hours and doctor schedule
1528
- for (const workingHour of workingHours) {
1529
- for (const scheduleSlot of doctorSchedule) {
1530
- // Find overlap between working hours and doctor schedule
1531
- const overlapStart = new Date(
1532
- Math.max(workingHour.start.getTime(), scheduleSlot.start.getTime())
1533
- );
1534
- const overlapEnd = new Date(
1535
- Math.min(workingHour.end.getTime(), scheduleSlot.end.getTime())
1536
- );
1537
-
1538
- // If there is an overlap and both slots are available
1539
- if (
1540
- overlapStart < overlapEnd &&
1541
- workingHour.isAvailable &&
1542
- scheduleSlot.isAvailable
1543
- ) {
1544
- // Create 15-minute slots within the overlap period
1545
- let slotStart = new Date(overlapStart);
1546
- while (slotStart < overlapEnd) {
1547
- const slotEnd = new Date(
1548
- slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
1549
- );
1550
-
1551
- // Check if this slot overlaps with any existing appointments
1552
- const hasOverlap = existingAppointments.some((appointment) => {
1553
- const appointmentStart = appointment.eventTime.start.toDate();
1554
- const appointmentEnd = appointment.eventTime.end.toDate();
1555
- return (
1556
- (slotStart >= appointmentStart && slotStart < appointmentEnd) ||
1557
- (slotEnd > appointmentStart && slotEnd <= appointmentEnd)
1558
- );
1559
- });
1560
-
1561
- if (!hasOverlap && slotEnd <= overlapEnd) {
1562
- availableSlots.push({
1563
- start: new Date(slotStart),
1564
- end: new Date(slotEnd),
1565
- isAvailable: true,
1566
- });
1567
- }
1568
-
1569
- // Move to next slot
1570
- slotStart = new Date(
1571
- slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
1572
- );
1573
- }
1574
- }
1575
- }
1576
- }
1577
-
1578
- return availableSlots;
1579
- }
1580
-
1581
- /**
1582
- * Fetches and creates info cards for clinic, doctor, and patient profiles
1583
- * @param clinicId - ID of the clinic
1584
- * @param doctorId - ID of the doctor
1585
- * @param patientId - ID of the patient
1586
- * @returns Object containing info cards for all profiles
1587
- */
1588
- private async fetchProfileInfoCards(
1589
- clinicId: string,
1590
- doctorId: string,
1591
- patientId: string
1592
- ): Promise<{
1593
- clinicInfo: ClinicInfo | null;
1594
- practitionerInfo: PractitionerProfileInfo | null;
1595
- patientInfo: PatientProfileInfo | null;
1596
- }> {
1597
- try {
1598
- // Fetch all profiles concurrently
1599
- const [clinicDoc, practitionerDoc, patientDoc, patientSensitiveInfoDoc] =
1600
- await Promise.all([
1601
- getDoc(doc(this.db, CLINICS_COLLECTION, clinicId)),
1602
- getDoc(doc(this.db, PRACTITIONERS_COLLECTION, doctorId)),
1603
- getDoc(doc(this.db, PATIENTS_COLLECTION, patientId)),
1604
- getDoc(
1605
- doc(
1606
- this.db,
1607
- PATIENTS_COLLECTION,
1608
- patientId,
1609
- PATIENT_SENSITIVE_INFO_COLLECTION,
1610
- patientId
1611
- )
1612
- ),
1613
- ]);
1614
-
1615
- // Create info cards
1616
- const clinicInfo: ClinicInfo | null = clinicDoc.exists()
1617
- ? {
1618
- id: clinicDoc.id,
1619
- featuredPhoto: clinicDoc.data().featuredPhoto || "",
1620
- name: clinicDoc.data().name,
1621
- description: clinicDoc.data().description || "",
1622
- location: clinicDoc.data().location,
1623
- contactInfo: clinicDoc.data().contactInfo,
1624
- }
1625
- : null;
1626
-
1627
- const practitionerInfo: PractitionerProfileInfo | null =
1628
- practitionerDoc.exists()
1629
- ? {
1630
- id: practitionerDoc.id,
1631
- practitionerPhoto:
1632
- practitionerDoc.data().basicInfo.profileImageUrl || null,
1633
- name: `${practitionerDoc.data().basicInfo.firstName} ${
1634
- practitionerDoc.data().basicInfo.lastName
1635
- }`,
1636
- email: practitionerDoc.data().basicInfo.email,
1637
- phone: practitionerDoc.data().basicInfo.phoneNumber || null,
1638
- certification: practitionerDoc.data().certification,
1639
- }
1640
- : null;
1641
-
1642
- // First try to get data from sensitive-info subcollection
1643
- let patientInfo: PatientProfileInfo | null = null;
1644
-
1645
- if (patientSensitiveInfoDoc.exists()) {
1646
- const sensitiveData = patientSensitiveInfoDoc.data();
1647
- patientInfo = {
1648
- id: patientId,
1649
- fullName: `${sensitiveData.firstName} ${sensitiveData.lastName}`,
1650
- email: sensitiveData.email || "",
1651
- phone: sensitiveData.phoneNumber || null,
1652
- dateOfBirth: sensitiveData.dateOfBirth || Timestamp.now(),
1653
- gender: sensitiveData.gender || Gender.OTHER,
1654
- };
1655
- } else if (patientDoc.exists()) {
1656
- // Fall back to patient document if sensitive info not available
1657
- patientInfo = {
1658
- id: patientDoc.id,
1659
- fullName: patientDoc.data().displayName,
1660
- email: patientDoc.data().contactInfo?.email || "",
1661
- phone: patientDoc.data().phoneNumber || null,
1662
- dateOfBirth: patientDoc.data().dateOfBirth || Timestamp.now(),
1663
- gender: patientDoc.data().gender || Gender.OTHER,
1664
- };
1665
- }
1666
-
1667
- return {
1668
- clinicInfo,
1669
- practitionerInfo,
1670
- patientInfo,
1671
- };
1672
- } catch (error) {
1673
- console.error("Error fetching profile info cards:", error);
1674
- return {
1675
- clinicInfo: null,
1676
- practitionerInfo: null,
1677
- patientInfo: null,
1678
- };
1679
- }
1680
- }
1681
-
1682
- // #endregion
1683
- }
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.COMPLETED,
1144
+ CalendarEventStatus.RESCHEDULED,
1145
+ CalendarEventStatus.NO_SHOW,
1146
+ ],
1147
+ [CalendarEventStatus.REJECTED]: [],
1148
+ [CalendarEventStatus.CANCELED]: [],
1149
+ [CalendarEventStatus.RESCHEDULED]: [
1150
+ CalendarEventStatus.CONFIRMED,
1151
+ CalendarEventStatus.CANCELED,
1152
+ ],
1153
+ [CalendarEventStatus.COMPLETED]: [],
1154
+ [CalendarEventStatus.NO_SHOW]: [],
1155
+ };
1156
+
1157
+ // Check if transition is valid
1158
+ if (!validTransitions[currentStatus].includes(newStatus)) {
1159
+ throw new Error(
1160
+ `Invalid status transition from ${currentStatus} to ${newStatus}`
1161
+ );
1162
+ }
1163
+ }
1164
+
1165
+ /**
1166
+ * Syncs appointment with external calendars based on entity type and status
1167
+ * @param appointment - Calendar event to sync
1168
+ */
1169
+ private async syncAppointmentWithExternalCalendars(
1170
+ appointment: CalendarEvent
1171
+ ): Promise<void> {
1172
+ if (!appointment.practitionerProfileId || !appointment.patientProfileId) {
1173
+ return;
1174
+ }
1175
+
1176
+ try {
1177
+ // Get synced calendars for doctor and patient (no longer sync with clinic)
1178
+ const [doctorCalendars, patientCalendars] = await Promise.all([
1179
+ this.syncedCalendarsService.getPractitionerSyncedCalendars(
1180
+ appointment.practitionerProfileId
1181
+ ),
1182
+ this.syncedCalendarsService.getPatientSyncedCalendars(
1183
+ appointment.patientProfileId
1184
+ ),
1185
+ ]);
1186
+
1187
+ // Filter active calendars
1188
+ const activeDoctorCalendars = doctorCalendars.filter(
1189
+ (cal) => cal.isActive
1190
+ );
1191
+ const activePatientCalendars = patientCalendars.filter(
1192
+ (cal) => cal.isActive
1193
+ );
1194
+
1195
+ // Skip if there are no active calendars
1196
+ if (
1197
+ activeDoctorCalendars.length === 0 &&
1198
+ activePatientCalendars.length === 0
1199
+ ) {
1200
+ return;
1201
+ }
1202
+
1203
+ // Only sync INTERNAL events (those created within our system)
1204
+ if (appointment.syncStatus !== CalendarSyncStatus.INTERNAL) {
1205
+ return;
1206
+ }
1207
+
1208
+ // For doctors: Only sync CONFIRMED status events
1209
+ if (
1210
+ appointment.status === CalendarEventStatus.CONFIRMED &&
1211
+ activeDoctorCalendars.length > 0
1212
+ ) {
1213
+ await Promise.all(
1214
+ activeDoctorCalendars.map((calendar) =>
1215
+ this.syncEventToExternalCalendar(appointment, calendar, "doctor")
1216
+ )
1217
+ );
1218
+ }
1219
+
1220
+ // For patients: Sync all events EXCEPT CANCELED and REJECTED
1221
+ if (
1222
+ appointment.status !== CalendarEventStatus.CANCELED &&
1223
+ appointment.status !== CalendarEventStatus.REJECTED &&
1224
+ activePatientCalendars.length > 0
1225
+ ) {
1226
+ await Promise.all(
1227
+ activePatientCalendars.map((calendar) =>
1228
+ this.syncEventToExternalCalendar(appointment, calendar, "patient")
1229
+ )
1230
+ );
1231
+ }
1232
+ } catch (error) {
1233
+ console.error("Error syncing with external calendars:", error);
1234
+ // Don't throw error as this is not critical for appointment creation
1235
+ }
1236
+ }
1237
+
1238
+ /**
1239
+ * Syncs a single event to an external calendar
1240
+ * @param appointment - Calendar event to sync
1241
+ * @param calendar - External calendar to sync with
1242
+ * @param entityType - Type of entity owning the calendar
1243
+ */
1244
+ private async syncEventToExternalCalendar(
1245
+ appointment: CalendarEvent,
1246
+ calendar: any,
1247
+ entityType: "doctor" | "patient"
1248
+ ): Promise<void> {
1249
+ try {
1250
+ // Create a copy of the appointment to modify for external syncing
1251
+ const eventToSync = { ...appointment };
1252
+
1253
+ // Prepare event title based on status and entity type
1254
+ let eventTitle = appointment.eventName;
1255
+ const clinicName = appointment.clinicBranchInfo?.name || "Clinic";
1256
+
1257
+ // Format title appropriately
1258
+ if (entityType === "patient") {
1259
+ eventTitle = `[${appointment.status}] ${eventTitle} @ ${clinicName}`;
1260
+ } else {
1261
+ eventTitle = `${eventTitle} - Patient: ${
1262
+ appointment.patientProfileInfo?.fullName || "Unknown"
1263
+ } @ ${clinicName}`;
1264
+ }
1265
+
1266
+ // Update the event name for external sync
1267
+ eventToSync.eventName = eventTitle;
1268
+
1269
+ // Check if this event was previously synced with this calendar
1270
+ const existingSyncId = appointment.syncedCalendarEventId?.find(
1271
+ (sync) => sync.syncedCalendarProvider === calendar.provider
1272
+ )?.eventId;
1273
+
1274
+ // If we have a synced event ID, we should update the existing event
1275
+ // If not, create a new event
1276
+
1277
+ if (calendar.provider === SyncedCalendarProvider.GOOGLE) {
1278
+ const result =
1279
+ await this.syncedCalendarsService.syncPractitionerEventsToGoogleCalendar(
1280
+ entityType === "doctor"
1281
+ ? appointment.practitionerProfileId!
1282
+ : appointment.patientProfileId!,
1283
+ calendar.id,
1284
+ [eventToSync],
1285
+ existingSyncId // Pass existing sync ID if we have one
1286
+ );
1287
+
1288
+ // If sync was successful and we've created a new event (no existing sync),
1289
+ // we should update our local event with the new sync ID
1290
+ if (result.success && result.eventIds?.length && !existingSyncId) {
1291
+ // Update the appointment with the new sync ID
1292
+ const newSyncEvent: SyncedCalendarEvent = {
1293
+ eventId: result.eventIds[0],
1294
+ syncedCalendarProvider: calendar.provider,
1295
+ syncedAt: Timestamp.now(),
1296
+ };
1297
+
1298
+ // Update the event in the database with the new sync ID
1299
+ await this.updateEventWithSyncId(
1300
+ entityType === "doctor"
1301
+ ? appointment.practitionerProfileId!
1302
+ : appointment.patientProfileId!,
1303
+ entityType,
1304
+ appointment.id,
1305
+ newSyncEvent
1306
+ );
1307
+ }
1308
+ }
1309
+ } catch (error) {
1310
+ console.error(`Error syncing with ${entityType}'s calendar:`, error);
1311
+ // Don't throw error as this is not critical
1312
+ }
1313
+ }
1314
+
1315
+ /**
1316
+ * Updates an event with a new sync ID
1317
+ * @param entityId - ID of the entity (doctor or patient)
1318
+ * @param entityType - Type of entity
1319
+ * @param eventId - ID of the event
1320
+ * @param syncEvent - Sync event information
1321
+ */
1322
+ private async updateEventWithSyncId(
1323
+ entityId: string,
1324
+ entityType: "doctor" | "patient",
1325
+ eventId: string,
1326
+ syncEvent: SyncedCalendarEvent
1327
+ ): Promise<void> {
1328
+ try {
1329
+ // Determine the collection path based on entity type
1330
+ const collectionPath =
1331
+ entityType === "doctor"
1332
+ ? `${PRACTITIONERS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`
1333
+ : `${PATIENTS_COLLECTION}/${entityId}/${CALENDAR_COLLECTION}`;
1334
+
1335
+ // Get the event reference
1336
+ const eventRef = doc(this.db, collectionPath, eventId);
1337
+ const eventDoc = await getDoc(eventRef);
1338
+
1339
+ if (eventDoc.exists()) {
1340
+ const event = eventDoc.data() as CalendarEvent;
1341
+ const syncIds = [...(event.syncedCalendarEventId || [])];
1342
+
1343
+ // Check if we already have this sync ID
1344
+ const existingSyncIndex = syncIds.findIndex(
1345
+ (sync) =>
1346
+ sync.syncedCalendarProvider === syncEvent.syncedCalendarProvider
1347
+ );
1348
+
1349
+ if (existingSyncIndex >= 0) {
1350
+ // Update the existing sync ID
1351
+ syncIds[existingSyncIndex] = syncEvent;
1352
+ } else {
1353
+ // Add the new sync ID
1354
+ syncIds.push(syncEvent);
1355
+ }
1356
+
1357
+ // Update the event
1358
+ await updateDoc(eventRef, {
1359
+ syncedCalendarEventId: syncIds,
1360
+ updatedAt: serverTimestamp(),
1361
+ });
1362
+
1363
+ console.log(
1364
+ `Updated event ${eventId} with sync ID ${syncEvent.eventId}`
1365
+ );
1366
+ }
1367
+ } catch (error) {
1368
+ console.error("Error updating event with sync ID:", error);
1369
+ }
1370
+ }
1371
+
1372
+ /**
1373
+ * Validates update permissions and parameters
1374
+ * @param params - Update parameters to validate
1375
+ */
1376
+ private async validateUpdatePermissions(
1377
+ params: UpdateAppointmentParams
1378
+ ): Promise<void> {
1379
+ // TODO: Add custom validation logic after Zod schema validation
1380
+ // - Check if user has permission to update the appointment
1381
+ // - Check if the appointment exists
1382
+ // - Check if the new status transition is valid
1383
+ // - Check if the new time slot is valid
1384
+ // - Validate against clinic's business rules
1385
+
1386
+ // Validate basic parameters using Zod schema
1387
+ await updateAppointmentSchema.parseAsync(params);
1388
+ }
1389
+
1390
+ /**
1391
+ * Gets clinic working hours for a specific date
1392
+ * @param clinicId - ID of the clinic
1393
+ * @param date - Date to get working hours for
1394
+ * @returns Working hours for the clinic
1395
+ */
1396
+ private async getClinicWorkingHours(
1397
+ clinicId: string,
1398
+ date: Date
1399
+ ): Promise<TimeSlot[]> {
1400
+ // Get clinic document
1401
+ const clinicRef = doc(this.db, CLINICS_COLLECTION, clinicId);
1402
+ const clinicDoc = await getDoc(clinicRef);
1403
+
1404
+ if (!clinicDoc.exists()) {
1405
+ throw new Error(`Clinic with ID ${clinicId} not found`);
1406
+ }
1407
+
1408
+ // TODO: Implement proper working hours retrieval from clinic data model
1409
+ // For now, return default working hours (9 AM - 5 PM)
1410
+ const workingHours: TimeSlot[] = [];
1411
+ const dayOfWeek = date.getDay();
1412
+
1413
+ // Skip weekends (0 = Sunday, 6 = Saturday)
1414
+ if (dayOfWeek === 0 || dayOfWeek === 6) {
1415
+ return workingHours;
1416
+ }
1417
+
1418
+ // Create working hours slot (9 AM - 5 PM)
1419
+ const workingDate = new Date(date);
1420
+ workingDate.setHours(9, 0, 0, 0);
1421
+ const startTime = new Date(workingDate);
1422
+
1423
+ workingDate.setHours(17, 0, 0, 0);
1424
+ const endTime = new Date(workingDate);
1425
+
1426
+ workingHours.push({
1427
+ start: startTime,
1428
+ end: endTime,
1429
+ isAvailable: true,
1430
+ });
1431
+
1432
+ return workingHours;
1433
+ }
1434
+
1435
+ /**
1436
+ * Gets doctor's schedule for a specific date
1437
+ * @param doctorId - ID of the doctor
1438
+ * @param date - Date to get schedule for
1439
+ * @returns Doctor's schedule
1440
+ */
1441
+ private async getDoctorSchedule(
1442
+ doctorId: string,
1443
+ date: Date
1444
+ ): Promise<TimeSlot[]> {
1445
+ // Get doctor document
1446
+ const practitionerRef = doc(this.db, PRACTITIONERS_COLLECTION, doctorId);
1447
+ const practitionerDoc = await getDoc(practitionerRef);
1448
+
1449
+ if (!practitionerDoc.exists()) {
1450
+ throw new Error(`Doctor with ID ${doctorId} not found`);
1451
+ }
1452
+
1453
+ // TODO: Implement proper schedule retrieval from practitioner data model
1454
+ // For now, return default schedule (9 AM - 5 PM)
1455
+ const schedule: TimeSlot[] = [];
1456
+ const dayOfWeek = date.getDay();
1457
+
1458
+ // Skip weekends (0 = Sunday, 6 = Saturday)
1459
+ if (dayOfWeek === 0 || dayOfWeek === 6) {
1460
+ return schedule;
1461
+ }
1462
+
1463
+ // Create schedule slot (9 AM - 5 PM)
1464
+ const scheduleDate = new Date(date);
1465
+ scheduleDate.setHours(9, 0, 0, 0);
1466
+ const startTime = new Date(scheduleDate);
1467
+
1468
+ scheduleDate.setHours(17, 0, 0, 0);
1469
+ const endTime = new Date(scheduleDate);
1470
+
1471
+ schedule.push({
1472
+ start: startTime,
1473
+ end: endTime,
1474
+ isAvailable: true,
1475
+ });
1476
+
1477
+ return schedule;
1478
+ }
1479
+
1480
+ /**
1481
+ * Gets doctor's appointments for a specific date
1482
+ * @param doctorId - ID of the doctor
1483
+ * @param date - Date to get appointments for
1484
+ * @returns Array of calendar events
1485
+ */
1486
+ private async getDoctorAppointments(
1487
+ doctorId: string,
1488
+ date: Date
1489
+ ): Promise<CalendarEvent[]> {
1490
+ // Create start and end timestamps for the day
1491
+ const startOfDay = new Date(date);
1492
+ startOfDay.setHours(0, 0, 0, 0);
1493
+ const endOfDay = new Date(date);
1494
+ endOfDay.setHours(23, 59, 59, 999);
1495
+
1496
+ // Query appointments for the doctor on the specified date
1497
+ const appointmentsRef = collection(this.db, CALENDAR_COLLECTION);
1498
+ const q = query(
1499
+ appointmentsRef,
1500
+ where("practitionerProfileId", "==", doctorId),
1501
+ where("eventTime.start", ">=", Timestamp.fromDate(startOfDay)),
1502
+ where("eventTime.start", "<=", Timestamp.fromDate(endOfDay)),
1503
+ where("status", "in", [
1504
+ CalendarEventStatus.CONFIRMED,
1505
+ CalendarEventStatus.PENDING,
1506
+ ])
1507
+ );
1508
+
1509
+ const querySnapshot = await getDocs(q);
1510
+ return querySnapshot.docs.map((doc) => doc.data() as CalendarEvent);
1511
+ }
1512
+
1513
+ /**
1514
+ * Calculates available time slots based on working hours, schedule and existing appointments
1515
+ * @param workingHours - Clinic working hours
1516
+ * @param doctorSchedule - Doctor's schedule
1517
+ * @param existingAppointments - Existing appointments
1518
+ * @returns Array of available time slots
1519
+ */
1520
+ private calculateAvailableSlots(
1521
+ workingHours: TimeSlot[],
1522
+ doctorSchedule: TimeSlot[],
1523
+ existingAppointments: CalendarEvent[]
1524
+ ): TimeSlot[] {
1525
+ const availableSlots: TimeSlot[] = [];
1526
+
1527
+ // First, find overlapping time slots between clinic hours and doctor schedule
1528
+ for (const workingHour of workingHours) {
1529
+ for (const scheduleSlot of doctorSchedule) {
1530
+ // Find overlap between working hours and doctor schedule
1531
+ const overlapStart = new Date(
1532
+ Math.max(workingHour.start.getTime(), scheduleSlot.start.getTime())
1533
+ );
1534
+ const overlapEnd = new Date(
1535
+ Math.min(workingHour.end.getTime(), scheduleSlot.end.getTime())
1536
+ );
1537
+
1538
+ // If there is an overlap and both slots are available
1539
+ if (
1540
+ overlapStart < overlapEnd &&
1541
+ workingHour.isAvailable &&
1542
+ scheduleSlot.isAvailable
1543
+ ) {
1544
+ // Create 15-minute slots within the overlap period
1545
+ let slotStart = new Date(overlapStart);
1546
+ while (slotStart < overlapEnd) {
1547
+ const slotEnd = new Date(
1548
+ slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
1549
+ );
1550
+
1551
+ // Check if this slot overlaps with any existing appointments
1552
+ const hasOverlap = existingAppointments.some((appointment) => {
1553
+ const appointmentStart = appointment.eventTime.start.toDate();
1554
+ const appointmentEnd = appointment.eventTime.end.toDate();
1555
+ return (
1556
+ (slotStart >= appointmentStart && slotStart < appointmentEnd) ||
1557
+ (slotEnd > appointmentStart && slotEnd <= appointmentEnd)
1558
+ );
1559
+ });
1560
+
1561
+ if (!hasOverlap && slotEnd <= overlapEnd) {
1562
+ availableSlots.push({
1563
+ start: new Date(slotStart),
1564
+ end: new Date(slotEnd),
1565
+ isAvailable: true,
1566
+ });
1567
+ }
1568
+
1569
+ // Move to next slot
1570
+ slotStart = new Date(
1571
+ slotStart.getTime() + MIN_APPOINTMENT_DURATION * 60 * 1000
1572
+ );
1573
+ }
1574
+ }
1575
+ }
1576
+ }
1577
+
1578
+ return availableSlots;
1579
+ }
1580
+
1581
+ /**
1582
+ * Fetches and creates info cards for clinic, doctor, and patient profiles
1583
+ * @param clinicId - ID of the clinic
1584
+ * @param doctorId - ID of the doctor
1585
+ * @param patientId - ID of the patient
1586
+ * @returns Object containing info cards for all profiles
1587
+ */
1588
+ private async fetchProfileInfoCards(
1589
+ clinicId: string,
1590
+ doctorId: string,
1591
+ patientId: string
1592
+ ): Promise<{
1593
+ clinicInfo: ClinicInfo | null;
1594
+ practitionerInfo: PractitionerProfileInfo | null;
1595
+ patientInfo: PatientProfileInfo | null;
1596
+ }> {
1597
+ try {
1598
+ // Fetch all profiles concurrently
1599
+ const [clinicDoc, practitionerDoc, patientDoc, patientSensitiveInfoDoc] =
1600
+ await Promise.all([
1601
+ getDoc(doc(this.db, CLINICS_COLLECTION, clinicId)),
1602
+ getDoc(doc(this.db, PRACTITIONERS_COLLECTION, doctorId)),
1603
+ getDoc(doc(this.db, PATIENTS_COLLECTION, patientId)),
1604
+ getDoc(
1605
+ doc(
1606
+ this.db,
1607
+ PATIENTS_COLLECTION,
1608
+ patientId,
1609
+ PATIENT_SENSITIVE_INFO_COLLECTION,
1610
+ patientId
1611
+ )
1612
+ ),
1613
+ ]);
1614
+
1615
+ // Create info cards
1616
+ const clinicInfo: ClinicInfo | null = clinicDoc.exists()
1617
+ ? {
1618
+ id: clinicDoc.id,
1619
+ featuredPhoto: clinicDoc.data().featuredPhoto || "",
1620
+ name: clinicDoc.data().name,
1621
+ description: clinicDoc.data().description || "",
1622
+ location: clinicDoc.data().location,
1623
+ contactInfo: clinicDoc.data().contactInfo,
1624
+ }
1625
+ : null;
1626
+
1627
+ const practitionerInfo: PractitionerProfileInfo | null =
1628
+ practitionerDoc.exists()
1629
+ ? {
1630
+ id: practitionerDoc.id,
1631
+ practitionerPhoto:
1632
+ practitionerDoc.data().basicInfo.profileImageUrl || null,
1633
+ name: `${practitionerDoc.data().basicInfo.firstName} ${
1634
+ practitionerDoc.data().basicInfo.lastName
1635
+ }`,
1636
+ email: practitionerDoc.data().basicInfo.email,
1637
+ phone: practitionerDoc.data().basicInfo.phoneNumber || null,
1638
+ certification: practitionerDoc.data().certification,
1639
+ }
1640
+ : null;
1641
+
1642
+ // First try to get data from sensitive-info subcollection
1643
+ let patientInfo: PatientProfileInfo | null = null;
1644
+
1645
+ if (patientSensitiveInfoDoc.exists()) {
1646
+ const sensitiveData = patientSensitiveInfoDoc.data();
1647
+ patientInfo = {
1648
+ id: patientId,
1649
+ fullName: `${sensitiveData.firstName} ${sensitiveData.lastName}`,
1650
+ email: sensitiveData.email || "",
1651
+ phone: sensitiveData.phoneNumber || null,
1652
+ dateOfBirth: sensitiveData.dateOfBirth || Timestamp.now(),
1653
+ gender: sensitiveData.gender || Gender.OTHER,
1654
+ };
1655
+ } else if (patientDoc.exists()) {
1656
+ // Fall back to patient document if sensitive info not available
1657
+ patientInfo = {
1658
+ id: patientDoc.id,
1659
+ fullName: patientDoc.data().displayName,
1660
+ email: patientDoc.data().contactInfo?.email || "",
1661
+ phone: patientDoc.data().phoneNumber || null,
1662
+ dateOfBirth: patientDoc.data().dateOfBirth || Timestamp.now(),
1663
+ gender: patientDoc.data().gender || Gender.OTHER,
1664
+ };
1665
+ }
1666
+
1667
+ return {
1668
+ clinicInfo,
1669
+ practitionerInfo,
1670
+ patientInfo,
1671
+ };
1672
+ } catch (error) {
1673
+ console.error("Error fetching profile info cards:", error);
1674
+ return {
1675
+ clinicInfo: null,
1676
+ practitionerInfo: null,
1677
+ patientInfo: null,
1678
+ };
1679
+ }
1680
+ }
1681
+
1682
+ // #endregion
1683
+ }