@blackcode_sa/metaestetics-api 1.13.5 → 1.13.8

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