@blackcode_sa/metaestetics-api 1.12.62 → 1.12.64

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