@blackcode_sa/metaestetics-api 1.8.18 → 1.11.0

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.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.8.18",
4
+ "version": "1.11.0",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -83,6 +83,7 @@
83
83
  "devDependencies": {
84
84
  "@testing-library/jest-dom": "^6.6.3",
85
85
  "@types/jest": "^29.5.14",
86
+ "@types/luxon": "^3.7.1",
86
87
  "@types/mailgun-js": "^0.22.18",
87
88
  "@types/node": "^20.17.13",
88
89
  "@types/react": "^19.1.0",
@@ -98,6 +99,7 @@
98
99
  "expo-server-sdk": "^3.13.0",
99
100
  "firebase-admin": "^13.0.2",
100
101
  "geofire-common": "^6.0.0",
102
+ "luxon": "^3.7.1",
101
103
  "zod": "^3.24.1"
102
104
  },
103
105
  "optionalDependencies": {
@@ -233,6 +233,7 @@ export class BookingAdmin {
233
233
  practitionerCalendarEvents: this.convertEventsTimestamps(
234
234
  practitionerCalendarEvents
235
235
  ),
236
+ tz: clinic.location.tz || "UTC",
236
237
  };
237
238
 
238
239
  Logger.info("[BookingAdmin] Calling availability calculator", {
@@ -823,6 +824,7 @@ export class BookingAdmin {
823
824
  calendarEventId: practitionerCalendarEventId,
824
825
  clinicBranchId: procedure.clinicBranchId,
825
826
  clinicInfo,
827
+ clinic_tz: clinicData.location.tz || "UTC",
826
828
  practitionerId: procedure.practitionerId,
827
829
  practitionerInfo,
828
830
  patientId: data.patientId,
@@ -1,4 +1,5 @@
1
1
  import { Timestamp } from "firebase/firestore";
2
+ import { DateTime } from "luxon";
2
3
  import {
3
4
  BookingAvailabilityRequest,
4
5
  BookingAvailabilityResponse,
@@ -39,6 +40,7 @@ export class BookingAvailabilityCalculator {
39
40
  timeframe,
40
41
  clinicCalendarEvents,
41
42
  practitionerCalendarEvents,
43
+ tz,
42
44
  } = request;
43
45
 
44
46
  // Get scheduling interval (default to 15 minutes if not specified)
@@ -61,7 +63,8 @@ export class BookingAvailabilityCalculator {
61
63
  availableIntervals = this.applyClinicWorkingHours(
62
64
  availableIntervals,
63
65
  clinic.workingHours,
64
- timeframe
66
+ timeframe,
67
+ tz
65
68
  );
66
69
 
67
70
  // Step 2: Subtract clinic blocking events
@@ -75,7 +78,8 @@ export class BookingAvailabilityCalculator {
75
78
  availableIntervals,
76
79
  practitioner,
77
80
  clinic.id,
78
- timeframe
81
+ timeframe,
82
+ tz
79
83
  );
80
84
 
81
85
  // Step 4: Subtract practitioner's busy times
@@ -104,12 +108,14 @@ export class BookingAvailabilityCalculator {
104
108
  * @param intervals - Current available intervals
105
109
  * @param workingHours - Clinic working hours
106
110
  * @param timeframe - Overall timeframe being considered
111
+ * @param tz - IANA timezone of the clinic
107
112
  * @returns Intervals filtered by clinic working hours
108
113
  */
109
114
  private static applyClinicWorkingHours(
110
115
  intervals: TimeInterval[],
111
116
  workingHours: any, // Using 'any' for now since we're working with the existing type structure
112
- timeframe: { start: Timestamp; end: Timestamp }
117
+ timeframe: { start: Timestamp; end: Timestamp },
118
+ tz: string
113
119
  ): TimeInterval[] {
114
120
  if (!intervals.length) return [];
115
121
  console.log(
@@ -120,7 +126,8 @@ export class BookingAvailabilityCalculator {
120
126
  const workingIntervals = this.createWorkingHoursIntervals(
121
127
  workingHours,
122
128
  timeframe.start.toDate(),
123
- timeframe.end.toDate()
129
+ timeframe.end.toDate(),
130
+ tz
124
131
  );
125
132
 
126
133
  // Intersect the available intervals with working hours intervals
@@ -133,44 +140,34 @@ export class BookingAvailabilityCalculator {
133
140
  * @param workingHours - Working hours definition
134
141
  * @param startDate - Start date of the overall timeframe
135
142
  * @param endDate - End date of the overall timeframe
143
+ * @param tz - IANA timezone of the clinic
136
144
  * @returns Array of time intervals representing working hours
137
145
  */
138
146
  private static createWorkingHoursIntervals(
139
147
  workingHours: any,
140
148
  startDate: Date,
141
- endDate: Date
149
+ endDate: Date,
150
+ tz: string
142
151
  ): TimeInterval[] {
143
152
  const workingIntervals: TimeInterval[] = [];
144
-
145
- // Clone the start date to avoid modifying the original
146
- const currentDate = new Date(startDate);
147
-
148
- // Reset time to start of day
149
- currentDate.setHours(0, 0, 0, 0);
150
-
151
- // Create a map of day names to day numbers (0 = Sunday, 1 = Monday, etc.)
152
- const dayNameToNumber: { [key: string]: number } = {
153
- sunday: 0,
154
- monday: 1,
155
- tuesday: 2,
156
- wednesday: 3,
157
- thursday: 4,
158
- friday: 5,
159
- saturday: 6,
160
- };
161
-
162
- // Iterate through each day in the timeframe
163
- while (currentDate <= endDate) {
164
- const dayOfWeek = currentDate.getDay();
165
- const dayName = Object.keys(dayNameToNumber).find(
166
- (key) => dayNameToNumber[key] === dayOfWeek
167
- );
153
+ let start = DateTime.fromJSDate(startDate, { zone: tz });
154
+ const end = DateTime.fromJSDate(endDate, { zone: tz });
155
+
156
+ while (start <= end) {
157
+ const dayOfWeek = start.weekday; // 1 for Monday, 7 for Sunday
158
+ const dayName = [
159
+ "monday",
160
+ "tuesday",
161
+ "wednesday",
162
+ "thursday",
163
+ "friday",
164
+ "saturday",
165
+ "sunday",
166
+ ][dayOfWeek - 1];
168
167
 
169
168
  if (dayName && workingHours[dayName]) {
170
169
  const daySchedule = workingHours[dayName];
171
-
172
170
  if (daySchedule) {
173
- // Parse open and close times
174
171
  const [openHours, openMinutes] = daySchedule.open
175
172
  .split(":")
176
173
  .map(Number);
@@ -178,25 +175,37 @@ export class BookingAvailabilityCalculator {
178
175
  .split(":")
179
176
  .map(Number);
180
177
 
181
- // Create start and end times for this working day
182
- const workStart = new Date(currentDate);
183
- workStart.setHours(openHours, openMinutes, 0, 0);
184
-
185
- const workEnd = new Date(currentDate);
186
- workEnd.setHours(closeHours, closeMinutes, 0, 0);
178
+ let workStart = start.set({
179
+ hour: openHours,
180
+ minute: openMinutes,
181
+ second: 0,
182
+ millisecond: 0,
183
+ });
184
+ let workEnd = start.set({
185
+ hour: closeHours,
186
+ minute: closeMinutes,
187
+ second: 0,
188
+ millisecond: 0,
189
+ });
187
190
 
188
- // Only add if the working hours are within our timeframe
189
- if (workEnd > startDate && workStart < endDate) {
190
- // Adjust interval to be within our overall timeframe
191
- const intervalStart = workStart < startDate ? startDate : workStart;
192
- const intervalEnd = workEnd > endDate ? endDate : workEnd;
191
+ if (
192
+ workEnd.toMillis() > startDate.getTime() &&
193
+ workStart.toMillis() < endDate.getTime()
194
+ ) {
195
+ const intervalStart =
196
+ workStart < DateTime.fromJSDate(startDate, { zone: tz })
197
+ ? DateTime.fromJSDate(startDate, { zone: tz })
198
+ : workStart;
199
+ const intervalEnd =
200
+ workEnd > DateTime.fromJSDate(endDate, { zone: tz })
201
+ ? DateTime.fromJSDate(endDate, { zone: tz })
202
+ : workEnd;
193
203
 
194
204
  workingIntervals.push({
195
- start: Timestamp.fromDate(intervalStart),
196
- end: Timestamp.fromDate(intervalEnd),
205
+ start: Timestamp.fromMillis(intervalStart.toMillis()),
206
+ end: Timestamp.fromMillis(intervalEnd.toMillis()),
197
207
  });
198
208
 
199
- // Handle breaks if they exist
200
209
  if (daySchedule.breaks && daySchedule.breaks.length > 0) {
201
210
  for (const breakTime of daySchedule.breaks) {
202
211
  const [breakStartHours, breakStartMinutes] = breakTime.start
@@ -206,21 +215,23 @@ export class BookingAvailabilityCalculator {
206
215
  .split(":")
207
216
  .map(Number);
208
217
 
209
- const breakStart = new Date(currentDate);
210
- breakStart.setHours(breakStartHours, breakStartMinutes, 0, 0);
211
-
212
- const breakEnd = new Date(currentDate);
213
- breakEnd.setHours(breakEndHours, breakEndMinutes, 0, 0);
218
+ const breakStart = start.set({
219
+ hour: breakStartHours,
220
+ minute: breakStartMinutes,
221
+ });
222
+ const breakEnd = start.set({
223
+ hour: breakEndHours,
224
+ minute: breakEndMinutes,
225
+ });
214
226
 
215
- // Subtract this break from our working intervals
216
227
  workingIntervals.splice(
217
228
  -1,
218
229
  1,
219
230
  ...this.subtractInterval(
220
231
  workingIntervals[workingIntervals.length - 1],
221
232
  {
222
- start: Timestamp.fromDate(breakStart),
223
- end: Timestamp.fromDate(breakEnd),
233
+ start: Timestamp.fromMillis(breakStart.toMillis()),
234
+ end: Timestamp.fromMillis(breakEnd.toMillis()),
224
235
  }
225
236
  )
226
237
  );
@@ -229,11 +240,8 @@ export class BookingAvailabilityCalculator {
229
240
  }
230
241
  }
231
242
  }
232
-
233
- // Move to the next day
234
- currentDate.setDate(currentDate.getDate() + 1);
243
+ start = start.plus({ days: 1 });
235
244
  }
236
-
237
245
  return workingIntervals;
238
246
  }
239
247
 
@@ -290,13 +298,15 @@ export class BookingAvailabilityCalculator {
290
298
  * @param practitioner - Practitioner object
291
299
  * @param clinicId - ID of the clinic
292
300
  * @param timeframe - Overall timeframe being considered
301
+ * @param tz - IANA timezone of the clinic
293
302
  * @returns Intervals filtered by practitioner's working hours
294
303
  */
295
304
  private static applyPractitionerWorkingHours(
296
305
  intervals: TimeInterval[],
297
306
  practitioner: any,
298
307
  clinicId: string,
299
- timeframe: { start: Timestamp; end: Timestamp }
308
+ timeframe: { start: Timestamp; end: Timestamp },
309
+ tz: string
300
310
  ): TimeInterval[] {
301
311
  if (!intervals.length) return [];
302
312
  console.log(`Applying practitioner working hours for clinic ${clinicId}`);
@@ -319,7 +329,8 @@ export class BookingAvailabilityCalculator {
319
329
  const workingIntervals = this.createPractitionerWorkingHoursIntervals(
320
330
  clinicWorkingHours.workingHours,
321
331
  timeframe.start.toDate(),
322
- timeframe.end.toDate()
332
+ timeframe.end.toDate(),
333
+ tz
323
334
  );
324
335
 
325
336
  // Intersect the available intervals with practitioner's working hours intervals
@@ -332,74 +343,67 @@ export class BookingAvailabilityCalculator {
332
343
  * @param workingHours - Practitioner's working hours definition
333
344
  * @param startDate - Start date of the overall timeframe
334
345
  * @param endDate - End date of the overall timeframe
346
+ * @param tz - IANA timezone of the clinic
335
347
  * @returns Array of time intervals representing practitioner's working hours
336
348
  */
337
349
  private static createPractitionerWorkingHoursIntervals(
338
350
  workingHours: any,
339
351
  startDate: Date,
340
- endDate: Date
352
+ endDate: Date,
353
+ tz: string
341
354
  ): TimeInterval[] {
342
355
  const workingIntervals: TimeInterval[] = [];
343
-
344
- // Clone the start date to avoid modifying the original
345
- const currentDate = new Date(startDate);
346
-
347
- // Reset time to start of day
348
- currentDate.setHours(0, 0, 0, 0);
349
-
350
- // Create a map of day names to day numbers (0 = Sunday, 1 = Monday, etc.)
351
- const dayNameToNumber: { [key: string]: number } = {
352
- sunday: 0,
353
- monday: 1,
354
- tuesday: 2,
355
- wednesday: 3,
356
- thursday: 4,
357
- friday: 5,
358
- saturday: 6,
359
- };
360
-
361
- // Iterate through each day in the timeframe
362
- while (currentDate <= endDate) {
363
- const dayOfWeek = currentDate.getDay();
364
- const dayName = Object.keys(dayNameToNumber).find(
365
- (key) => dayNameToNumber[key] === dayOfWeek
366
- );
356
+ let start = DateTime.fromJSDate(startDate, { zone: tz });
357
+ const end = DateTime.fromJSDate(endDate, { zone: tz });
358
+
359
+ while (start <= end) {
360
+ const dayOfWeek = start.weekday;
361
+ const dayName = [
362
+ "monday",
363
+ "tuesday",
364
+ "wednesday",
365
+ "thursday",
366
+ "friday",
367
+ "saturday",
368
+ "sunday",
369
+ ][dayOfWeek - 1];
367
370
 
368
371
  if (dayName && workingHours[dayName]) {
369
372
  const daySchedule = workingHours[dayName];
370
-
371
373
  if (daySchedule) {
372
- // Parse start and end times
373
374
  const [startHours, startMinutes] = daySchedule.start
374
375
  .split(":")
375
376
  .map(Number);
376
377
  const [endHours, endMinutes] = daySchedule.end.split(":").map(Number);
377
378
 
378
- // Create start and end times for this working day
379
- const workStart = new Date(currentDate);
380
- workStart.setHours(startHours, startMinutes, 0, 0);
381
-
382
- const workEnd = new Date(currentDate);
383
- workEnd.setHours(endHours, endMinutes, 0, 0);
384
-
385
- // Only add if the working hours are within our timeframe
386
- if (workEnd > startDate && workStart < endDate) {
387
- // Adjust interval to be within our overall timeframe
388
- const intervalStart = workStart < startDate ? startDate : workStart;
389
- const intervalEnd = workEnd > endDate ? endDate : workEnd;
379
+ const workStart = start.set({
380
+ hour: startHours,
381
+ minute: startMinutes,
382
+ });
383
+ const workEnd = start.set({ hour: endHours, minute: endMinutes });
384
+
385
+ if (
386
+ workEnd.toMillis() > startDate.getTime() &&
387
+ workStart.toMillis() < endDate.getTime()
388
+ ) {
389
+ const intervalStart =
390
+ workStart < DateTime.fromJSDate(startDate, { zone: tz })
391
+ ? DateTime.fromJSDate(startDate, { zone: tz })
392
+ : workStart;
393
+ const intervalEnd =
394
+ workEnd > DateTime.fromJSDate(endDate, { zone: tz })
395
+ ? DateTime.fromJSDate(endDate, { zone: tz })
396
+ : workEnd;
390
397
 
391
398
  workingIntervals.push({
392
- start: Timestamp.fromDate(intervalStart),
393
- end: Timestamp.fromDate(intervalEnd),
399
+ start: Timestamp.fromMillis(intervalStart.toMillis()),
400
+ end: Timestamp.fromMillis(intervalEnd.toMillis()),
394
401
  });
395
402
  }
396
403
  }
397
404
  }
398
-
399
- // Move to the next day
400
- currentDate.setDate(currentDate.getDate() + 1);
405
+ start = start.plus({ days: 1 });
401
406
  }
402
-
403
407
  return workingIntervals;
404
408
  }
405
409
 
@@ -485,32 +489,32 @@ export class BookingAvailabilityCalculator {
485
489
  const intervalEnd = interval.end.toDate();
486
490
 
487
491
  // Start at the beginning of the interval
488
- let slotStart = new Date(intervalStart);
492
+ let slotStart = DateTime.fromJSDate(intervalStart);
489
493
 
490
494
  // Adjust slotStart to the nearest interval boundary if needed
491
- const minutesIntoDay = slotStart.getHours() * 60 + slotStart.getMinutes();
495
+ const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
492
496
  const minutesRemainder = minutesIntoDay % intervalMinutes;
493
497
 
494
498
  if (minutesRemainder > 0) {
495
- slotStart.setMinutes(
496
- slotStart.getMinutes() + (intervalMinutes - minutesRemainder)
497
- );
499
+ slotStart = slotStart.plus({
500
+ minutes: intervalMinutes - minutesRemainder,
501
+ });
498
502
  }
499
503
 
500
504
  // Iterate through potential start times
501
- while (slotStart.getTime() + durationMs <= intervalEnd.getTime()) {
505
+ while (slotStart.toMillis() + durationMs <= intervalEnd.getTime()) {
502
506
  // Calculate potential end time
503
- const slotEnd = new Date(slotStart.getTime() + durationMs);
507
+ const slotEnd = slotStart.plus({ minutes: durationMinutes });
504
508
 
505
509
  // Check if this slot fits entirely within one of our available intervals
506
510
  if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals)) {
507
511
  slots.push({
508
- start: Timestamp.fromDate(slotStart),
512
+ start: Timestamp.fromMillis(slotStart.toMillis()),
509
513
  });
510
514
  }
511
515
 
512
516
  // Move to the next potential start time
513
- slotStart = new Date(slotStart.getTime() + intervalMs);
517
+ slotStart = slotStart.plus({ minutes: intervalMinutes });
514
518
  }
515
519
  }
516
520
 
@@ -527,14 +531,14 @@ export class BookingAvailabilityCalculator {
527
531
  * @returns True if the slot is fully contained within an available interval
528
532
  */
529
533
  private static isSlotFullyAvailable(
530
- slotStart: Date,
531
- slotEnd: Date,
534
+ slotStart: DateTime,
535
+ slotEnd: DateTime,
532
536
  intervals: TimeInterval[]
533
537
  ): boolean {
534
538
  // Check if the slot is fully contained in any of the available intervals
535
539
  return intervals.some((interval) => {
536
- const intervalStart = interval.start.toDate();
537
- const intervalEnd = interval.end.toDate();
540
+ const intervalStart = DateTime.fromMillis(interval.start.toMillis());
541
+ const intervalEnd = DateTime.fromMillis(interval.end.toMillis());
538
542
 
539
543
  return slotStart >= intervalStart && slotEnd <= intervalEnd;
540
544
  });
@@ -28,6 +28,9 @@ export interface BookingAvailabilityRequest {
28
28
 
29
29
  /** Calendar events for the practitioner during the specified timeframe */
30
30
  practitionerCalendarEvents: CalendarEvent[];
31
+
32
+ /** IANA timezone of the clinic */
33
+ tz: string;
31
34
  }
32
35
 
33
36
  /**
@@ -17,6 +17,7 @@ import {
17
17
  arrayUnion,
18
18
  arrayRemove,
19
19
  } from "firebase/firestore";
20
+ import { getFunctions, httpsCallable } from "firebase/functions";
20
21
  import { BaseService } from "../base.service";
21
22
  import {
22
23
  Clinic,
@@ -63,6 +64,7 @@ export class ClinicService extends BaseService {
63
64
  private clinicGroupService: ClinicGroupService;
64
65
  private clinicAdminService: ClinicAdminService;
65
66
  private mediaService: MediaService;
67
+ private functions: any;
66
68
 
67
69
  constructor(
68
70
  db: Firestore,
@@ -76,6 +78,28 @@ export class ClinicService extends BaseService {
76
78
  this.clinicAdminService = clinicAdminService;
77
79
  this.clinicGroupService = clinicGroupService;
78
80
  this.mediaService = mediaService;
81
+ this.functions = getFunctions(app);
82
+ }
83
+
84
+ /**
85
+ * Get timezone from coordinates using the callable function
86
+ * @param lat Latitude
87
+ * @param lng Longitude
88
+ * @returns IANA timezone string
89
+ */
90
+ private async getTimezone(lat: number, lng: number): Promise<string | null> {
91
+ try {
92
+ const getTimezoneFromCoordinates = httpsCallable(
93
+ this.functions,
94
+ "getTimezoneFromCoordinates"
95
+ );
96
+ const result = await getTimezoneFromCoordinates({ lat, lng });
97
+ const data = result.data as { timezone: string };
98
+ return data.timezone;
99
+ } catch (error) {
100
+ console.error("Error getting timezone:", error);
101
+ return null;
102
+ }
79
103
  }
80
104
 
81
105
  /**
@@ -225,6 +249,7 @@ export class ClinicService extends BaseService {
225
249
 
226
250
  const location = validatedData.location;
227
251
  const hash = geohashForLocation([location.latitude, location.longitude]);
252
+ const tz = await this.getTimezone(location.latitude, location.longitude);
228
253
 
229
254
  const defaultReviewInfo: ClinicReviewInfo = {
230
255
  totalReviews: 0,
@@ -246,7 +271,7 @@ export class ClinicService extends BaseService {
246
271
  name: validatedData.name,
247
272
  nameLower: validatedData.name.toLowerCase(), // Add this line
248
273
  description: validatedData.description,
249
- location: { ...location, geohash: hash },
274
+ location: { ...location, geohash: hash, tz },
250
275
  contactInfo: validatedData.contactInfo,
251
276
  workingHours: validatedData.workingHours,
252
277
  tags: validatedData.tags,
@@ -393,9 +418,11 @@ export class ClinicService extends BaseService {
393
418
  // Handle location update with geohash
394
419
  if (validatedData.location) {
395
420
  const loc = validatedData.location;
421
+ const tz = await this.getTimezone(loc.latitude, loc.longitude);
396
422
  updatePayload.location = {
397
423
  ...loc,
398
424
  geohash: geohashForLocation([loc.latitude, loc.longitude]),
425
+ tz,
399
426
  };
400
427
  }
401
428
 
@@ -643,6 +670,7 @@ export class ClinicService extends BaseService {
643
670
  procedureTechnology?: string;
644
671
  minRating?: number;
645
672
  maxRating?: number;
673
+ nameSearch?: string;
646
674
  pagination?: number;
647
675
  lastDoc?: any;
648
676
  isActive?: boolean;
@@ -658,21 +686,23 @@ export class ClinicService extends BaseService {
658
686
  * This is optimized for mobile map usage to reduce payload size.
659
687
  * @returns Array of minimal clinic info for map
660
688
  */
661
- async getClinicsForMap(): Promise<{
662
- id: string;
663
- name: string;
664
- address: string;
665
- latitude: number | undefined;
666
- longitude: number | undefined;
667
- }[]> {
689
+ async getClinicsForMap(): Promise<
690
+ {
691
+ id: string;
692
+ name: string;
693
+ address: string;
694
+ latitude: number | undefined;
695
+ longitude: number | undefined;
696
+ }[]
697
+ > {
668
698
  const clinicsRef = collection(this.db, CLINICS_COLLECTION);
669
699
  const snapshot = await getDocs(clinicsRef);
670
- const clinicsForMap = snapshot.docs.map(doc => {
700
+ const clinicsForMap = snapshot.docs.map((doc) => {
671
701
  const data = doc.data();
672
702
  return {
673
703
  id: doc.id,
674
704
  name: data.name,
675
- address: data.location?.address || '',
705
+ address: data.location?.address || "",
676
706
  latitude: data.location?.latitude,
677
707
  longitude: data.location?.longitude,
678
708
  };