@blackcode_sa/metaestetics-api 1.12.62 → 1.12.63

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 (273) hide show
  1. package/dist/admin/index.d.mts +4 -2
  2. package/dist/admin/index.d.ts +4 -2
  3. package/dist/admin/index.js +4 -45
  4. package/dist/admin/index.mjs +4 -45
  5. package/dist/backoffice/index.d.mts +9 -0
  6. package/dist/backoffice/index.d.ts +9 -0
  7. package/dist/backoffice/index.js +11 -0
  8. package/dist/backoffice/index.mjs +11 -0
  9. package/dist/index.d.mts +99 -3
  10. package/dist/index.d.ts +99 -3
  11. package/dist/index.js +545 -281
  12. package/dist/index.mjs +867 -603
  13. package/package.json +119 -119
  14. package/src/__mocks__/firstore.ts +10 -10
  15. package/src/admin/aggregation/README.md +79 -79
  16. package/src/admin/aggregation/appointment/README.md +128 -128
  17. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +1844 -1844
  18. package/src/admin/aggregation/appointment/index.ts +1 -1
  19. package/src/admin/aggregation/clinic/README.md +52 -52
  20. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +703 -703
  21. package/src/admin/aggregation/clinic/index.ts +1 -1
  22. package/src/admin/aggregation/forms/README.md +13 -13
  23. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  24. package/src/admin/aggregation/forms/index.ts +1 -1
  25. package/src/admin/aggregation/index.ts +8 -8
  26. package/src/admin/aggregation/patient/README.md +27 -27
  27. package/src/admin/aggregation/patient/index.ts +1 -1
  28. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  29. package/src/admin/aggregation/practitioner/README.md +42 -42
  30. package/src/admin/aggregation/practitioner/index.ts +1 -1
  31. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  32. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  33. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  34. package/src/admin/aggregation/procedure/README.md +43 -43
  35. package/src/admin/aggregation/procedure/index.ts +1 -1
  36. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  37. package/src/admin/aggregation/reviews/index.ts +1 -1
  38. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +641 -689
  39. package/src/admin/booking/README.md +125 -125
  40. package/src/admin/booking/booking.admin.ts +1037 -1037
  41. package/src/admin/booking/booking.calculator.ts +712 -712
  42. package/src/admin/booking/booking.types.ts +59 -59
  43. package/src/admin/booking/index.ts +3 -3
  44. package/src/admin/booking/timezones-problem.md +185 -185
  45. package/src/admin/calendar/README.md +7 -7
  46. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  47. package/src/admin/calendar/index.ts +1 -1
  48. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  49. package/src/admin/documentation-templates/index.ts +1 -1
  50. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  51. package/src/admin/free-consultation/index.ts +1 -1
  52. package/src/admin/index.ts +75 -75
  53. package/src/admin/logger/index.ts +78 -78
  54. package/src/admin/mailing/README.md +95 -95
  55. package/src/admin/mailing/appointment/appointment.mailing.service.ts +732 -732
  56. package/src/admin/mailing/appointment/index.ts +1 -1
  57. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  58. package/src/admin/mailing/base.mailing.service.ts +208 -208
  59. package/src/admin/mailing/index.ts +3 -3
  60. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  61. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  62. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  63. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  64. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  65. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  66. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  67. package/src/admin/notifications/index.ts +1 -1
  68. package/src/admin/notifications/notifications.admin.ts +710 -710
  69. package/src/admin/requirements/README.md +128 -128
  70. package/src/admin/requirements/index.ts +1 -1
  71. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  72. package/src/admin/users/index.ts +1 -1
  73. package/src/admin/users/user-profile.admin.ts +405 -405
  74. package/src/backoffice/constants/certification.constants.ts +13 -13
  75. package/src/backoffice/constants/index.ts +1 -1
  76. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  77. package/src/backoffice/errors/index.ts +1 -1
  78. package/src/backoffice/expo-safe/README.md +26 -26
  79. package/src/backoffice/expo-safe/index.ts +41 -41
  80. package/src/backoffice/index.ts +5 -5
  81. package/src/backoffice/services/FIXES_README.md +102 -102
  82. package/src/backoffice/services/README.md +40 -40
  83. package/src/backoffice/services/brand.service.ts +256 -256
  84. package/src/backoffice/services/category.service.ts +318 -318
  85. package/src/backoffice/services/constants.service.ts +385 -385
  86. package/src/backoffice/services/documentation-template.service.ts +202 -202
  87. package/src/backoffice/services/index.ts +8 -8
  88. package/src/backoffice/services/migrate-products.ts +116 -116
  89. package/src/backoffice/services/product.service.ts +553 -553
  90. package/src/backoffice/services/requirement.service.ts +235 -235
  91. package/src/backoffice/services/subcategory.service.ts +395 -395
  92. package/src/backoffice/services/technology.service.ts +1083 -1070
  93. package/src/backoffice/types/README.md +12 -12
  94. package/src/backoffice/types/admin-constants.types.ts +69 -69
  95. package/src/backoffice/types/brand.types.ts +29 -29
  96. package/src/backoffice/types/category.types.ts +62 -62
  97. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  98. package/src/backoffice/types/index.ts +10 -10
  99. package/src/backoffice/types/procedure-product.types.ts +38 -38
  100. package/src/backoffice/types/product.types.ts +240 -240
  101. package/src/backoffice/types/requirement.types.ts +63 -63
  102. package/src/backoffice/types/static/README.md +18 -18
  103. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  104. package/src/backoffice/types/static/certification.types.ts +37 -37
  105. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  106. package/src/backoffice/types/static/index.ts +6 -6
  107. package/src/backoffice/types/static/pricing.types.ts +16 -16
  108. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  109. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  110. package/src/backoffice/types/subcategory.types.ts +34 -34
  111. package/src/backoffice/types/technology.types.ts +163 -161
  112. package/src/backoffice/validations/index.ts +1 -1
  113. package/src/backoffice/validations/schemas.ts +164 -163
  114. package/src/config/__mocks__/firebase.ts +99 -99
  115. package/src/config/firebase.ts +78 -78
  116. package/src/config/index.ts +9 -9
  117. package/src/errors/auth.error.ts +6 -6
  118. package/src/errors/auth.errors.ts +200 -200
  119. package/src/errors/clinic.errors.ts +32 -32
  120. package/src/errors/firebase.errors.ts +47 -47
  121. package/src/errors/user.errors.ts +99 -99
  122. package/src/index.backup.ts +407 -407
  123. package/src/index.ts +6 -6
  124. package/src/locales/en.ts +31 -31
  125. package/src/recommender/admin/index.ts +1 -1
  126. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  127. package/src/recommender/front/index.ts +1 -1
  128. package/src/recommender/front/services/onboarding.service.ts +5 -5
  129. package/src/recommender/front/services/recommender.service.ts +3 -3
  130. package/src/recommender/index.ts +1 -1
  131. package/src/services/PATIENTAUTH.MD +197 -197
  132. package/src/services/README.md +106 -106
  133. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  134. package/src/services/__tests__/auth/auth.setup.ts +293 -293
  135. package/src/services/__tests__/auth.service.test.ts +346 -346
  136. package/src/services/__tests__/base.service.test.ts +77 -77
  137. package/src/services/__tests__/user.service.test.ts +528 -528
  138. package/src/services/appointment/README.md +17 -17
  139. package/src/services/appointment/appointment.service.ts +2505 -2082
  140. package/src/services/appointment/index.ts +1 -1
  141. package/src/services/appointment/utils/appointment.utils.ts +552 -552
  142. package/src/services/appointment/utils/extended-procedure.utils.ts +314 -314
  143. package/src/services/appointment/utils/form-initialization.utils.ts +225 -225
  144. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  145. package/src/services/appointment/utils/zone-management.utils.ts +353 -353
  146. package/src/services/appointment/utils/zone-photo.utils.ts +152 -152
  147. package/src/services/auth/auth.service.ts +989 -989
  148. package/src/services/auth/auth.v2.service.ts +961 -961
  149. package/src/services/auth/index.ts +7 -7
  150. package/src/services/auth/utils/error.utils.ts +90 -90
  151. package/src/services/auth/utils/firebase.utils.ts +49 -49
  152. package/src/services/auth/utils/index.ts +21 -21
  153. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  154. package/src/services/base.service.ts +41 -41
  155. package/src/services/calendar/calendar.service.ts +1077 -1077
  156. package/src/services/calendar/calendar.v2.service.ts +1683 -1683
  157. package/src/services/calendar/calendar.v3.service.ts +313 -313
  158. package/src/services/calendar/externalCalendar.service.ts +178 -178
  159. package/src/services/calendar/index.ts +5 -5
  160. package/src/services/calendar/synced-calendars.service.ts +743 -743
  161. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  162. package/src/services/calendar/utils/calendar-event.utils.ts +646 -646
  163. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  164. package/src/services/calendar/utils/docs.utils.ts +157 -157
  165. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  166. package/src/services/calendar/utils/index.ts +8 -8
  167. package/src/services/calendar/utils/patient.utils.ts +198 -198
  168. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  169. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  170. package/src/services/clinic/README.md +204 -204
  171. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +287 -287
  172. package/src/services/clinic/__tests__/clinic-group.service.test.ts +352 -352
  173. package/src/services/clinic/__tests__/clinic.service.test.ts +354 -354
  174. package/src/services/clinic/billing-transactions.service.ts +217 -217
  175. package/src/services/clinic/clinic-admin.service.ts +202 -202
  176. package/src/services/clinic/clinic-group.service.ts +310 -310
  177. package/src/services/clinic/clinic.service.ts +708 -708
  178. package/src/services/clinic/index.ts +5 -5
  179. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  180. package/src/services/clinic/utils/admin.utils.ts +551 -551
  181. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  182. package/src/services/clinic/utils/clinic.utils.ts +949 -949
  183. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  184. package/src/services/clinic/utils/filter.utils.ts +446 -446
  185. package/src/services/clinic/utils/index.ts +11 -11
  186. package/src/services/clinic/utils/photos.utils.ts +188 -188
  187. package/src/services/clinic/utils/search.utils.ts +84 -84
  188. package/src/services/clinic/utils/tag.utils.ts +124 -124
  189. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  190. package/src/services/documentation-templates/filled-document.service.ts +587 -587
  191. package/src/services/documentation-templates/index.ts +2 -2
  192. package/src/services/index.ts +13 -13
  193. package/src/services/media/index.ts +1 -1
  194. package/src/services/media/media.service.ts +418 -418
  195. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  196. package/src/services/notifications/index.ts +1 -1
  197. package/src/services/notifications/notification.service.ts +215 -215
  198. package/src/services/patient/README.md +48 -48
  199. package/src/services/patient/To-Do.md +43 -43
  200. package/src/services/patient/__tests__/patient.service.test.ts +294 -294
  201. package/src/services/patient/index.ts +2 -2
  202. package/src/services/patient/patient.service.ts +883 -883
  203. package/src/services/patient/patientRequirements.service.ts +285 -285
  204. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  205. package/src/services/patient/utils/clinic.utils.ts +80 -80
  206. package/src/services/patient/utils/docs.utils.ts +142 -142
  207. package/src/services/patient/utils/index.ts +9 -9
  208. package/src/services/patient/utils/location.utils.ts +126 -126
  209. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  210. package/src/services/patient/utils/medical.utils.ts +458 -458
  211. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  212. package/src/services/patient/utils/profile.utils.ts +510 -510
  213. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  214. package/src/services/patient/utils/token.utils.ts +211 -211
  215. package/src/services/practitioner/README.md +145 -145
  216. package/src/services/practitioner/index.ts +1 -1
  217. package/src/services/practitioner/practitioner.service.ts +1742 -1742
  218. package/src/services/procedure/README.md +163 -163
  219. package/src/services/procedure/index.ts +1 -1
  220. package/src/services/procedure/procedure.service.ts +1682 -1682
  221. package/src/services/reviews/index.ts +1 -1
  222. package/src/services/reviews/reviews.service.ts +636 -683
  223. package/src/services/user/index.ts +1 -1
  224. package/src/services/user/user.service.ts +489 -489
  225. package/src/services/user/user.v2.service.ts +466 -466
  226. package/src/types/appointment/index.ts +481 -453
  227. package/src/types/calendar/index.ts +258 -258
  228. package/src/types/calendar/synced-calendar.types.ts +66 -66
  229. package/src/types/clinic/index.ts +489 -489
  230. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  231. package/src/types/clinic/preferences.types.ts +159 -159
  232. package/src/types/clinic/to-do +3 -3
  233. package/src/types/documentation-templates/index.ts +308 -308
  234. package/src/types/index.ts +44 -44
  235. package/src/types/notifications/README.md +77 -77
  236. package/src/types/notifications/index.ts +265 -265
  237. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  238. package/src/types/patient/allergies.ts +58 -58
  239. package/src/types/patient/index.ts +275 -273
  240. package/src/types/patient/medical-info.types.ts +152 -152
  241. package/src/types/patient/patient-requirements.ts +92 -92
  242. package/src/types/patient/token.types.ts +61 -61
  243. package/src/types/practitioner/index.ts +206 -206
  244. package/src/types/procedure/index.ts +181 -181
  245. package/src/types/profile/index.ts +39 -39
  246. package/src/types/reviews/index.ts +130 -132
  247. package/src/types/tz-lookup.d.ts +4 -4
  248. package/src/types/user/index.ts +38 -38
  249. package/src/utils/TIMESTAMPS.md +176 -176
  250. package/src/utils/TimestampUtils.ts +241 -241
  251. package/src/utils/index.ts +1 -1
  252. package/src/validations/appointment.schema.ts +574 -574
  253. package/src/validations/calendar.schema.ts +225 -225
  254. package/src/validations/clinic.schema.ts +493 -493
  255. package/src/validations/common.schema.ts +25 -25
  256. package/src/validations/documentation-templates/index.ts +1 -1
  257. package/src/validations/documentation-templates/template.schema.ts +220 -220
  258. package/src/validations/documentation-templates.schema.ts +10 -10
  259. package/src/validations/index.ts +20 -20
  260. package/src/validations/media.schema.ts +10 -10
  261. package/src/validations/notification.schema.ts +90 -90
  262. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  263. package/src/validations/patient/medical-info.schema.ts +125 -125
  264. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  265. package/src/validations/patient/token.schema.ts +29 -29
  266. package/src/validations/patient.schema.ts +217 -216
  267. package/src/validations/practitioner.schema.ts +222 -222
  268. package/src/validations/procedure-product.schema.ts +41 -41
  269. package/src/validations/procedure.schema.ts +124 -124
  270. package/src/validations/profile-info.schema.ts +41 -41
  271. package/src/validations/reviews.schema.ts +189 -195
  272. package/src/validations/schemas.ts +104 -104
  273. 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
+ }