@blackcode_sa/metaestetics-api 1.13.3 → 1.13.5

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