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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (326) hide show
  1. package/dist/admin/index.d.mts +377 -222
  2. package/dist/admin/index.d.ts +377 -222
  3. package/dist/admin/index.js +625 -206
  4. package/dist/admin/index.mjs +624 -206
  5. package/dist/backoffice/index.d.mts +24 -0
  6. package/dist/backoffice/index.d.ts +24 -0
  7. package/dist/index.d.mts +297 -9
  8. package/dist/index.d.ts +297 -9
  9. package/dist/index.js +1144 -632
  10. package/dist/index.mjs +1139 -619
  11. package/package.json +2 -1
  12. package/src/__mocks__/firstore.ts +10 -10
  13. package/src/admin/aggregation/README.md +79 -79
  14. package/src/admin/aggregation/appointment/README.md +151 -129
  15. package/src/admin/aggregation/appointment/appointment.aggregation.service.ts +2137 -2091
  16. package/src/admin/aggregation/appointment/index.ts +1 -1
  17. package/src/admin/aggregation/clinic/README.md +52 -52
  18. package/src/admin/aggregation/clinic/clinic.aggregation.service.ts +966 -966
  19. package/src/admin/aggregation/clinic/index.ts +1 -1
  20. package/src/admin/aggregation/forms/README.md +13 -13
  21. package/src/admin/aggregation/forms/filled-forms.aggregation.service.ts +322 -322
  22. package/src/admin/aggregation/forms/index.ts +1 -1
  23. package/src/admin/aggregation/index.ts +8 -8
  24. package/src/admin/aggregation/patient/README.md +27 -27
  25. package/src/admin/aggregation/patient/index.ts +1 -1
  26. package/src/admin/aggregation/patient/patient.aggregation.service.ts +141 -141
  27. package/src/admin/aggregation/practitioner/README.md +42 -42
  28. package/src/admin/aggregation/practitioner/index.ts +1 -1
  29. package/src/admin/aggregation/practitioner/practitioner.aggregation.service.ts +433 -433
  30. package/src/admin/aggregation/practitioner-invite/index.ts +1 -1
  31. package/src/admin/aggregation/practitioner-invite/practitioner-invite.aggregation.service.ts +961 -961
  32. package/src/admin/aggregation/procedure/README.md +43 -43
  33. package/src/admin/aggregation/procedure/index.ts +1 -1
  34. package/src/admin/aggregation/procedure/procedure.aggregation.service.ts +702 -702
  35. package/src/admin/aggregation/reviews/index.ts +1 -1
  36. package/src/admin/aggregation/reviews/reviews.aggregation.service.ts +689 -689
  37. package/src/admin/analytics/analytics.admin.service.ts +278 -278
  38. package/src/admin/analytics/index.ts +2 -2
  39. package/src/admin/booking/README.md +184 -125
  40. package/src/admin/booking/booking.admin.ts +1330 -1073
  41. package/src/admin/booking/booking.calculator.ts +850 -712
  42. package/src/admin/booking/booking.types.ts +76 -59
  43. package/src/admin/booking/index.ts +3 -3
  44. package/src/admin/booking/timezones-problem.md +185 -185
  45. package/src/admin/calendar/README.md +62 -7
  46. package/src/admin/calendar/calendar.admin.service.ts +345 -345
  47. package/src/admin/calendar/index.ts +2 -1
  48. package/src/admin/calendar/resource-calendar.admin.ts +198 -0
  49. package/src/admin/documentation-templates/document-manager.admin.ts +260 -260
  50. package/src/admin/documentation-templates/index.ts +1 -1
  51. package/src/admin/free-consultation/free-consultation-utils.admin.ts +148 -148
  52. package/src/admin/free-consultation/index.ts +1 -1
  53. package/src/admin/index.ts +83 -83
  54. package/src/admin/logger/index.ts +78 -78
  55. package/src/admin/mailing/README.md +139 -139
  56. package/src/admin/mailing/appointment/appointment.mailing.service.ts +1253 -1253
  57. package/src/admin/mailing/appointment/index.ts +1 -1
  58. package/src/admin/mailing/appointment/templates/patient/appointment-confirmed.html +40 -40
  59. package/src/admin/mailing/base.mailing.service.ts +208 -208
  60. package/src/admin/mailing/clinicWelcome/clinicWelcome.mailing.ts +292 -292
  61. package/src/admin/mailing/clinicWelcome/index.ts +1 -1
  62. package/src/admin/mailing/clinicWelcome/templates/welcome.template.ts +225 -225
  63. package/src/admin/mailing/index.ts +5 -5
  64. package/src/admin/mailing/patientInvite/index.ts +2 -2
  65. package/src/admin/mailing/patientInvite/patientInvite.mailing.ts +415 -415
  66. package/src/admin/mailing/patientInvite/templates/invitation.template.ts +105 -105
  67. package/src/admin/mailing/practitionerInvite/existing-practitioner-invite.mailing.ts +611 -611
  68. package/src/admin/mailing/practitionerInvite/index.ts +2 -2
  69. package/src/admin/mailing/practitionerInvite/practitionerInvite.mailing.ts +395 -395
  70. package/src/admin/mailing/practitionerInvite/templates/existing-practitioner-invitation.template.ts +155 -155
  71. package/src/admin/mailing/practitionerInvite/templates/invitation.template.ts +101 -101
  72. package/src/admin/mailing/practitionerInvite/templates/invite-accepted-notification.template.ts +228 -228
  73. package/src/admin/mailing/practitionerInvite/templates/invite-rejected-notification.template.ts +242 -242
  74. package/src/admin/notifications/index.ts +1 -1
  75. package/src/admin/notifications/notifications.admin.ts +818 -818
  76. package/src/admin/requirements/README.md +128 -128
  77. package/src/admin/requirements/index.ts +1 -1
  78. package/src/admin/requirements/patient-requirements.admin.service.ts +475 -475
  79. package/src/admin/users/index.ts +1 -1
  80. package/src/admin/users/user-profile.admin.ts +405 -405
  81. package/src/backoffice/constants/certification.constants.ts +13 -13
  82. package/src/backoffice/constants/index.ts +1 -1
  83. package/src/backoffice/errors/backoffice.errors.ts +181 -181
  84. package/src/backoffice/errors/index.ts +1 -1
  85. package/src/backoffice/expo-safe/README.md +26 -26
  86. package/src/backoffice/expo-safe/index.ts +41 -41
  87. package/src/backoffice/index.ts +5 -5
  88. package/src/backoffice/services/FIXES_README.md +102 -102
  89. package/src/backoffice/services/README.md +57 -57
  90. package/src/backoffice/services/analytics.service.proposal.md +863 -863
  91. package/src/backoffice/services/analytics.service.summary.md +143 -143
  92. package/src/backoffice/services/brand.service.ts +260 -260
  93. package/src/backoffice/services/category.service.ts +384 -384
  94. package/src/backoffice/services/constants.service.ts +385 -385
  95. package/src/backoffice/services/documentation-template.service.ts +202 -202
  96. package/src/backoffice/services/index.ts +10 -10
  97. package/src/backoffice/services/migrate-products.ts +116 -116
  98. package/src/backoffice/services/product.service.ts +557 -557
  99. package/src/backoffice/services/requirement.service.ts +235 -235
  100. package/src/backoffice/services/subcategory.service.ts +461 -461
  101. package/src/backoffice/services/technology.service.ts +1153 -1153
  102. package/src/backoffice/types/README.md +12 -12
  103. package/src/backoffice/types/admin-constants.types.ts +69 -69
  104. package/src/backoffice/types/brand.types.ts +29 -29
  105. package/src/backoffice/types/category.types.ts +67 -67
  106. package/src/backoffice/types/documentation-templates.types.ts +28 -28
  107. package/src/backoffice/types/index.ts +10 -10
  108. package/src/backoffice/types/procedure-product.types.ts +38 -38
  109. package/src/backoffice/types/product.types.ts +239 -239
  110. package/src/backoffice/types/requirement.types.ts +63 -63
  111. package/src/backoffice/types/static/README.md +18 -18
  112. package/src/backoffice/types/static/blocking-condition.types.ts +21 -21
  113. package/src/backoffice/types/static/certification.types.ts +37 -37
  114. package/src/backoffice/types/static/contraindication.types.ts +19 -19
  115. package/src/backoffice/types/static/index.ts +6 -6
  116. package/src/backoffice/types/static/pricing.types.ts +16 -16
  117. package/src/backoffice/types/static/procedure-family.types.ts +14 -14
  118. package/src/backoffice/types/static/treatment-benefit.types.ts +22 -22
  119. package/src/backoffice/types/subcategory.types.ts +34 -34
  120. package/src/backoffice/types/technology.types.ts +168 -168
  121. package/src/backoffice/validations/index.ts +1 -1
  122. package/src/backoffice/validations/schemas.ts +164 -164
  123. package/src/config/__mocks__/firebase.ts +99 -99
  124. package/src/config/firebase.ts +78 -78
  125. package/src/config/index.ts +17 -17
  126. package/src/config/tiers.config.ts +255 -229
  127. package/src/errors/auth.error.ts +6 -6
  128. package/src/errors/auth.errors.ts +211 -211
  129. package/src/errors/clinic.errors.ts +32 -32
  130. package/src/errors/firebase.errors.ts +47 -47
  131. package/src/errors/user.errors.ts +99 -99
  132. package/src/index.backup.ts +407 -407
  133. package/src/index.ts +6 -6
  134. package/src/locales/en.ts +31 -31
  135. package/src/recommender/admin/index.ts +1 -1
  136. package/src/recommender/admin/services/recommender.service.admin.ts +5 -5
  137. package/src/recommender/front/index.ts +1 -1
  138. package/src/recommender/front/services/onboarding.service.ts +5 -5
  139. package/src/recommender/front/services/recommender.service.ts +3 -3
  140. package/src/recommender/index.ts +1 -1
  141. package/src/services/PATIENTAUTH.MD +197 -197
  142. package/src/services/README.md +106 -106
  143. package/src/services/__tests__/auth/auth.mock.test.ts +17 -17
  144. package/src/services/__tests__/auth/auth.setup.ts +298 -298
  145. package/src/services/__tests__/auth.service.test.ts +310 -310
  146. package/src/services/__tests__/base.service.test.ts +36 -36
  147. package/src/services/__tests__/user.service.test.ts +530 -530
  148. package/src/services/analytics/ARCHITECTURE.md +199 -199
  149. package/src/services/analytics/CLOUD_FUNCTIONS.md +225 -225
  150. package/src/services/analytics/GROUPED_ANALYTICS.md +501 -501
  151. package/src/services/analytics/QUICK_START.md +393 -393
  152. package/src/services/analytics/README.md +304 -304
  153. package/src/services/analytics/SUMMARY.md +141 -141
  154. package/src/services/analytics/TRENDS.md +380 -380
  155. package/src/services/analytics/USAGE_GUIDE.md +518 -518
  156. package/src/services/analytics/analytics-cloud.service.ts +222 -222
  157. package/src/services/analytics/analytics.service.ts +2148 -2148
  158. package/src/services/analytics/index.ts +4 -4
  159. package/src/services/analytics/review-analytics.service.ts +941 -941
  160. package/src/services/analytics/utils/appointment-filtering.utils.ts +138 -138
  161. package/src/services/analytics/utils/cost-calculation.utils.ts +182 -182
  162. package/src/services/analytics/utils/grouping.utils.ts +434 -434
  163. package/src/services/analytics/utils/stored-analytics.utils.ts +347 -347
  164. package/src/services/analytics/utils/time-calculation.utils.ts +186 -186
  165. package/src/services/analytics/utils/trend-calculation.utils.ts +200 -200
  166. package/src/services/appointment/README.md +17 -17
  167. package/src/services/appointment/appointment.service.ts +2943 -2941
  168. package/src/services/appointment/index.ts +1 -1
  169. package/src/services/appointment/utils/appointment.utils.ts +620 -620
  170. package/src/services/appointment/utils/extended-procedure.utils.ts +354 -354
  171. package/src/services/appointment/utils/form-initialization.utils.ts +516 -516
  172. package/src/services/appointment/utils/recommended-procedure.utils.ts +195 -195
  173. package/src/services/appointment/utils/zone-management.utils.ts +468 -468
  174. package/src/services/appointment/utils/zone-photo.utils.ts +302 -302
  175. package/src/services/auth/auth.service.ts +1435 -1435
  176. package/src/services/auth/auth.v2.service.ts +961 -961
  177. package/src/services/auth/index.ts +7 -7
  178. package/src/services/auth/utils/error.utils.ts +90 -90
  179. package/src/services/auth/utils/firebase.utils.ts +49 -49
  180. package/src/services/auth/utils/index.ts +21 -21
  181. package/src/services/auth/utils/practitioner.utils.ts +125 -125
  182. package/src/services/base.service.ts +41 -41
  183. package/src/services/calendar/calendar.service.ts +1077 -1077
  184. package/src/services/calendar/calendar.v2.service.ts +1693 -1693
  185. package/src/services/calendar/calendar.v3.service.ts +313 -313
  186. package/src/services/calendar/externalCalendar.service.ts +178 -178
  187. package/src/services/calendar/index.ts +5 -5
  188. package/src/services/calendar/synced-calendars.service.ts +743 -743
  189. package/src/services/calendar/utils/appointment.utils.ts +265 -265
  190. package/src/services/calendar/utils/calendar-event.utils.ts +676 -676
  191. package/src/services/calendar/utils/clinic.utils.ts +237 -237
  192. package/src/services/calendar/utils/docs.utils.ts +157 -157
  193. package/src/services/calendar/utils/google-calendar.utils.ts +697 -697
  194. package/src/services/calendar/utils/index.ts +8 -8
  195. package/src/services/calendar/utils/patient.utils.ts +198 -198
  196. package/src/services/calendar/utils/practitioner.utils.ts +221 -221
  197. package/src/services/calendar/utils/synced-calendar.utils.ts +472 -472
  198. package/src/services/clinic/README.md +204 -204
  199. package/src/services/clinic/__tests__/clinic-admin.service.test.ts +265 -265
  200. package/src/services/clinic/__tests__/clinic-group.service.test.ts +222 -222
  201. package/src/services/clinic/__tests__/clinic.service.test.ts +302 -302
  202. package/src/services/clinic/billing-transactions.service.ts +217 -217
  203. package/src/services/clinic/clinic-admin.service.ts +202 -202
  204. package/src/services/clinic/clinic-group.service.ts +310 -310
  205. package/src/services/clinic/clinic.service.ts +720 -720
  206. package/src/services/clinic/index.ts +5 -5
  207. package/src/services/clinic/practitioner-invite.service.ts +519 -519
  208. package/src/services/clinic/utils/admin.utils.ts +551 -551
  209. package/src/services/clinic/utils/clinic-group.utils.ts +646 -646
  210. package/src/services/clinic/utils/clinic.utils.ts +1023 -1023
  211. package/src/services/clinic/utils/filter.utils.d.ts +23 -23
  212. package/src/services/clinic/utils/filter.utils.ts +462 -462
  213. package/src/services/clinic/utils/index.ts +10 -10
  214. package/src/services/clinic/utils/photos.utils.ts +188 -188
  215. package/src/services/clinic/utils/search.utils.ts +83 -83
  216. package/src/services/clinic/utils/tag.utils.ts +124 -124
  217. package/src/services/documentation-templates/documentation-template.service.ts +537 -537
  218. package/src/services/documentation-templates/filled-document.service.ts +597 -597
  219. package/src/services/documentation-templates/index.ts +2 -2
  220. package/src/services/index.ts +16 -15
  221. package/src/services/media/index.ts +1 -1
  222. package/src/services/media/media.service.ts +418 -418
  223. package/src/services/notifications/__tests__/notification.service.test.ts +242 -242
  224. package/src/services/notifications/index.ts +1 -1
  225. package/src/services/notifications/notification.service.ts +215 -215
  226. package/src/services/patient/README.md +48 -48
  227. package/src/services/patient/To-Do.md +43 -43
  228. package/src/services/patient/__tests__/patient.service.test.ts +286 -286
  229. package/src/services/patient/index.ts +2 -2
  230. package/src/services/patient/patient.service.ts +1021 -1021
  231. package/src/services/patient/patientRequirements.service.ts +309 -309
  232. package/src/services/patient/utils/aesthetic-analysis.utils.ts +176 -176
  233. package/src/services/patient/utils/body-assessment.utils.ts +159 -159
  234. package/src/services/patient/utils/clinic.utils.ts +159 -159
  235. package/src/services/patient/utils/docs.utils.ts +142 -142
  236. package/src/services/patient/utils/hair-scalp-assessment.utils.ts +158 -158
  237. package/src/services/patient/utils/index.ts +9 -9
  238. package/src/services/patient/utils/location.utils.ts +126 -126
  239. package/src/services/patient/utils/medical-stuff.utils.ts +143 -143
  240. package/src/services/patient/utils/medical.utils.ts +458 -458
  241. package/src/services/patient/utils/practitioner.utils.ts +260 -260
  242. package/src/services/patient/utils/pre-surgical-assessment.utils.ts +161 -161
  243. package/src/services/patient/utils/profile.utils.ts +510 -510
  244. package/src/services/patient/utils/sensitive.utils.ts +260 -260
  245. package/src/services/patient/utils/skin-quality-assessment.utils.ts +160 -160
  246. package/src/services/patient/utils/token.utils.ts +211 -211
  247. package/src/services/practitioner/README.md +145 -145
  248. package/src/services/practitioner/index.ts +1 -1
  249. package/src/services/practitioner/practitioner.service.ts +2355 -2354
  250. package/src/services/procedure/README.md +163 -163
  251. package/src/services/procedure/index.ts +1 -1
  252. package/src/services/procedure/procedure.service.ts +2521 -2521
  253. package/src/services/resource/README.md +119 -0
  254. package/src/services/resource/index.ts +1 -0
  255. package/src/services/resource/resource.service.ts +555 -0
  256. package/src/services/reviews/index.ts +1 -1
  257. package/src/services/reviews/reviews.service.ts +745 -745
  258. package/src/services/tier-enforcement.ts +240 -240
  259. package/src/services/user/index.ts +1 -1
  260. package/src/services/user/user.service.ts +533 -533
  261. package/src/services/user/user.v2.service.ts +467 -467
  262. package/src/types/analytics/analytics.types.ts +597 -597
  263. package/src/types/analytics/grouped-analytics.types.ts +173 -173
  264. package/src/types/analytics/index.ts +4 -4
  265. package/src/types/analytics/stored-analytics.types.ts +137 -137
  266. package/src/types/appointment/index.ts +524 -517
  267. package/src/types/calendar/index.ts +261 -260
  268. package/src/types/calendar/synced-calendar.types.ts +66 -66
  269. package/src/types/clinic/index.ts +530 -529
  270. package/src/types/clinic/practitioner-invite.types.ts +91 -91
  271. package/src/types/clinic/preferences.types.ts +159 -159
  272. package/src/types/clinic/rbac.types.ts +64 -63
  273. package/src/types/clinic/to-do +3 -3
  274. package/src/types/documentation-templates/index.ts +308 -308
  275. package/src/types/index.ts +50 -47
  276. package/src/types/notifications/README.md +77 -77
  277. package/src/types/notifications/index.ts +300 -300
  278. package/src/types/patient/aesthetic-analysis.types.ts +66 -66
  279. package/src/types/patient/allergies.ts +58 -58
  280. package/src/types/patient/body-assessment.types.ts +93 -93
  281. package/src/types/patient/hair-scalp-assessment.types.ts +98 -98
  282. package/src/types/patient/index.ts +279 -279
  283. package/src/types/patient/medical-info.types.ts +152 -152
  284. package/src/types/patient/patient-requirements.ts +92 -92
  285. package/src/types/patient/pre-surgical-assessment.types.ts +95 -95
  286. package/src/types/patient/skin-quality-assessment.types.ts +105 -105
  287. package/src/types/patient/token.types.ts +61 -61
  288. package/src/types/practitioner/index.ts +208 -208
  289. package/src/types/procedure/index.ts +189 -183
  290. package/src/types/profile/index.ts +39 -39
  291. package/src/types/resource/README.md +153 -0
  292. package/src/types/resource/index.ts +199 -0
  293. package/src/types/reviews/index.ts +132 -132
  294. package/src/types/tz-lookup.d.ts +4 -4
  295. package/src/types/user/index.ts +60 -60
  296. package/src/utils/TIMESTAMPS.md +176 -176
  297. package/src/utils/TimestampUtils.ts +241 -241
  298. package/src/utils/index.ts +1 -1
  299. package/src/validations/README.md +94 -0
  300. package/src/validations/appointment.schema.ts +589 -589
  301. package/src/validations/calendar.schema.ts +225 -225
  302. package/src/validations/clinic.schema.ts +494 -494
  303. package/src/validations/common.schema.ts +25 -25
  304. package/src/validations/documentation-templates/index.ts +1 -1
  305. package/src/validations/documentation-templates/template.schema.ts +220 -220
  306. package/src/validations/documentation-templates.schema.ts +10 -10
  307. package/src/validations/index.ts +21 -20
  308. package/src/validations/media.schema.ts +10 -10
  309. package/src/validations/notification.schema.ts +90 -90
  310. package/src/validations/patient/aesthetic-analysis.schema.ts +55 -55
  311. package/src/validations/patient/body-assessment.schema.ts +82 -82
  312. package/src/validations/patient/hair-scalp-assessment.schema.ts +70 -70
  313. package/src/validations/patient/medical-info.schema.ts +177 -177
  314. package/src/validations/patient/patient-requirements.schema.ts +84 -84
  315. package/src/validations/patient/pre-surgical-assessment.schema.ts +78 -78
  316. package/src/validations/patient/skin-quality-assessment.schema.ts +70 -70
  317. package/src/validations/patient/token.schema.ts +29 -29
  318. package/src/validations/patient.schema.ts +217 -217
  319. package/src/validations/practitioner.schema.ts +224 -224
  320. package/src/validations/procedure-product.schema.ts +41 -41
  321. package/src/validations/procedure.schema.ts +136 -124
  322. package/src/validations/profile-info.schema.ts +41 -41
  323. package/src/validations/resource.schema.ts +57 -0
  324. package/src/validations/reviews.schema.ts +195 -195
  325. package/src/validations/schemas.ts +109 -109
  326. package/src/validations/shared.schema.ts +78 -78
@@ -1,712 +1,850 @@
1
- import { Timestamp } from "firebase/firestore";
2
- import { DateTime } from "luxon";
3
- import {
4
- BookingAvailabilityRequest,
5
- BookingAvailabilityResponse,
6
- AvailableSlot,
7
- TimeInterval,
8
- } from "./booking.types";
9
- import {
10
- CalendarEvent,
11
- CalendarEventStatus,
12
- CalendarEventType,
13
- } from "../../types/calendar";
14
- import { PractitionerClinicWorkingHours } from "../../types/practitioner";
15
- import { Clinic } from "../../types/clinic";
16
-
17
- /**
18
- * Calculator for determining available booking slots
19
- * This class handles the complex logic of determining when appointments can be scheduled
20
- * based on clinic working hours, practitioner availability, and existing calendar events.
21
- */
22
- export class BookingAvailabilityCalculator {
23
- /** Default scheduling interval in minutes if not specified by the clinic */
24
- private static readonly DEFAULT_INTERVAL_MINUTES = 15;
25
-
26
- /**
27
- * Calculate available booking slots based on the provided data
28
- *
29
- * @param request - The request containing all necessary data for calculation
30
- * @returns Response with available booking slots
31
- */
32
- public static calculateSlots(
33
- request: BookingAvailabilityRequest
34
- ): BookingAvailabilityResponse {
35
- // Extract necessary data from the request
36
- const {
37
- clinic,
38
- practitioner,
39
- procedure,
40
- timeframe,
41
- clinicCalendarEvents,
42
- practitionerCalendarEvents,
43
- tz,
44
- } = request;
45
-
46
- // Get scheduling interval (default to 15 minutes if not specified)
47
- const schedulingIntervalMinutes =
48
- (clinic as any).schedulingInterval || this.DEFAULT_INTERVAL_MINUTES;
49
-
50
- // Get procedure duration in minutes
51
- const procedureDurationMinutes = procedure.duration;
52
-
53
- console.log(
54
- `Calculating slots with interval: ${schedulingIntervalMinutes}min and procedure duration: ${procedureDurationMinutes}min`
55
- );
56
-
57
- // Start with the full timeframe as initially available
58
- let availableIntervals: TimeInterval[] = [
59
- { start: timeframe.start, end: timeframe.end },
60
- ];
61
-
62
- // Step 1: Apply clinic working hours
63
- availableIntervals = this.applyClinicWorkingHours(
64
- availableIntervals,
65
- clinic.workingHours,
66
- timeframe,
67
- tz
68
- );
69
-
70
- // Step 2: Subtract clinic blocking events
71
- availableIntervals = this.subtractBlockingEvents(
72
- availableIntervals,
73
- clinicCalendarEvents
74
- );
75
-
76
- // Step 3: Apply practitioner's working hours for this clinic
77
- availableIntervals = this.applyPractitionerWorkingHours(
78
- availableIntervals,
79
- practitioner,
80
- clinic.id,
81
- timeframe,
82
- tz
83
- );
84
-
85
- // Step 4: Subtract practitioner's busy times
86
- availableIntervals = this.subtractPractitionerBusyTimes(
87
- availableIntervals,
88
- practitionerCalendarEvents
89
- );
90
-
91
- console.log(
92
- `After all filters, have ${availableIntervals.length} available intervals`
93
- );
94
-
95
- // Step 5: Generate available slots based on scheduling interval and procedure duration
96
- const availableSlots = this.generateAvailableSlots(
97
- availableIntervals,
98
- schedulingIntervalMinutes,
99
- procedureDurationMinutes,
100
- tz
101
- );
102
-
103
- return { availableSlots };
104
- }
105
-
106
- /**
107
- * Apply clinic working hours to available intervals
108
- *
109
- * @param intervals - Current available intervals
110
- * @param workingHours - Clinic working hours
111
- * @param timeframe - Overall timeframe being considered
112
- * @param tz - IANA timezone of the clinic
113
- * @returns Intervals filtered by clinic working hours
114
- */
115
- private static applyClinicWorkingHours(
116
- intervals: TimeInterval[],
117
- workingHours: any, // Using 'any' for now since we're working with the existing type structure
118
- timeframe: { start: Timestamp; end: Timestamp },
119
- tz: string
120
- ): TimeInterval[] {
121
- if (!intervals.length) return [];
122
- console.log(
123
- `Applying clinic working hours to ${intervals.length} intervals`
124
- );
125
-
126
- // Create working intervals for each day in the timeframe based on clinic's working hours
127
- const workingIntervals = this.createWorkingHoursIntervals(
128
- workingHours,
129
- timeframe.start.toDate(),
130
- timeframe.end.toDate(),
131
- tz
132
- );
133
-
134
- // Intersect the available intervals with working hours intervals
135
- return this.intersectIntervals(intervals, workingIntervals);
136
- }
137
-
138
- /**
139
- * Create time intervals for working hours across multiple days
140
- *
141
- * @param workingHours - Working hours definition
142
- * @param startDate - Start date of the overall timeframe
143
- * @param endDate - End date of the overall timeframe
144
- * @param tz - IANA timezone of the clinic
145
- * @returns Array of time intervals representing working hours
146
- */
147
- private static createWorkingHoursIntervals(
148
- workingHours: any,
149
- startDate: Date,
150
- endDate: Date,
151
- tz: string
152
- ): TimeInterval[] {
153
- const workingIntervals: TimeInterval[] = [];
154
- // FIXED: Use fromMillis instead of fromJSDate to avoid timezone reinterpretation
155
- let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
156
- const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
157
-
158
- while (start <= end) {
159
- const dayOfWeek = start.weekday; // 1 for Monday, 7 for Sunday
160
- const dayName = [
161
- "monday",
162
- "tuesday",
163
- "wednesday",
164
- "thursday",
165
- "friday",
166
- "saturday",
167
- "sunday",
168
- ][dayOfWeek - 1];
169
-
170
- if (dayName && workingHours[dayName]) {
171
- const daySchedule = workingHours[dayName];
172
- if (daySchedule) {
173
- const [openHours, openMinutes] = daySchedule.open
174
- .split(":")
175
- .map(Number);
176
- const [closeHours, closeMinutes] = daySchedule.close
177
- .split(":")
178
- .map(Number);
179
-
180
- let workStart = start.set({
181
- hour: openHours,
182
- minute: openMinutes,
183
- second: 0,
184
- millisecond: 0,
185
- });
186
- let workEnd = start.set({
187
- hour: closeHours,
188
- minute: closeMinutes,
189
- second: 0,
190
- millisecond: 0,
191
- });
192
-
193
- if (
194
- workEnd.toMillis() > startDate.getTime() &&
195
- workStart.toMillis() < endDate.getTime()
196
- ) {
197
- // FIXED: Use fromMillis instead of fromJSDate
198
- const intervalStart =
199
- workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz })
200
- ? DateTime.fromMillis(startDate.getTime(), { zone: tz })
201
- : workStart;
202
- const intervalEnd =
203
- workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz })
204
- ? DateTime.fromMillis(endDate.getTime(), { zone: tz })
205
- : workEnd;
206
-
207
- workingIntervals.push({
208
- start: Timestamp.fromMillis(intervalStart.toMillis()),
209
- end: Timestamp.fromMillis(intervalEnd.toMillis()),
210
- });
211
-
212
- if (daySchedule.breaks && daySchedule.breaks.length > 0) {
213
- for (const breakTime of daySchedule.breaks) {
214
- const [breakStartHours, breakStartMinutes] = breakTime.start
215
- .split(":")
216
- .map(Number);
217
- const [breakEndHours, breakEndMinutes] = breakTime.end
218
- .split(":")
219
- .map(Number);
220
-
221
- const breakStart = start.set({
222
- hour: breakStartHours,
223
- minute: breakStartMinutes,
224
- });
225
- const breakEnd = start.set({
226
- hour: breakEndHours,
227
- minute: breakEndMinutes,
228
- });
229
-
230
- workingIntervals.splice(
231
- -1,
232
- 1,
233
- ...this.subtractInterval(
234
- workingIntervals[workingIntervals.length - 1],
235
- {
236
- start: Timestamp.fromMillis(breakStart.toMillis()),
237
- end: Timestamp.fromMillis(breakEnd.toMillis()),
238
- }
239
- )
240
- );
241
- }
242
- }
243
- }
244
- }
245
- }
246
- start = start.plus({ days: 1 });
247
- }
248
- return workingIntervals;
249
- }
250
-
251
- /**
252
- * Subtract blocking events from available intervals
253
- *
254
- * @param intervals - Current available intervals
255
- * @param events - Calendar events to subtract
256
- * @returns Available intervals after removing blocking events
257
- */
258
- private static subtractBlockingEvents(
259
- intervals: TimeInterval[],
260
- events: CalendarEvent[]
261
- ): TimeInterval[] {
262
- if (!intervals.length) return [];
263
- console.log(`Subtracting ${events.length} blocking events`);
264
-
265
- // Filter only blocking-type events
266
- const blockingEvents = events.filter(
267
- (event) =>
268
- event.eventType === CalendarEventType.BLOCKING ||
269
- event.eventType === CalendarEventType.BREAK ||
270
- event.eventType === CalendarEventType.FREE_DAY
271
- );
272
-
273
- let result = [...intervals];
274
-
275
- // For each blocking event, subtract its time from the available intervals
276
- for (const event of blockingEvents) {
277
- const { start, end } = event.eventTime;
278
- const blockingInterval = { start, end };
279
-
280
- // Create a new result array by subtracting the blocking interval from each available interval
281
- const newResult: TimeInterval[] = [];
282
-
283
- for (const interval of result) {
284
- const remainingIntervals = this.subtractInterval(
285
- interval,
286
- blockingInterval
287
- );
288
- newResult.push(...remainingIntervals);
289
- }
290
-
291
- result = newResult;
292
- }
293
-
294
- return result;
295
- }
296
-
297
- /**
298
- * Apply practitioner's specific working hours for the given clinic
299
- *
300
- * @param intervals - Current available intervals
301
- * @param practitioner - Practitioner object
302
- * @param clinicId - ID of the clinic
303
- * @param timeframe - Overall timeframe being considered
304
- * @param tz - IANA timezone of the clinic
305
- * @returns Intervals filtered by practitioner's working hours
306
- */
307
- private static applyPractitionerWorkingHours(
308
- intervals: TimeInterval[],
309
- practitioner: any,
310
- clinicId: string,
311
- timeframe: { start: Timestamp; end: Timestamp },
312
- tz: string
313
- ): TimeInterval[] {
314
- if (!intervals.length) return [];
315
- console.log(`Applying practitioner working hours for clinic ${clinicId}`);
316
-
317
- // Find practitioner's working hours for this specific clinic
318
- const clinicWorkingHours = practitioner.clinicWorkingHours.find(
319
- (hours: PractitionerClinicWorkingHours) =>
320
- hours.clinicId === clinicId && hours.isActive
321
- );
322
-
323
- // If no specific working hours are found, practitioner isn't available at this clinic
324
- if (!clinicWorkingHours) {
325
- console.log(
326
- `No working hours found for practitioner at clinic ${clinicId}`
327
- );
328
- return [];
329
- }
330
-
331
- // Create working intervals for each day in the timeframe based on practitioner's working hours
332
- const workingIntervals = this.createPractitionerWorkingHoursIntervals(
333
- clinicWorkingHours.workingHours,
334
- timeframe.start.toDate(),
335
- timeframe.end.toDate(),
336
- tz
337
- );
338
-
339
- // Intersect the available intervals with practitioner's working hours intervals
340
- return this.intersectIntervals(intervals, workingIntervals);
341
- }
342
-
343
- /**
344
- * Create time intervals for practitioner's working hours across multiple days
345
- *
346
- * @param workingHours - Practitioner's working hours definition
347
- * @param startDate - Start date of the overall timeframe
348
- * @param endDate - End date of the overall timeframe
349
- * @param tz - IANA timezone of the clinic
350
- * @returns Array of time intervals representing practitioner's working hours
351
- */
352
- private static createPractitionerWorkingHoursIntervals(
353
- workingHours: any,
354
- startDate: Date,
355
- endDate: Date,
356
- tz: string
357
- ): TimeInterval[] {
358
- const workingIntervals: TimeInterval[] = [];
359
- // FIXED: Use fromMillis instead of fromJSDate to avoid timezone reinterpretation
360
- let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
361
- const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
362
-
363
- while (start <= end) {
364
- const dayOfWeek = start.weekday;
365
- const dayName = [
366
- "monday",
367
- "tuesday",
368
- "wednesday",
369
- "thursday",
370
- "friday",
371
- "saturday",
372
- "sunday",
373
- ][dayOfWeek - 1];
374
-
375
- if (dayName && workingHours[dayName]) {
376
- const daySchedule = workingHours[dayName];
377
- if (daySchedule) {
378
- const [startHours, startMinutes] = daySchedule.start
379
- .split(":")
380
- .map(Number);
381
- const [endHours, endMinutes] = daySchedule.end.split(":").map(Number);
382
-
383
- const workStart = start.set({
384
- hour: startHours,
385
- minute: startMinutes,
386
- });
387
- const workEnd = start.set({ hour: endHours, minute: endMinutes });
388
-
389
- if (
390
- workEnd.toMillis() > startDate.getTime() &&
391
- workStart.toMillis() < endDate.getTime()
392
- ) {
393
- // FIXED: Use fromMillis instead of fromJSDate
394
- const intervalStart =
395
- workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz })
396
- ? DateTime.fromMillis(startDate.getTime(), { zone: tz })
397
- : workStart;
398
- const intervalEnd =
399
- workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz })
400
- ? DateTime.fromMillis(endDate.getTime(), { zone: tz })
401
- : workEnd;
402
-
403
- workingIntervals.push({
404
- start: Timestamp.fromMillis(intervalStart.toMillis()),
405
- end: Timestamp.fromMillis(intervalEnd.toMillis()),
406
- });
407
- }
408
- }
409
- }
410
- start = start.plus({ days: 1 });
411
- }
412
- return workingIntervals;
413
- }
414
-
415
- /**
416
- * Subtract practitioner's busy times from available intervals
417
- *
418
- * @param intervals - Current available intervals
419
- * @param events - Practitioner's calendar events
420
- * @returns Available intervals after removing busy times
421
- */
422
- private static subtractPractitionerBusyTimes(
423
- intervals: TimeInterval[],
424
- events: CalendarEvent[]
425
- ): TimeInterval[] {
426
- if (!intervals.length) return [];
427
- console.log(`Subtracting ${events.length} practitioner events`);
428
-
429
- // Filter events that make the practitioner busy (pending, confirmed, blocking)
430
- const busyEvents = events.filter(
431
- (event) =>
432
- // Include all blocking events
433
- event.eventType === CalendarEventType.BLOCKING ||
434
- event.eventType === CalendarEventType.BREAK ||
435
- event.eventType === CalendarEventType.FREE_DAY ||
436
- // Include appointments that are pending, confirmed, or rescheduled
437
- (event.eventType === CalendarEventType.APPOINTMENT &&
438
- (event.status === CalendarEventStatus.PENDING ||
439
- event.status === CalendarEventStatus.CONFIRMED ||
440
- event.status === CalendarEventStatus.RESCHEDULED))
441
- );
442
-
443
- let result = [...intervals];
444
-
445
- // For each busy event, subtract its time from the available intervals
446
- for (const event of busyEvents) {
447
- const { start, end } = event.eventTime;
448
- const busyInterval = { start, end };
449
-
450
- // Create a new result array by subtracting the busy interval from each available interval
451
- const newResult: TimeInterval[] = [];
452
-
453
- for (const interval of result) {
454
- const remainingIntervals = this.subtractInterval(
455
- interval,
456
- busyInterval
457
- );
458
- newResult.push(...remainingIntervals);
459
- }
460
-
461
- result = newResult;
462
- }
463
-
464
- return result;
465
- }
466
-
467
- /**
468
- * Generate available booking slots based on the final available intervals
469
- *
470
- * @param intervals - Final available intervals
471
- * @param intervalMinutes - Scheduling interval in minutes
472
- * @param durationMinutes - Procedure duration in minutes
473
- * @param tz - IANA timezone of the clinic
474
- * @returns Array of available booking slots
475
- */
476
- private static generateAvailableSlots(
477
- intervals: TimeInterval[],
478
- intervalMinutes: number,
479
- durationMinutes: number,
480
- tz: string
481
- ): AvailableSlot[] {
482
- const slots: AvailableSlot[] = [];
483
- console.log(
484
- `Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
485
- );
486
-
487
- // Get current time in clinic timezone
488
- const nowInClinicTz = DateTime.now().setZone(tz);
489
- // Add minimum booking window (15 minutes from now)
490
- const MINIMUM_BOOKING_WINDOW_MINUTES = 15;
491
- const earliestBookableTime = nowInClinicTz.plus({ minutes: MINIMUM_BOOKING_WINDOW_MINUTES });
492
-
493
- console.log(
494
- `Current time in ${tz}: ${nowInClinicTz.toISO()}, earliest bookable: ${earliestBookableTime.toISO()}`
495
- );
496
-
497
- // Convert duration to milliseconds
498
- const durationMs = durationMinutes * 60 * 1000;
499
- // Convert interval to milliseconds
500
- const intervalMs = intervalMinutes * 60 * 1000;
501
-
502
- // For each available interval
503
- for (const interval of intervals) {
504
- // Convert timestamps to JS Date objects for easier manipulation
505
- const intervalStart = interval.start.toDate();
506
- const intervalEnd = interval.end.toDate();
507
-
508
- // Start at the beginning of the interval IN CLINIC TIMEZONE
509
- let slotStart = DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
510
-
511
- // Adjust slotStart to the nearest interval boundary if needed
512
- const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
513
- const minutesRemainder = minutesIntoDay % intervalMinutes;
514
-
515
- if (minutesRemainder > 0) {
516
- slotStart = slotStart.plus({
517
- minutes: intervalMinutes - minutesRemainder,
518
- });
519
- }
520
-
521
- // Iterate through potential start times
522
- while (slotStart.toMillis() + durationMs <= intervalEnd.getTime()) {
523
- // Calculate potential end time
524
- const slotEnd = slotStart.plus({ minutes: durationMinutes });
525
-
526
- // CRITICAL FIX: Filter out past slots and slots too close to now
527
- const isInFuture = slotStart >= earliestBookableTime;
528
-
529
- // Check if this slot fits entirely within one of our available intervals AND is in the future
530
- if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
531
- slots.push({
532
- start: Timestamp.fromMillis(slotStart.toMillis()),
533
- });
534
- }
535
-
536
- // Move to the next potential start time
537
- slotStart = slotStart.plus({ minutes: intervalMinutes });
538
- }
539
- }
540
-
541
- console.log(`Generated ${slots.length} available slots (filtered for future times with ${MINIMUM_BOOKING_WINDOW_MINUTES}min minimum window)`);
542
- return slots;
543
- }
544
-
545
- /**
546
- * Check if a time slot is fully available within the given intervals
547
- *
548
- * @param slotStart - Start time of the slot
549
- * @param slotEnd - End time of the slot
550
- * @param intervals - Available intervals
551
- * @param tz - IANA timezone of the clinic
552
- * @returns True if the slot is fully contained within an available interval
553
- */
554
- private static isSlotFullyAvailable(
555
- slotStart: DateTime,
556
- slotEnd: DateTime,
557
- intervals: TimeInterval[],
558
- tz: string
559
- ): boolean {
560
- // Check if the slot is fully contained in any of the available intervals
561
- return intervals.some((interval) => {
562
- const intervalStart = DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
563
- const intervalEnd = DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
564
-
565
- return slotStart >= intervalStart && slotEnd <= intervalEnd;
566
- });
567
- }
568
-
569
- /**
570
- * Intersect two sets of time intervals
571
- *
572
- * @param intervalsA - First set of intervals
573
- * @param intervalsB - Second set of intervals
574
- * @returns Intersection of the two sets of intervals
575
- */
576
- private static intersectIntervals(
577
- intervalsA: TimeInterval[],
578
- intervalsB: TimeInterval[]
579
- ): TimeInterval[] {
580
- const result: TimeInterval[] = [];
581
-
582
- // For each pair of intervals, find their intersection
583
- for (const intervalA of intervalsA) {
584
- for (const intervalB of intervalsB) {
585
- // Find the later of the two start times
586
- const intersectionStart =
587
- intervalA.start.toMillis() > intervalB.start.toMillis()
588
- ? intervalA.start
589
- : intervalB.start;
590
-
591
- // Find the earlier of the two end times
592
- const intersectionEnd =
593
- intervalA.end.toMillis() < intervalB.end.toMillis()
594
- ? intervalA.end
595
- : intervalB.end;
596
-
597
- // If there is a valid intersection, add it to the result
598
- if (intersectionStart.toMillis() < intersectionEnd.toMillis()) {
599
- result.push({
600
- start: intersectionStart,
601
- end: intersectionEnd,
602
- });
603
- }
604
- }
605
- }
606
-
607
- return this.mergeOverlappingIntervals(result);
608
- }
609
-
610
- /**
611
- * Subtract one interval from another, potentially resulting in 0, 1, or 2 intervals
612
- *
613
- * @param interval - Interval to subtract from
614
- * @param subtrahend - Interval to subtract
615
- * @returns Array of remaining intervals after subtraction
616
- */
617
- private static subtractInterval(
618
- interval: TimeInterval,
619
- subtrahend: TimeInterval
620
- ): TimeInterval[] {
621
- // Case 1: No overlap - return the original interval
622
- if (
623
- interval.end.toMillis() <= subtrahend.start.toMillis() ||
624
- interval.start.toMillis() >= subtrahend.end.toMillis()
625
- ) {
626
- return [interval];
627
- }
628
-
629
- // Case 2: Subtrahend covers the entire interval - return empty array
630
- if (
631
- subtrahend.start.toMillis() <= interval.start.toMillis() &&
632
- subtrahend.end.toMillis() >= interval.end.toMillis()
633
- ) {
634
- return [];
635
- }
636
-
637
- // Case 3: Subtrahend splits the interval - return two intervals
638
- if (
639
- subtrahend.start.toMillis() > interval.start.toMillis() &&
640
- subtrahend.end.toMillis() < interval.end.toMillis()
641
- ) {
642
- return [
643
- {
644
- start: interval.start,
645
- end: subtrahend.start,
646
- },
647
- {
648
- start: subtrahend.end,
649
- end: interval.end,
650
- },
651
- ];
652
- }
653
-
654
- // Case 4: Subtrahend overlaps only the start - return the remaining end portion
655
- if (
656
- subtrahend.start.toMillis() <= interval.start.toMillis() &&
657
- subtrahend.end.toMillis() > interval.start.toMillis()
658
- ) {
659
- return [
660
- {
661
- start: subtrahend.end,
662
- end: interval.end,
663
- },
664
- ];
665
- }
666
-
667
- // Case 5: Subtrahend overlaps only the end - return the remaining start portion
668
- return [
669
- {
670
- start: interval.start,
671
- end: subtrahend.start,
672
- },
673
- ];
674
- }
675
-
676
- /**
677
- * Merge overlapping intervals to simplify the result
678
- *
679
- * @param intervals - Intervals to merge
680
- * @returns Merged intervals
681
- */
682
- private static mergeOverlappingIntervals(
683
- intervals: TimeInterval[]
684
- ): TimeInterval[] {
685
- if (intervals.length <= 1) return intervals;
686
-
687
- // Sort intervals by start time
688
- const sorted = [...intervals].sort(
689
- (a, b) => a.start.toMillis() - b.start.toMillis()
690
- );
691
-
692
- const result: TimeInterval[] = [sorted[0]];
693
-
694
- for (let i = 1; i < sorted.length; i++) {
695
- const current = sorted[i];
696
- const lastResult = result[result.length - 1];
697
-
698
- // If current interval overlaps with the last result interval, merge them
699
- if (current.start.toMillis() <= lastResult.end.toMillis()) {
700
- // Update the end time of the last result to be the maximum of the two end times
701
- if (current.end.toMillis() > lastResult.end.toMillis()) {
702
- lastResult.end = current.end;
703
- }
704
- } else {
705
- // No overlap, add the current interval to the result
706
- result.push(current);
707
- }
708
- }
709
-
710
- return result;
711
- }
712
- }
1
+ import { Timestamp } from "firebase/firestore";
2
+ import { DateTime } from "luxon";
3
+ import {
4
+ BookingAvailabilityRequest,
5
+ BookingAvailabilityResponse,
6
+ AvailableSlot,
7
+ TimeInterval,
8
+ } from "./booking.types";
9
+ import {
10
+ CalendarEvent,
11
+ CalendarEventStatus,
12
+ CalendarEventType,
13
+ } from "../../types/calendar";
14
+ import { PractitionerClinicWorkingHours } from "../../types/practitioner";
15
+ import { Clinic } from "../../types/clinic";
16
+ import { ResourceCalendarEvent } from "../../types/resource";
17
+
18
+ /**
19
+ * Calculator for determining available booking slots
20
+ * This class handles the complex logic of determining when appointments can be scheduled
21
+ * based on clinic working hours, practitioner availability, and existing calendar events.
22
+ */
23
+ export class BookingAvailabilityCalculator {
24
+ /** Default scheduling interval in minutes if not specified by the clinic */
25
+ private static readonly DEFAULT_INTERVAL_MINUTES = 15;
26
+
27
+ /**
28
+ * Calculate available booking slots based on the provided data
29
+ *
30
+ * @param request - The request containing all necessary data for calculation
31
+ * @returns Response with available booking slots
32
+ */
33
+ public static calculateSlots(
34
+ request: BookingAvailabilityRequest
35
+ ): BookingAvailabilityResponse {
36
+ // Extract necessary data from the request
37
+ const {
38
+ clinic,
39
+ practitioner,
40
+ procedure,
41
+ timeframe,
42
+ clinicCalendarEvents,
43
+ practitionerCalendarEvents,
44
+ tz,
45
+ } = request;
46
+
47
+ // Get scheduling interval (default to 15 minutes if not specified)
48
+ const schedulingIntervalMinutes =
49
+ (clinic as any).schedulingInterval || this.DEFAULT_INTERVAL_MINUTES;
50
+
51
+ // Get procedure duration in minutes
52
+ const procedureDurationMinutes = procedure.duration;
53
+
54
+ console.log(
55
+ `Calculating slots with interval: ${schedulingIntervalMinutes}min and procedure duration: ${procedureDurationMinutes}min`
56
+ );
57
+
58
+ // Start with the full timeframe as initially available
59
+ let availableIntervals: TimeInterval[] = [
60
+ { start: timeframe.start, end: timeframe.end },
61
+ ];
62
+
63
+ // Step 1: Apply clinic working hours
64
+ availableIntervals = this.applyClinicWorkingHours(
65
+ availableIntervals,
66
+ clinic.workingHours,
67
+ timeframe,
68
+ tz
69
+ );
70
+
71
+ // Step 2: Subtract clinic blocking events
72
+ availableIntervals = this.subtractBlockingEvents(
73
+ availableIntervals,
74
+ clinicCalendarEvents
75
+ );
76
+
77
+ // Step 3: Apply practitioner's working hours for this clinic
78
+ availableIntervals = this.applyPractitionerWorkingHours(
79
+ availableIntervals,
80
+ practitioner,
81
+ clinic.id,
82
+ timeframe,
83
+ tz
84
+ );
85
+
86
+ // Step 4: Subtract practitioner's busy times
87
+ availableIntervals = this.subtractPractitionerBusyTimes(
88
+ availableIntervals,
89
+ practitionerCalendarEvents
90
+ );
91
+
92
+ // Step 5: Apply resource availability constraints (if procedure requires resources)
93
+ if (request.resourceCalendarEventsMap) {
94
+ availableIntervals = this.applyResourceAvailability(
95
+ availableIntervals,
96
+ request.resourceCalendarEventsMap,
97
+ timeframe
98
+ );
99
+ }
100
+
101
+ console.log(
102
+ `After all filters, have ${availableIntervals.length} available intervals`
103
+ );
104
+
105
+ // Step 6: Generate available slots based on scheduling interval and procedure duration
106
+ const availableSlots = this.generateAvailableSlots(
107
+ availableIntervals,
108
+ schedulingIntervalMinutes,
109
+ procedureDurationMinutes,
110
+ tz
111
+ );
112
+
113
+ return { availableSlots };
114
+ }
115
+
116
+ /**
117
+ * Apply clinic working hours to available intervals
118
+ *
119
+ * @param intervals - Current available intervals
120
+ * @param workingHours - Clinic working hours
121
+ * @param timeframe - Overall timeframe being considered
122
+ * @param tz - IANA timezone of the clinic
123
+ * @returns Intervals filtered by clinic working hours
124
+ */
125
+ private static applyClinicWorkingHours(
126
+ intervals: TimeInterval[],
127
+ workingHours: any, // Using 'any' for now since we're working with the existing type structure
128
+ timeframe: { start: Timestamp; end: Timestamp },
129
+ tz: string
130
+ ): TimeInterval[] {
131
+ if (!intervals.length) return [];
132
+ console.log(
133
+ `Applying clinic working hours to ${intervals.length} intervals`
134
+ );
135
+
136
+ // Create working intervals for each day in the timeframe based on clinic's working hours
137
+ const workingIntervals = this.createWorkingHoursIntervals(
138
+ workingHours,
139
+ timeframe.start.toDate(),
140
+ timeframe.end.toDate(),
141
+ tz
142
+ );
143
+
144
+ // Intersect the available intervals with working hours intervals
145
+ return this.intersectIntervals(intervals, workingIntervals);
146
+ }
147
+
148
+ /**
149
+ * Create time intervals for working hours across multiple days
150
+ *
151
+ * @param workingHours - Working hours definition
152
+ * @param startDate - Start date of the overall timeframe
153
+ * @param endDate - End date of the overall timeframe
154
+ * @param tz - IANA timezone of the clinic
155
+ * @returns Array of time intervals representing working hours
156
+ */
157
+ private static createWorkingHoursIntervals(
158
+ workingHours: any,
159
+ startDate: Date,
160
+ endDate: Date,
161
+ tz: string
162
+ ): TimeInterval[] {
163
+ const workingIntervals: TimeInterval[] = [];
164
+ // FIXED: Use fromMillis instead of fromJSDate to avoid timezone reinterpretation
165
+ let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
166
+ const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
167
+
168
+ while (start <= end) {
169
+ const dayOfWeek = start.weekday; // 1 for Monday, 7 for Sunday
170
+ const dayName = [
171
+ "monday",
172
+ "tuesday",
173
+ "wednesday",
174
+ "thursday",
175
+ "friday",
176
+ "saturday",
177
+ "sunday",
178
+ ][dayOfWeek - 1];
179
+
180
+ if (dayName && workingHours[dayName]) {
181
+ const daySchedule = workingHours[dayName];
182
+ if (daySchedule) {
183
+ const [openHours, openMinutes] = daySchedule.open
184
+ .split(":")
185
+ .map(Number);
186
+ const [closeHours, closeMinutes] = daySchedule.close
187
+ .split(":")
188
+ .map(Number);
189
+
190
+ let workStart = start.set({
191
+ hour: openHours,
192
+ minute: openMinutes,
193
+ second: 0,
194
+ millisecond: 0,
195
+ });
196
+ let workEnd = start.set({
197
+ hour: closeHours,
198
+ minute: closeMinutes,
199
+ second: 0,
200
+ millisecond: 0,
201
+ });
202
+
203
+ if (
204
+ workEnd.toMillis() > startDate.getTime() &&
205
+ workStart.toMillis() < endDate.getTime()
206
+ ) {
207
+ // FIXED: Use fromMillis instead of fromJSDate
208
+ const intervalStart =
209
+ workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz })
210
+ ? DateTime.fromMillis(startDate.getTime(), { zone: tz })
211
+ : workStart;
212
+ const intervalEnd =
213
+ workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz })
214
+ ? DateTime.fromMillis(endDate.getTime(), { zone: tz })
215
+ : workEnd;
216
+
217
+ workingIntervals.push({
218
+ start: Timestamp.fromMillis(intervalStart.toMillis()),
219
+ end: Timestamp.fromMillis(intervalEnd.toMillis()),
220
+ });
221
+
222
+ if (daySchedule.breaks && daySchedule.breaks.length > 0) {
223
+ for (const breakTime of daySchedule.breaks) {
224
+ const [breakStartHours, breakStartMinutes] = breakTime.start
225
+ .split(":")
226
+ .map(Number);
227
+ const [breakEndHours, breakEndMinutes] = breakTime.end
228
+ .split(":")
229
+ .map(Number);
230
+
231
+ const breakStart = start.set({
232
+ hour: breakStartHours,
233
+ minute: breakStartMinutes,
234
+ });
235
+ const breakEnd = start.set({
236
+ hour: breakEndHours,
237
+ minute: breakEndMinutes,
238
+ });
239
+
240
+ workingIntervals.splice(
241
+ -1,
242
+ 1,
243
+ ...this.subtractInterval(
244
+ workingIntervals[workingIntervals.length - 1],
245
+ {
246
+ start: Timestamp.fromMillis(breakStart.toMillis()),
247
+ end: Timestamp.fromMillis(breakEnd.toMillis()),
248
+ }
249
+ )
250
+ );
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+ start = start.plus({ days: 1 });
257
+ }
258
+ return workingIntervals;
259
+ }
260
+
261
+ /**
262
+ * Subtract blocking events from available intervals
263
+ *
264
+ * @param intervals - Current available intervals
265
+ * @param events - Calendar events to subtract
266
+ * @returns Available intervals after removing blocking events
267
+ */
268
+ private static subtractBlockingEvents(
269
+ intervals: TimeInterval[],
270
+ events: CalendarEvent[]
271
+ ): TimeInterval[] {
272
+ if (!intervals.length) return [];
273
+ console.log(`Subtracting ${events.length} blocking events`);
274
+
275
+ // Filter only blocking-type events
276
+ const blockingEvents = events.filter(
277
+ (event) =>
278
+ event.eventType === CalendarEventType.BLOCKING ||
279
+ event.eventType === CalendarEventType.BREAK ||
280
+ event.eventType === CalendarEventType.FREE_DAY
281
+ );
282
+
283
+ let result = [...intervals];
284
+
285
+ // For each blocking event, subtract its time from the available intervals
286
+ for (const event of blockingEvents) {
287
+ const { start, end } = event.eventTime;
288
+ const blockingInterval = { start, end };
289
+
290
+ // Create a new result array by subtracting the blocking interval from each available interval
291
+ const newResult: TimeInterval[] = [];
292
+
293
+ for (const interval of result) {
294
+ const remainingIntervals = this.subtractInterval(
295
+ interval,
296
+ blockingInterval
297
+ );
298
+ newResult.push(...remainingIntervals);
299
+ }
300
+
301
+ result = newResult;
302
+ }
303
+
304
+ return result;
305
+ }
306
+
307
+ /**
308
+ * Apply practitioner's specific working hours for the given clinic
309
+ *
310
+ * @param intervals - Current available intervals
311
+ * @param practitioner - Practitioner object
312
+ * @param clinicId - ID of the clinic
313
+ * @param timeframe - Overall timeframe being considered
314
+ * @param tz - IANA timezone of the clinic
315
+ * @returns Intervals filtered by practitioner's working hours
316
+ */
317
+ private static applyPractitionerWorkingHours(
318
+ intervals: TimeInterval[],
319
+ practitioner: any,
320
+ clinicId: string,
321
+ timeframe: { start: Timestamp; end: Timestamp },
322
+ tz: string
323
+ ): TimeInterval[] {
324
+ if (!intervals.length) return [];
325
+ console.log(`Applying practitioner working hours for clinic ${clinicId}`);
326
+
327
+ // Find practitioner's working hours for this specific clinic
328
+ const clinicWorkingHours = practitioner.clinicWorkingHours.find(
329
+ (hours: PractitionerClinicWorkingHours) =>
330
+ hours.clinicId === clinicId && hours.isActive
331
+ );
332
+
333
+ // If no specific working hours are found, practitioner isn't available at this clinic
334
+ if (!clinicWorkingHours) {
335
+ console.log(
336
+ `No working hours found for practitioner at clinic ${clinicId}`
337
+ );
338
+ return [];
339
+ }
340
+
341
+ // Create working intervals for each day in the timeframe based on practitioner's working hours
342
+ const workingIntervals = this.createPractitionerWorkingHoursIntervals(
343
+ clinicWorkingHours.workingHours,
344
+ timeframe.start.toDate(),
345
+ timeframe.end.toDate(),
346
+ tz
347
+ );
348
+
349
+ // Intersect the available intervals with practitioner's working hours intervals
350
+ return this.intersectIntervals(intervals, workingIntervals);
351
+ }
352
+
353
+ /**
354
+ * Create time intervals for practitioner's working hours across multiple days
355
+ *
356
+ * @param workingHours - Practitioner's working hours definition
357
+ * @param startDate - Start date of the overall timeframe
358
+ * @param endDate - End date of the overall timeframe
359
+ * @param tz - IANA timezone of the clinic
360
+ * @returns Array of time intervals representing practitioner's working hours
361
+ */
362
+ private static createPractitionerWorkingHoursIntervals(
363
+ workingHours: any,
364
+ startDate: Date,
365
+ endDate: Date,
366
+ tz: string
367
+ ): TimeInterval[] {
368
+ const workingIntervals: TimeInterval[] = [];
369
+ // FIXED: Use fromMillis instead of fromJSDate to avoid timezone reinterpretation
370
+ let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
371
+ const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
372
+
373
+ while (start <= end) {
374
+ const dayOfWeek = start.weekday;
375
+ const dayName = [
376
+ "monday",
377
+ "tuesday",
378
+ "wednesday",
379
+ "thursday",
380
+ "friday",
381
+ "saturday",
382
+ "sunday",
383
+ ][dayOfWeek - 1];
384
+
385
+ if (dayName && workingHours[dayName]) {
386
+ const daySchedule = workingHours[dayName];
387
+ if (daySchedule) {
388
+ const [startHours, startMinutes] = daySchedule.start
389
+ .split(":")
390
+ .map(Number);
391
+ const [endHours, endMinutes] = daySchedule.end.split(":").map(Number);
392
+
393
+ const workStart = start.set({
394
+ hour: startHours,
395
+ minute: startMinutes,
396
+ });
397
+ const workEnd = start.set({ hour: endHours, minute: endMinutes });
398
+
399
+ if (
400
+ workEnd.toMillis() > startDate.getTime() &&
401
+ workStart.toMillis() < endDate.getTime()
402
+ ) {
403
+ // FIXED: Use fromMillis instead of fromJSDate
404
+ const intervalStart =
405
+ workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz })
406
+ ? DateTime.fromMillis(startDate.getTime(), { zone: tz })
407
+ : workStart;
408
+ const intervalEnd =
409
+ workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz })
410
+ ? DateTime.fromMillis(endDate.getTime(), { zone: tz })
411
+ : workEnd;
412
+
413
+ workingIntervals.push({
414
+ start: Timestamp.fromMillis(intervalStart.toMillis()),
415
+ end: Timestamp.fromMillis(intervalEnd.toMillis()),
416
+ });
417
+ }
418
+ }
419
+ }
420
+ start = start.plus({ days: 1 });
421
+ }
422
+ return workingIntervals;
423
+ }
424
+
425
+ /**
426
+ * Subtract practitioner's busy times from available intervals
427
+ *
428
+ * @param intervals - Current available intervals
429
+ * @param events - Practitioner's calendar events
430
+ * @returns Available intervals after removing busy times
431
+ */
432
+ private static subtractPractitionerBusyTimes(
433
+ intervals: TimeInterval[],
434
+ events: CalendarEvent[]
435
+ ): TimeInterval[] {
436
+ if (!intervals.length) return [];
437
+ console.log(`Subtracting ${events.length} practitioner events`);
438
+
439
+ // Filter events that make the practitioner busy (pending, confirmed, blocking)
440
+ const busyEvents = events.filter(
441
+ (event) =>
442
+ // Include all blocking events
443
+ event.eventType === CalendarEventType.BLOCKING ||
444
+ event.eventType === CalendarEventType.BREAK ||
445
+ event.eventType === CalendarEventType.FREE_DAY ||
446
+ // Include appointments that are pending, confirmed, or rescheduled
447
+ (event.eventType === CalendarEventType.APPOINTMENT &&
448
+ (event.status === CalendarEventStatus.PENDING ||
449
+ event.status === CalendarEventStatus.CONFIRMED ||
450
+ event.status === CalendarEventStatus.RESCHEDULED))
451
+ );
452
+
453
+ let result = [...intervals];
454
+
455
+ // For each busy event, subtract its time from the available intervals
456
+ for (const event of busyEvents) {
457
+ const { start, end } = event.eventTime;
458
+ const busyInterval = { start, end };
459
+
460
+ // Create a new result array by subtracting the busy interval from each available interval
461
+ const newResult: TimeInterval[] = [];
462
+
463
+ for (const interval of result) {
464
+ const remainingIntervals = this.subtractInterval(
465
+ interval,
466
+ busyInterval
467
+ );
468
+ newResult.push(...remainingIntervals);
469
+ }
470
+
471
+ result = newResult;
472
+ }
473
+
474
+ return result;
475
+ }
476
+
477
+ /**
478
+ * Generate available booking slots based on the final available intervals
479
+ *
480
+ * @param intervals - Final available intervals
481
+ * @param intervalMinutes - Scheduling interval in minutes
482
+ * @param durationMinutes - Procedure duration in minutes
483
+ * @param tz - IANA timezone of the clinic
484
+ * @returns Array of available booking slots
485
+ */
486
+ private static generateAvailableSlots(
487
+ intervals: TimeInterval[],
488
+ intervalMinutes: number,
489
+ durationMinutes: number,
490
+ tz: string
491
+ ): AvailableSlot[] {
492
+ const slots: AvailableSlot[] = [];
493
+ console.log(
494
+ `Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
495
+ );
496
+
497
+ // Get current time in clinic timezone
498
+ const nowInClinicTz = DateTime.now().setZone(tz);
499
+ // Add minimum booking window (15 minutes from now)
500
+ const MINIMUM_BOOKING_WINDOW_MINUTES = 15;
501
+ const earliestBookableTime = nowInClinicTz.plus({ minutes: MINIMUM_BOOKING_WINDOW_MINUTES });
502
+
503
+ console.log(
504
+ `Current time in ${tz}: ${nowInClinicTz.toISO()}, earliest bookable: ${earliestBookableTime.toISO()}`
505
+ );
506
+
507
+ // Convert duration to milliseconds
508
+ const durationMs = durationMinutes * 60 * 1000;
509
+ // Convert interval to milliseconds
510
+ const intervalMs = intervalMinutes * 60 * 1000;
511
+
512
+ // For each available interval
513
+ for (const interval of intervals) {
514
+ // Convert timestamps to JS Date objects for easier manipulation
515
+ const intervalStart = interval.start.toDate();
516
+ const intervalEnd = interval.end.toDate();
517
+
518
+ // Start at the beginning of the interval IN CLINIC TIMEZONE
519
+ let slotStart = DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
520
+
521
+ // Adjust slotStart to the nearest interval boundary if needed
522
+ const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
523
+ const minutesRemainder = minutesIntoDay % intervalMinutes;
524
+
525
+ if (minutesRemainder > 0) {
526
+ slotStart = slotStart.plus({
527
+ minutes: intervalMinutes - minutesRemainder,
528
+ });
529
+ }
530
+
531
+ // Iterate through potential start times
532
+ while (slotStart.toMillis() + durationMs <= intervalEnd.getTime()) {
533
+ // Calculate potential end time
534
+ const slotEnd = slotStart.plus({ minutes: durationMinutes });
535
+
536
+ // CRITICAL FIX: Filter out past slots and slots too close to now
537
+ const isInFuture = slotStart >= earliestBookableTime;
538
+
539
+ // Check if this slot fits entirely within one of our available intervals AND is in the future
540
+ if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
541
+ slots.push({
542
+ start: Timestamp.fromMillis(slotStart.toMillis()),
543
+ });
544
+ }
545
+
546
+ // Move to the next potential start time
547
+ slotStart = slotStart.plus({ minutes: intervalMinutes });
548
+ }
549
+ }
550
+
551
+ console.log(`Generated ${slots.length} available slots (filtered for future times with ${MINIMUM_BOOKING_WINDOW_MINUTES}min minimum window)`);
552
+ return slots;
553
+ }
554
+
555
+ /**
556
+ * Check if a time slot is fully available within the given intervals
557
+ *
558
+ * @param slotStart - Start time of the slot
559
+ * @param slotEnd - End time of the slot
560
+ * @param intervals - Available intervals
561
+ * @param tz - IANA timezone of the clinic
562
+ * @returns True if the slot is fully contained within an available interval
563
+ */
564
+ private static isSlotFullyAvailable(
565
+ slotStart: DateTime,
566
+ slotEnd: DateTime,
567
+ intervals: TimeInterval[],
568
+ tz: string
569
+ ): boolean {
570
+ // Check if the slot is fully contained in any of the available intervals
571
+ return intervals.some((interval) => {
572
+ const intervalStart = DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
573
+ const intervalEnd = DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
574
+
575
+ return slotStart >= intervalStart && slotEnd <= intervalEnd;
576
+ });
577
+ }
578
+
579
+ /**
580
+ * Intersect two sets of time intervals
581
+ *
582
+ * @param intervalsA - First set of intervals
583
+ * @param intervalsB - Second set of intervals
584
+ * @returns Intersection of the two sets of intervals
585
+ */
586
+ private static intersectIntervals(
587
+ intervalsA: TimeInterval[],
588
+ intervalsB: TimeInterval[]
589
+ ): TimeInterval[] {
590
+ const result: TimeInterval[] = [];
591
+
592
+ // For each pair of intervals, find their intersection
593
+ for (const intervalA of intervalsA) {
594
+ for (const intervalB of intervalsB) {
595
+ // Find the later of the two start times
596
+ const intersectionStart =
597
+ intervalA.start.toMillis() > intervalB.start.toMillis()
598
+ ? intervalA.start
599
+ : intervalB.start;
600
+
601
+ // Find the earlier of the two end times
602
+ const intersectionEnd =
603
+ intervalA.end.toMillis() < intervalB.end.toMillis()
604
+ ? intervalA.end
605
+ : intervalB.end;
606
+
607
+ // If there is a valid intersection, add it to the result
608
+ if (intersectionStart.toMillis() < intersectionEnd.toMillis()) {
609
+ result.push({
610
+ start: intersectionStart,
611
+ end: intersectionEnd,
612
+ });
613
+ }
614
+ }
615
+ }
616
+
617
+ return this.mergeOverlappingIntervals(result);
618
+ }
619
+
620
+ /**
621
+ * Subtract one interval from another, potentially resulting in 0, 1, or 2 intervals
622
+ *
623
+ * @param interval - Interval to subtract from
624
+ * @param subtrahend - Interval to subtract
625
+ * @returns Array of remaining intervals after subtraction
626
+ */
627
+ private static subtractInterval(
628
+ interval: TimeInterval,
629
+ subtrahend: TimeInterval
630
+ ): TimeInterval[] {
631
+ // Case 1: No overlap - return the original interval
632
+ if (
633
+ interval.end.toMillis() <= subtrahend.start.toMillis() ||
634
+ interval.start.toMillis() >= subtrahend.end.toMillis()
635
+ ) {
636
+ return [interval];
637
+ }
638
+
639
+ // Case 2: Subtrahend covers the entire interval - return empty array
640
+ if (
641
+ subtrahend.start.toMillis() <= interval.start.toMillis() &&
642
+ subtrahend.end.toMillis() >= interval.end.toMillis()
643
+ ) {
644
+ return [];
645
+ }
646
+
647
+ // Case 3: Subtrahend splits the interval - return two intervals
648
+ if (
649
+ subtrahend.start.toMillis() > interval.start.toMillis() &&
650
+ subtrahend.end.toMillis() < interval.end.toMillis()
651
+ ) {
652
+ return [
653
+ {
654
+ start: interval.start,
655
+ end: subtrahend.start,
656
+ },
657
+ {
658
+ start: subtrahend.end,
659
+ end: interval.end,
660
+ },
661
+ ];
662
+ }
663
+
664
+ // Case 4: Subtrahend overlaps only the start - return the remaining end portion
665
+ if (
666
+ subtrahend.start.toMillis() <= interval.start.toMillis() &&
667
+ subtrahend.end.toMillis() > interval.start.toMillis()
668
+ ) {
669
+ return [
670
+ {
671
+ start: subtrahend.end,
672
+ end: interval.end,
673
+ },
674
+ ];
675
+ }
676
+
677
+ // Case 5: Subtrahend overlaps only the end - return the remaining start portion
678
+ return [
679
+ {
680
+ start: interval.start,
681
+ end: subtrahend.start,
682
+ },
683
+ ];
684
+ }
685
+
686
+ /**
687
+ * Merge overlapping intervals to simplify the result
688
+ *
689
+ * @param intervals - Intervals to merge
690
+ * @returns Merged intervals
691
+ */
692
+ private static mergeOverlappingIntervals(
693
+ intervals: TimeInterval[]
694
+ ): TimeInterval[] {
695
+ if (intervals.length <= 1) return intervals;
696
+
697
+ // Sort intervals by start time
698
+ const sorted = [...intervals].sort(
699
+ (a, b) => a.start.toMillis() - b.start.toMillis()
700
+ );
701
+
702
+ const result: TimeInterval[] = [sorted[0]];
703
+
704
+ for (let i = 1; i < sorted.length; i++) {
705
+ const current = sorted[i];
706
+ const lastResult = result[result.length - 1];
707
+
708
+ // If current interval overlaps with the last result interval, merge them
709
+ if (current.start.toMillis() <= lastResult.end.toMillis()) {
710
+ // Update the end time of the last result to be the maximum of the two end times
711
+ if (current.end.toMillis() > lastResult.end.toMillis()) {
712
+ lastResult.end = current.end;
713
+ }
714
+ } else {
715
+ // No overlap, add the current interval to the result
716
+ result.push(current);
717
+ }
718
+ }
719
+
720
+ return result;
721
+ }
722
+
723
+ /**
724
+ * Apply resource availability constraints to the available intervals.
725
+ * For each required resource:
726
+ * - Compute each instance's availability (full timeframe minus its busy events)
727
+ * - Union all instance availabilities (at least one must be free)
728
+ * Then intersect all resource availabilities with the current doctor availability.
729
+ *
730
+ * @param intervals - Current available intervals (doctor availability)
731
+ * @param resourceEventsMap - Map of resource data with per-instance calendar events
732
+ * @param timeframe - Overall timeframe being considered
733
+ * @returns Intervals where doctor AND all required resources are available
734
+ */
735
+ private static applyResourceAvailability(
736
+ intervals: TimeInterval[],
737
+ resourceEventsMap: Record<
738
+ string,
739
+ {
740
+ resourceId: string;
741
+ resourceName: string;
742
+ quantity: number;
743
+ instanceEvents: Record<string, ResourceCalendarEvent[]>;
744
+ }
745
+ >,
746
+ timeframe: { start: Timestamp; end: Timestamp }
747
+ ): TimeInterval[] {
748
+ if (!intervals.length) return [];
749
+
750
+ const resourceIds = Object.keys(resourceEventsMap);
751
+ if (resourceIds.length === 0) return intervals;
752
+
753
+ console.log(
754
+ `Applying resource availability for ${resourceIds.length} resource(s)`
755
+ );
756
+
757
+ let result = intervals;
758
+
759
+ // For each required resource, compute its combined availability and intersect
760
+ for (const resourceId of resourceIds) {
761
+ const resourceData = resourceEventsMap[resourceId];
762
+ const resourceAvailability = this.computeResourceAvailability(
763
+ resourceData.instanceEvents,
764
+ timeframe
765
+ );
766
+
767
+ console.log(
768
+ `Resource "${resourceData.resourceName}" (qty=${resourceData.quantity}): ${resourceAvailability.length} available intervals`
769
+ );
770
+
771
+ // Intersect doctor availability with this resource's availability
772
+ result = this.intersectIntervals(result, resourceAvailability);
773
+
774
+ if (result.length === 0) {
775
+ console.log(
776
+ `No availability remaining after applying resource "${resourceData.resourceName}"`
777
+ );
778
+ return [];
779
+ }
780
+ }
781
+
782
+ return result;
783
+ }
784
+
785
+ /**
786
+ * Compute combined availability for a single resource across all its instances.
787
+ * For each instance, subtract its busy events from the full timeframe.
788
+ * Then union all instance availabilities — if at least one instance is free, the resource is available.
789
+ *
790
+ * @param instanceEvents - Calendar events keyed by instanceId
791
+ * @param timeframe - Overall timeframe
792
+ * @returns Combined availability intervals for the resource
793
+ */
794
+ private static computeResourceAvailability(
795
+ instanceEvents: Record<string, ResourceCalendarEvent[]>,
796
+ timeframe: { start: Timestamp; end: Timestamp }
797
+ ): TimeInterval[] {
798
+ const instanceIds = Object.keys(instanceEvents);
799
+ if (instanceIds.length === 0) {
800
+ // No instances means the full timeframe is available (no constraints)
801
+ return [{ start: timeframe.start, end: timeframe.end }];
802
+ }
803
+
804
+ // Collect availability intervals from all instances
805
+ const allInstanceAvailabilities: TimeInterval[] = [];
806
+
807
+ for (const instanceId of instanceIds) {
808
+ const events = instanceEvents[instanceId];
809
+ // Start with full timeframe
810
+ let instanceAvailability: TimeInterval[] = [
811
+ { start: timeframe.start, end: timeframe.end },
812
+ ];
813
+
814
+ // Subtract each busy event from this instance's availability
815
+ for (const event of events) {
816
+ // Blocking events always subtract from availability regardless of status.
817
+ // Booking events only subtract when actively held (pending or confirmed).
818
+ const isBlockingEvent =
819
+ (event as any).eventType === CalendarEventType.BLOCKING ||
820
+ (event as any).eventType === CalendarEventType.BREAK ||
821
+ (event as any).eventType === CalendarEventType.FREE_DAY;
822
+
823
+ const isActiveBooking =
824
+ event.status === CalendarEventStatus.PENDING ||
825
+ event.status === CalendarEventStatus.CONFIRMED;
826
+
827
+ if (isBlockingEvent || isActiveBooking) {
828
+ const busyInterval: TimeInterval = {
829
+ start: event.eventTime.start,
830
+ end: event.eventTime.end,
831
+ };
832
+
833
+ const newAvailability: TimeInterval[] = [];
834
+ for (const interval of instanceAvailability) {
835
+ newAvailability.push(
836
+ ...this.subtractInterval(interval, busyInterval)
837
+ );
838
+ }
839
+ instanceAvailability = newAvailability;
840
+ }
841
+ }
842
+
843
+ // Add this instance's available intervals to the combined pool
844
+ allInstanceAvailabilities.push(...instanceAvailability);
845
+ }
846
+
847
+ // Union (merge) all instance availabilities — at least one instance being free is sufficient
848
+ return this.mergeOverlappingIntervals(allInstanceAvailabilities);
849
+ }
850
+ }