@blackcode_sa/metaestetics-api 1.13.5 → 1.13.6

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