@blackcode_sa/metaestetics-api 1.12.61 → 1.12.62

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