@blackcode_sa/metaestetics-api 1.12.57 → 1.12.59

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.
@@ -3558,6 +3558,20 @@ interface ReviewAddedEmailData extends AppointmentEmailDataBase {
3558
3558
  declare class AppointmentMailingService extends BaseMailingService {
3559
3559
  private readonly DEFAULT_MAILGUN_DOMAIN;
3560
3560
  constructor(firestore: admin.firestore.Firestore, mailgunClient: NewMailgunClient$2);
3561
+ /**
3562
+ * Formats a Firestore Timestamp in the clinic's timezone
3563
+ * @param timestamp - Firestore Timestamp (UTC)
3564
+ * @param clinicTimezone - IANA timezone string (e.g., "Europe/Zurich")
3565
+ * @param format - Format type: 'date', 'time', or 'datetime'
3566
+ * @returns Formatted string in clinic's local timezone
3567
+ */
3568
+ private formatTimestampInClinicTimezone;
3569
+ /**
3570
+ * Gets a user-friendly display name for a timezone
3571
+ * @param timezone - IANA timezone string (e.g., "Europe/Zurich")
3572
+ * @returns User-friendly timezone name (e.g., "Clinic Time - Europe/Zurich")
3573
+ */
3574
+ private getTimezoneDisplayName;
3561
3575
  sendAppointmentConfirmedEmail(data: AppointmentConfirmationEmailData): Promise<any>;
3562
3576
  sendAppointmentRequestedEmailToClinic(data: AppointmentRequestedEmailData): Promise<any>;
3563
3577
  sendAppointmentCancelledEmail(data: AppointmentCancellationEmailData): Promise<any>;
@@ -3558,6 +3558,20 @@ interface ReviewAddedEmailData extends AppointmentEmailDataBase {
3558
3558
  declare class AppointmentMailingService extends BaseMailingService {
3559
3559
  private readonly DEFAULT_MAILGUN_DOMAIN;
3560
3560
  constructor(firestore: admin.firestore.Firestore, mailgunClient: NewMailgunClient$2);
3561
+ /**
3562
+ * Formats a Firestore Timestamp in the clinic's timezone
3563
+ * @param timestamp - Firestore Timestamp (UTC)
3564
+ * @param clinicTimezone - IANA timezone string (e.g., "Europe/Zurich")
3565
+ * @param format - Format type: 'date', 'time', or 'datetime'
3566
+ * @returns Formatted string in clinic's local timezone
3567
+ */
3568
+ private formatTimestampInClinicTimezone;
3569
+ /**
3570
+ * Gets a user-friendly display name for a timezone
3571
+ * @param timezone - IANA timezone string (e.g., "Europe/Zurich")
3572
+ * @returns User-friendly timezone name (e.g., "Clinic Time - Europe/Zurich")
3573
+ */
3574
+ private getTimezoneDisplayName;
3561
3575
  sendAppointmentConfirmedEmail(data: AppointmentConfirmationEmailData): Promise<any>;
3562
3576
  sendAppointmentRequestedEmailToClinic(data: AppointmentRequestedEmailData): Promise<any>;
3563
3577
  sendAppointmentCancelledEmail(data: AppointmentCancellationEmailData): Promise<any>;
@@ -1765,6 +1765,9 @@ var CalendarAdminService = class {
1765
1765
  }
1766
1766
  };
1767
1767
 
1768
+ // src/admin/mailing/appointment/appointment.mailing.service.ts
1769
+ var import_luxon = require("luxon");
1770
+
1768
1771
  // src/admin/mailing/base.mailing.service.ts
1769
1772
  var admin5 = __toESM(require("firebase-admin"));
1770
1773
  var BaseMailingService = class {
@@ -2323,6 +2326,62 @@ var AppointmentMailingService = class extends BaseMailingService {
2323
2326
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
2324
2327
  Logger.info("[AppointmentMailingService] Initialized.");
2325
2328
  }
2329
+ /**
2330
+ * Formats a Firestore Timestamp in the clinic's timezone
2331
+ * @param timestamp - Firestore Timestamp (UTC)
2332
+ * @param clinicTimezone - IANA timezone string (e.g., "Europe/Zurich")
2333
+ * @param format - Format type: 'date', 'time', or 'datetime'
2334
+ * @returns Formatted string in clinic's local timezone
2335
+ */
2336
+ formatTimestampInClinicTimezone(timestamp, clinicTimezone, format = "datetime") {
2337
+ try {
2338
+ const dateTimeInClinicTz = import_luxon.DateTime.fromMillis(timestamp.toMillis(), {
2339
+ zone: clinicTimezone
2340
+ });
2341
+ switch (format) {
2342
+ case "date":
2343
+ return dateTimeInClinicTz.toLocaleString(import_luxon.DateTime.DATE_FULL);
2344
+ case "time":
2345
+ return dateTimeInClinicTz.toLocaleString(import_luxon.DateTime.TIME_SIMPLE);
2346
+ case "datetime":
2347
+ return dateTimeInClinicTz.toLocaleString(import_luxon.DateTime.DATETIME_FULL);
2348
+ default:
2349
+ return dateTimeInClinicTz.toLocaleString(import_luxon.DateTime.DATETIME_FULL);
2350
+ }
2351
+ } catch (error) {
2352
+ Logger.error("[AppointmentMailingService] Error formatting timestamp in clinic timezone:", {
2353
+ error: error instanceof Error ? error.message : String(error),
2354
+ clinicTimezone
2355
+ });
2356
+ return timestamp.toDate().toLocaleString();
2357
+ }
2358
+ }
2359
+ /**
2360
+ * Gets a user-friendly display name for a timezone
2361
+ * @param timezone - IANA timezone string (e.g., "Europe/Zurich")
2362
+ * @returns User-friendly timezone name (e.g., "Clinic Time - Europe/Zurich")
2363
+ */
2364
+ getTimezoneDisplayName(timezone) {
2365
+ try {
2366
+ const timezoneMap = {
2367
+ "Europe/Zurich": "Clinic Time - Central European Time",
2368
+ "Europe/London": "Clinic Time - GMT/BST",
2369
+ "America/New_York": "Clinic Time - Eastern Time",
2370
+ "America/Los_Angeles": "Clinic Time - Pacific Time",
2371
+ "Asia/Dubai": "Clinic Time - Gulf Standard Time",
2372
+ "Asia/Karachi": "Clinic Time - Pakistan Standard Time",
2373
+ "Asia/Kolkata": "Clinic Time - India Standard Time",
2374
+ "Australia/Sydney": "Clinic Time - Australian Eastern Time"
2375
+ };
2376
+ return timezoneMap[timezone] || `Clinic Time - ${timezone}`;
2377
+ } catch (error) {
2378
+ Logger.error("[AppointmentMailingService] Error getting timezone display name:", {
2379
+ error: error instanceof Error ? error.message : String(error),
2380
+ timezone
2381
+ });
2382
+ return `Clinic Time - ${timezone}`;
2383
+ }
2384
+ }
2326
2385
  async sendAppointmentConfirmedEmail(data) {
2327
2386
  var _a, _b, _c, _d, _e;
2328
2387
  Logger.info(
@@ -2336,11 +2395,26 @@ var AppointmentMailingService = class extends BaseMailingService {
2336
2395
  });
2337
2396
  throw new Error("Recipient email address is missing.");
2338
2397
  }
2398
+ const clinicTimezone = data.appointment.clinic_tz || "UTC";
2399
+ Logger.debug("[AppointmentMailingService] Formatting appointment time", {
2400
+ clinicTimezone,
2401
+ utcTime: data.appointment.appointmentStartTime.toDate().toISOString()
2402
+ });
2403
+ const formattedTime = this.formatTimestampInClinicTimezone(
2404
+ data.appointment.appointmentStartTime,
2405
+ clinicTimezone,
2406
+ "time"
2407
+ );
2408
+ const timezoneName = this.getTimezoneDisplayName(clinicTimezone);
2339
2409
  const templateVariables = {
2340
2410
  patientName: data.appointment.patientInfo.fullName,
2341
2411
  procedureName: data.appointment.procedureInfo.name,
2342
- appointmentDate: data.appointment.appointmentStartTime.toDate().toLocaleDateString(),
2343
- appointmentTime: data.appointment.appointmentStartTime.toDate().toLocaleTimeString(),
2412
+ appointmentDate: this.formatTimestampInClinicTimezone(
2413
+ data.appointment.appointmentStartTime,
2414
+ clinicTimezone,
2415
+ "date"
2416
+ ),
2417
+ appointmentTime: `${formattedTime} (${timezoneName})`,
2344
2418
  practitionerName: data.appointment.practitionerInfo.name,
2345
2419
  clinicName: data.appointment.clinicInfo.name
2346
2420
  };
@@ -2387,12 +2461,27 @@ var AppointmentMailingService = class extends BaseMailingService {
2387
2461
  );
2388
2462
  throw new Error("Clinic contact email address is missing.");
2389
2463
  }
2464
+ const clinicTimezone = data.appointment.clinic_tz || "UTC";
2465
+ Logger.debug("[AppointmentMailingService] Formatting appointment time for clinic", {
2466
+ clinicTimezone,
2467
+ utcTime: data.appointment.appointmentStartTime.toDate().toISOString()
2468
+ });
2469
+ const formattedTime = this.formatTimestampInClinicTimezone(
2470
+ data.appointment.appointmentStartTime,
2471
+ clinicTimezone,
2472
+ "time"
2473
+ );
2474
+ const timezoneName = this.getTimezoneDisplayName(clinicTimezone);
2390
2475
  const templateVariables = {
2391
2476
  clinicName: data.clinicProfile.name,
2392
2477
  patientName: data.appointment.patientInfo.fullName,
2393
2478
  procedureName: data.appointment.procedureInfo.name,
2394
- appointmentDate: data.appointment.appointmentStartTime.toDate().toLocaleDateString(),
2395
- appointmentTime: data.appointment.appointmentStartTime.toDate().toLocaleTimeString(),
2479
+ appointmentDate: this.formatTimestampInClinicTimezone(
2480
+ data.appointment.appointmentStartTime,
2481
+ clinicTimezone,
2482
+ "date"
2483
+ ),
2484
+ appointmentTime: `${formattedTime} (${timezoneName})`,
2396
2485
  practitionerName: data.appointment.practitionerInfo.name
2397
2486
  };
2398
2487
  const html = this.renderTemplate(clinicAppointmentRequestedTemplate, templateVariables);
@@ -6576,7 +6665,7 @@ var ReviewsAggregationService = class {
6576
6665
 
6577
6666
  // src/admin/booking/booking.calculator.ts
6578
6667
  var import_firestore2 = require("firebase/firestore");
6579
- var import_luxon = require("luxon");
6668
+ var import_luxon2 = require("luxon");
6580
6669
  var BookingAvailabilityCalculator = class {
6581
6670
  /**
6582
6671
  * Calculate available booking slots based on the provided data
@@ -6667,8 +6756,8 @@ var BookingAvailabilityCalculator = class {
6667
6756
  */
6668
6757
  static createWorkingHoursIntervals(workingHours, startDate, endDate, tz) {
6669
6758
  const workingIntervals = [];
6670
- let start = import_luxon.DateTime.fromMillis(startDate.getTime(), { zone: tz });
6671
- const end = import_luxon.DateTime.fromMillis(endDate.getTime(), { zone: tz });
6759
+ let start = import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz });
6760
+ const end = import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz });
6672
6761
  while (start <= end) {
6673
6762
  const dayOfWeek = start.weekday;
6674
6763
  const dayName = [
@@ -6698,8 +6787,8 @@ var BookingAvailabilityCalculator = class {
6698
6787
  millisecond: 0
6699
6788
  });
6700
6789
  if (workEnd.toMillis() > startDate.getTime() && workStart.toMillis() < endDate.getTime()) {
6701
- const intervalStart = workStart < import_luxon.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6702
- const intervalEnd = workEnd > import_luxon.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6790
+ const intervalStart = workStart < import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6791
+ const intervalEnd = workEnd > import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6703
6792
  workingIntervals.push({
6704
6793
  start: import_firestore2.Timestamp.fromMillis(intervalStart.toMillis()),
6705
6794
  end: import_firestore2.Timestamp.fromMillis(intervalEnd.toMillis())
@@ -6806,8 +6895,8 @@ var BookingAvailabilityCalculator = class {
6806
6895
  */
6807
6896
  static createPractitionerWorkingHoursIntervals(workingHours, startDate, endDate, tz) {
6808
6897
  const workingIntervals = [];
6809
- let start = import_luxon.DateTime.fromMillis(startDate.getTime(), { zone: tz });
6810
- const end = import_luxon.DateTime.fromMillis(endDate.getTime(), { zone: tz });
6898
+ let start = import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz });
6899
+ const end = import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz });
6811
6900
  while (start <= end) {
6812
6901
  const dayOfWeek = start.weekday;
6813
6902
  const dayName = [
@@ -6830,8 +6919,8 @@ var BookingAvailabilityCalculator = class {
6830
6919
  });
6831
6920
  const workEnd = start.set({ hour: endHours, minute: endMinutes });
6832
6921
  if (workEnd.toMillis() > startDate.getTime() && workStart.toMillis() < endDate.getTime()) {
6833
- const intervalStart = workStart < import_luxon.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6834
- const intervalEnd = workEnd > import_luxon.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6922
+ const intervalStart = workStart < import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6923
+ const intervalEnd = workEnd > import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? import_luxon2.DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6835
6924
  workingIntervals.push({
6836
6925
  start: import_firestore2.Timestamp.fromMillis(intervalStart.toMillis()),
6837
6926
  end: import_firestore2.Timestamp.fromMillis(intervalEnd.toMillis())
@@ -6890,12 +6979,18 @@ var BookingAvailabilityCalculator = class {
6890
6979
  console.log(
6891
6980
  `Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
6892
6981
  );
6982
+ const nowInClinicTz = import_luxon2.DateTime.now().setZone(tz);
6983
+ const MINIMUM_BOOKING_WINDOW_MINUTES = 15;
6984
+ const earliestBookableTime = nowInClinicTz.plus({ minutes: MINIMUM_BOOKING_WINDOW_MINUTES });
6985
+ console.log(
6986
+ `Current time in ${tz}: ${nowInClinicTz.toISO()}, earliest bookable: ${earliestBookableTime.toISO()}`
6987
+ );
6893
6988
  const durationMs = durationMinutes * 60 * 1e3;
6894
6989
  const intervalMs = intervalMinutes * 60 * 1e3;
6895
6990
  for (const interval of intervals) {
6896
6991
  const intervalStart = interval.start.toDate();
6897
6992
  const intervalEnd = interval.end.toDate();
6898
- let slotStart = import_luxon.DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
6993
+ let slotStart = import_luxon2.DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
6899
6994
  const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
6900
6995
  const minutesRemainder = minutesIntoDay % intervalMinutes;
6901
6996
  if (minutesRemainder > 0) {
@@ -6905,7 +7000,8 @@ var BookingAvailabilityCalculator = class {
6905
7000
  }
6906
7001
  while (slotStart.toMillis() + durationMs <= intervalEnd.getTime()) {
6907
7002
  const slotEnd = slotStart.plus({ minutes: durationMinutes });
6908
- if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
7003
+ const isInFuture = slotStart >= earliestBookableTime;
7004
+ if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
6909
7005
  slots.push({
6910
7006
  start: import_firestore2.Timestamp.fromMillis(slotStart.toMillis())
6911
7007
  });
@@ -6913,7 +7009,7 @@ var BookingAvailabilityCalculator = class {
6913
7009
  slotStart = slotStart.plus({ minutes: intervalMinutes });
6914
7010
  }
6915
7011
  }
6916
- console.log(`Generated ${slots.length} available slots`);
7012
+ console.log(`Generated ${slots.length} available slots (filtered for future times with ${MINIMUM_BOOKING_WINDOW_MINUTES}min minimum window)`);
6917
7013
  return slots;
6918
7014
  }
6919
7015
  /**
@@ -6927,8 +7023,8 @@ var BookingAvailabilityCalculator = class {
6927
7023
  */
6928
7024
  static isSlotFullyAvailable(slotStart, slotEnd, intervals, tz) {
6929
7025
  return intervals.some((interval) => {
6930
- const intervalStart = import_luxon.DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
6931
- const intervalEnd = import_luxon.DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
7026
+ const intervalStart = import_luxon2.DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
7027
+ const intervalEnd = import_luxon2.DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
6932
7028
  return slotStart >= intervalStart && slotEnd <= intervalEnd;
6933
7029
  });
6934
7030
  }
@@ -1703,6 +1703,9 @@ var CalendarAdminService = class {
1703
1703
  }
1704
1704
  };
1705
1705
 
1706
+ // src/admin/mailing/appointment/appointment.mailing.service.ts
1707
+ import { DateTime } from "luxon";
1708
+
1706
1709
  // src/admin/mailing/base.mailing.service.ts
1707
1710
  import * as admin5 from "firebase-admin";
1708
1711
  var BaseMailingService = class {
@@ -2261,6 +2264,62 @@ var AppointmentMailingService = class extends BaseMailingService {
2261
2264
  this.DEFAULT_MAILGUN_DOMAIN = "mg.metaesthetics.net";
2262
2265
  Logger.info("[AppointmentMailingService] Initialized.");
2263
2266
  }
2267
+ /**
2268
+ * Formats a Firestore Timestamp in the clinic's timezone
2269
+ * @param timestamp - Firestore Timestamp (UTC)
2270
+ * @param clinicTimezone - IANA timezone string (e.g., "Europe/Zurich")
2271
+ * @param format - Format type: 'date', 'time', or 'datetime'
2272
+ * @returns Formatted string in clinic's local timezone
2273
+ */
2274
+ formatTimestampInClinicTimezone(timestamp, clinicTimezone, format = "datetime") {
2275
+ try {
2276
+ const dateTimeInClinicTz = DateTime.fromMillis(timestamp.toMillis(), {
2277
+ zone: clinicTimezone
2278
+ });
2279
+ switch (format) {
2280
+ case "date":
2281
+ return dateTimeInClinicTz.toLocaleString(DateTime.DATE_FULL);
2282
+ case "time":
2283
+ return dateTimeInClinicTz.toLocaleString(DateTime.TIME_SIMPLE);
2284
+ case "datetime":
2285
+ return dateTimeInClinicTz.toLocaleString(DateTime.DATETIME_FULL);
2286
+ default:
2287
+ return dateTimeInClinicTz.toLocaleString(DateTime.DATETIME_FULL);
2288
+ }
2289
+ } catch (error) {
2290
+ Logger.error("[AppointmentMailingService] Error formatting timestamp in clinic timezone:", {
2291
+ error: error instanceof Error ? error.message : String(error),
2292
+ clinicTimezone
2293
+ });
2294
+ return timestamp.toDate().toLocaleString();
2295
+ }
2296
+ }
2297
+ /**
2298
+ * Gets a user-friendly display name for a timezone
2299
+ * @param timezone - IANA timezone string (e.g., "Europe/Zurich")
2300
+ * @returns User-friendly timezone name (e.g., "Clinic Time - Europe/Zurich")
2301
+ */
2302
+ getTimezoneDisplayName(timezone) {
2303
+ try {
2304
+ const timezoneMap = {
2305
+ "Europe/Zurich": "Clinic Time - Central European Time",
2306
+ "Europe/London": "Clinic Time - GMT/BST",
2307
+ "America/New_York": "Clinic Time - Eastern Time",
2308
+ "America/Los_Angeles": "Clinic Time - Pacific Time",
2309
+ "Asia/Dubai": "Clinic Time - Gulf Standard Time",
2310
+ "Asia/Karachi": "Clinic Time - Pakistan Standard Time",
2311
+ "Asia/Kolkata": "Clinic Time - India Standard Time",
2312
+ "Australia/Sydney": "Clinic Time - Australian Eastern Time"
2313
+ };
2314
+ return timezoneMap[timezone] || `Clinic Time - ${timezone}`;
2315
+ } catch (error) {
2316
+ Logger.error("[AppointmentMailingService] Error getting timezone display name:", {
2317
+ error: error instanceof Error ? error.message : String(error),
2318
+ timezone
2319
+ });
2320
+ return `Clinic Time - ${timezone}`;
2321
+ }
2322
+ }
2264
2323
  async sendAppointmentConfirmedEmail(data) {
2265
2324
  var _a, _b, _c, _d, _e;
2266
2325
  Logger.info(
@@ -2274,11 +2333,26 @@ var AppointmentMailingService = class extends BaseMailingService {
2274
2333
  });
2275
2334
  throw new Error("Recipient email address is missing.");
2276
2335
  }
2336
+ const clinicTimezone = data.appointment.clinic_tz || "UTC";
2337
+ Logger.debug("[AppointmentMailingService] Formatting appointment time", {
2338
+ clinicTimezone,
2339
+ utcTime: data.appointment.appointmentStartTime.toDate().toISOString()
2340
+ });
2341
+ const formattedTime = this.formatTimestampInClinicTimezone(
2342
+ data.appointment.appointmentStartTime,
2343
+ clinicTimezone,
2344
+ "time"
2345
+ );
2346
+ const timezoneName = this.getTimezoneDisplayName(clinicTimezone);
2277
2347
  const templateVariables = {
2278
2348
  patientName: data.appointment.patientInfo.fullName,
2279
2349
  procedureName: data.appointment.procedureInfo.name,
2280
- appointmentDate: data.appointment.appointmentStartTime.toDate().toLocaleDateString(),
2281
- appointmentTime: data.appointment.appointmentStartTime.toDate().toLocaleTimeString(),
2350
+ appointmentDate: this.formatTimestampInClinicTimezone(
2351
+ data.appointment.appointmentStartTime,
2352
+ clinicTimezone,
2353
+ "date"
2354
+ ),
2355
+ appointmentTime: `${formattedTime} (${timezoneName})`,
2282
2356
  practitionerName: data.appointment.practitionerInfo.name,
2283
2357
  clinicName: data.appointment.clinicInfo.name
2284
2358
  };
@@ -2325,12 +2399,27 @@ var AppointmentMailingService = class extends BaseMailingService {
2325
2399
  );
2326
2400
  throw new Error("Clinic contact email address is missing.");
2327
2401
  }
2402
+ const clinicTimezone = data.appointment.clinic_tz || "UTC";
2403
+ Logger.debug("[AppointmentMailingService] Formatting appointment time for clinic", {
2404
+ clinicTimezone,
2405
+ utcTime: data.appointment.appointmentStartTime.toDate().toISOString()
2406
+ });
2407
+ const formattedTime = this.formatTimestampInClinicTimezone(
2408
+ data.appointment.appointmentStartTime,
2409
+ clinicTimezone,
2410
+ "time"
2411
+ );
2412
+ const timezoneName = this.getTimezoneDisplayName(clinicTimezone);
2328
2413
  const templateVariables = {
2329
2414
  clinicName: data.clinicProfile.name,
2330
2415
  patientName: data.appointment.patientInfo.fullName,
2331
2416
  procedureName: data.appointment.procedureInfo.name,
2332
- appointmentDate: data.appointment.appointmentStartTime.toDate().toLocaleDateString(),
2333
- appointmentTime: data.appointment.appointmentStartTime.toDate().toLocaleTimeString(),
2417
+ appointmentDate: this.formatTimestampInClinicTimezone(
2418
+ data.appointment.appointmentStartTime,
2419
+ clinicTimezone,
2420
+ "date"
2421
+ ),
2422
+ appointmentTime: `${formattedTime} (${timezoneName})`,
2334
2423
  practitionerName: data.appointment.practitionerInfo.name
2335
2424
  };
2336
2425
  const html = this.renderTemplate(clinicAppointmentRequestedTemplate, templateVariables);
@@ -6514,7 +6603,7 @@ var ReviewsAggregationService = class {
6514
6603
 
6515
6604
  // src/admin/booking/booking.calculator.ts
6516
6605
  import { Timestamp } from "firebase/firestore";
6517
- import { DateTime } from "luxon";
6606
+ import { DateTime as DateTime2 } from "luxon";
6518
6607
  var BookingAvailabilityCalculator = class {
6519
6608
  /**
6520
6609
  * Calculate available booking slots based on the provided data
@@ -6605,8 +6694,8 @@ var BookingAvailabilityCalculator = class {
6605
6694
  */
6606
6695
  static createWorkingHoursIntervals(workingHours, startDate, endDate, tz) {
6607
6696
  const workingIntervals = [];
6608
- let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
6609
- const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
6697
+ let start = DateTime2.fromMillis(startDate.getTime(), { zone: tz });
6698
+ const end = DateTime2.fromMillis(endDate.getTime(), { zone: tz });
6610
6699
  while (start <= end) {
6611
6700
  const dayOfWeek = start.weekday;
6612
6701
  const dayName = [
@@ -6636,8 +6725,8 @@ var BookingAvailabilityCalculator = class {
6636
6725
  millisecond: 0
6637
6726
  });
6638
6727
  if (workEnd.toMillis() > startDate.getTime() && workStart.toMillis() < endDate.getTime()) {
6639
- const intervalStart = workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6640
- const intervalEnd = workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6728
+ const intervalStart = workStart < DateTime2.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6729
+ const intervalEnd = workEnd > DateTime2.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6641
6730
  workingIntervals.push({
6642
6731
  start: Timestamp.fromMillis(intervalStart.toMillis()),
6643
6732
  end: Timestamp.fromMillis(intervalEnd.toMillis())
@@ -6744,8 +6833,8 @@ var BookingAvailabilityCalculator = class {
6744
6833
  */
6745
6834
  static createPractitionerWorkingHoursIntervals(workingHours, startDate, endDate, tz) {
6746
6835
  const workingIntervals = [];
6747
- let start = DateTime.fromMillis(startDate.getTime(), { zone: tz });
6748
- const end = DateTime.fromMillis(endDate.getTime(), { zone: tz });
6836
+ let start = DateTime2.fromMillis(startDate.getTime(), { zone: tz });
6837
+ const end = DateTime2.fromMillis(endDate.getTime(), { zone: tz });
6749
6838
  while (start <= end) {
6750
6839
  const dayOfWeek = start.weekday;
6751
6840
  const dayName = [
@@ -6768,8 +6857,8 @@ var BookingAvailabilityCalculator = class {
6768
6857
  });
6769
6858
  const workEnd = start.set({ hour: endHours, minute: endMinutes });
6770
6859
  if (workEnd.toMillis() > startDate.getTime() && workStart.toMillis() < endDate.getTime()) {
6771
- const intervalStart = workStart < DateTime.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6772
- const intervalEnd = workEnd > DateTime.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6860
+ const intervalStart = workStart < DateTime2.fromMillis(startDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(startDate.getTime(), { zone: tz }) : workStart;
6861
+ const intervalEnd = workEnd > DateTime2.fromMillis(endDate.getTime(), { zone: tz }) ? DateTime2.fromMillis(endDate.getTime(), { zone: tz }) : workEnd;
6773
6862
  workingIntervals.push({
6774
6863
  start: Timestamp.fromMillis(intervalStart.toMillis()),
6775
6864
  end: Timestamp.fromMillis(intervalEnd.toMillis())
@@ -6828,12 +6917,18 @@ var BookingAvailabilityCalculator = class {
6828
6917
  console.log(
6829
6918
  `Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
6830
6919
  );
6920
+ const nowInClinicTz = DateTime2.now().setZone(tz);
6921
+ const MINIMUM_BOOKING_WINDOW_MINUTES = 15;
6922
+ const earliestBookableTime = nowInClinicTz.plus({ minutes: MINIMUM_BOOKING_WINDOW_MINUTES });
6923
+ console.log(
6924
+ `Current time in ${tz}: ${nowInClinicTz.toISO()}, earliest bookable: ${earliestBookableTime.toISO()}`
6925
+ );
6831
6926
  const durationMs = durationMinutes * 60 * 1e3;
6832
6927
  const intervalMs = intervalMinutes * 60 * 1e3;
6833
6928
  for (const interval of intervals) {
6834
6929
  const intervalStart = interval.start.toDate();
6835
6930
  const intervalEnd = interval.end.toDate();
6836
- let slotStart = DateTime.fromMillis(intervalStart.getTime(), { zone: tz });
6931
+ let slotStart = DateTime2.fromMillis(intervalStart.getTime(), { zone: tz });
6837
6932
  const minutesIntoDay = slotStart.hour * 60 + slotStart.minute;
6838
6933
  const minutesRemainder = minutesIntoDay % intervalMinutes;
6839
6934
  if (minutesRemainder > 0) {
@@ -6843,7 +6938,8 @@ var BookingAvailabilityCalculator = class {
6843
6938
  }
6844
6939
  while (slotStart.toMillis() + durationMs <= intervalEnd.getTime()) {
6845
6940
  const slotEnd = slotStart.plus({ minutes: durationMinutes });
6846
- if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
6941
+ const isInFuture = slotStart >= earliestBookableTime;
6942
+ if (isInFuture && this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
6847
6943
  slots.push({
6848
6944
  start: Timestamp.fromMillis(slotStart.toMillis())
6849
6945
  });
@@ -6851,7 +6947,7 @@ var BookingAvailabilityCalculator = class {
6851
6947
  slotStart = slotStart.plus({ minutes: intervalMinutes });
6852
6948
  }
6853
6949
  }
6854
- console.log(`Generated ${slots.length} available slots`);
6950
+ console.log(`Generated ${slots.length} available slots (filtered for future times with ${MINIMUM_BOOKING_WINDOW_MINUTES}min minimum window)`);
6855
6951
  return slots;
6856
6952
  }
6857
6953
  /**
@@ -6865,8 +6961,8 @@ var BookingAvailabilityCalculator = class {
6865
6961
  */
6866
6962
  static isSlotFullyAvailable(slotStart, slotEnd, intervals, tz) {
6867
6963
  return intervals.some((interval) => {
6868
- const intervalStart = DateTime.fromMillis(interval.start.toMillis(), { zone: tz });
6869
- const intervalEnd = DateTime.fromMillis(interval.end.toMillis(), { zone: tz });
6964
+ const intervalStart = DateTime2.fromMillis(interval.start.toMillis(), { zone: tz });
6965
+ const intervalEnd = DateTime2.fromMillis(interval.end.toMillis(), { zone: tz });
6870
6966
  return slotStart >= intervalStart && slotEnd <= intervalEnd;
6871
6967
  });
6872
6968
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@blackcode_sa/metaestetics-api",
3
3
  "private": false,
4
- "version": "1.12.57",
4
+ "version": "1.12.59",
5
5
  "description": "Firebase authentication service with anonymous upgrade support",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.mjs",
@@ -484,6 +484,16 @@ export class BookingAvailabilityCalculator {
484
484
  `Generating slots with ${intervalMinutes}min intervals for ${durationMinutes}min procedure in timezone ${tz}`
485
485
  );
486
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
+
487
497
  // Convert duration to milliseconds
488
498
  const durationMs = durationMinutes * 60 * 1000;
489
499
  // Convert interval to milliseconds
@@ -513,8 +523,11 @@ export class BookingAvailabilityCalculator {
513
523
  // Calculate potential end time
514
524
  const slotEnd = slotStart.plus({ minutes: durationMinutes });
515
525
 
516
- // Check if this slot fits entirely within one of our available intervals
517
- if (this.isSlotFullyAvailable(slotStart, slotEnd, intervals, tz)) {
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)) {
518
531
  slots.push({
519
532
  start: Timestamp.fromMillis(slotStart.toMillis()),
520
533
  });
@@ -525,7 +538,7 @@ export class BookingAvailabilityCalculator {
525
538
  }
526
539
  }
527
540
 
528
- console.log(`Generated ${slots.length} available slots`);
541
+ console.log(`Generated ${slots.length} available slots (filtered for future times with ${MINIMUM_BOOKING_WINDOW_MINUTES}min minimum window)`);
529
542
  return slots;
530
543
  }
531
544
 
@@ -1,4 +1,5 @@
1
1
  import * as admin from 'firebase-admin';
2
+ import { DateTime } from 'luxon';
2
3
  import { BaseMailingService, NewMailgunClient } from '../base.mailing.service';
3
4
  import { Logger } from '../../logger';
4
5
  import { Appointment } from '../../../types/appointment';
@@ -466,6 +467,78 @@ export class AppointmentMailingService extends BaseMailingService {
466
467
  Logger.info('[AppointmentMailingService] Initialized.');
467
468
  }
468
469
 
470
+ /**
471
+ * Formats a Firestore Timestamp in the clinic's timezone
472
+ * @param timestamp - Firestore Timestamp (UTC)
473
+ * @param clinicTimezone - IANA timezone string (e.g., "Europe/Zurich")
474
+ * @param format - Format type: 'date', 'time', or 'datetime'
475
+ * @returns Formatted string in clinic's local timezone
476
+ */
477
+ private formatTimestampInClinicTimezone(
478
+ timestamp: admin.firestore.Timestamp,
479
+ clinicTimezone: string,
480
+ format: 'date' | 'time' | 'datetime' = 'datetime',
481
+ ): string {
482
+ try {
483
+ // Convert UTC timestamp to clinic's timezone
484
+ const dateTimeInClinicTz = DateTime.fromMillis(timestamp.toMillis(), {
485
+ zone: clinicTimezone,
486
+ });
487
+
488
+ // Format based on requested type
489
+ switch (format) {
490
+ case 'date':
491
+ // Format: "Monday, November 5, 2025"
492
+ return dateTimeInClinicTz.toLocaleString(DateTime.DATE_FULL);
493
+ case 'time':
494
+ // Format: "2:30 PM"
495
+ return dateTimeInClinicTz.toLocaleString(DateTime.TIME_SIMPLE);
496
+ case 'datetime':
497
+ // Format: "Monday, November 5, 2025 at 2:30 PM"
498
+ return dateTimeInClinicTz.toLocaleString(DateTime.DATETIME_FULL);
499
+ default:
500
+ return dateTimeInClinicTz.toLocaleString(DateTime.DATETIME_FULL);
501
+ }
502
+ } catch (error) {
503
+ Logger.error('[AppointmentMailingService] Error formatting timestamp in clinic timezone:', {
504
+ error: error instanceof Error ? error.message : String(error),
505
+ clinicTimezone,
506
+ });
507
+ // Fallback to UTC formatting if timezone conversion fails
508
+ return timestamp.toDate().toLocaleString();
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Gets a user-friendly display name for a timezone
514
+ * @param timezone - IANA timezone string (e.g., "Europe/Zurich")
515
+ * @returns User-friendly timezone name (e.g., "Clinic Time - Europe/Zurich")
516
+ */
517
+ private getTimezoneDisplayName(timezone: string): string {
518
+ try {
519
+ // For common timezones, provide a more readable format
520
+ const timezoneMap: Record<string, string> = {
521
+ 'Europe/Zurich': 'Clinic Time - Central European Time',
522
+ 'Europe/London': 'Clinic Time - GMT/BST',
523
+ 'America/New_York': 'Clinic Time - Eastern Time',
524
+ 'America/Los_Angeles': 'Clinic Time - Pacific Time',
525
+ 'Asia/Dubai': 'Clinic Time - Gulf Standard Time',
526
+ 'Asia/Karachi': 'Clinic Time - Pakistan Standard Time',
527
+ 'Asia/Kolkata': 'Clinic Time - India Standard Time',
528
+ 'Australia/Sydney': 'Clinic Time - Australian Eastern Time',
529
+ };
530
+
531
+ // Return mapped name if available, otherwise use the IANA timezone
532
+ return timezoneMap[timezone] || `Clinic Time - ${timezone}`;
533
+ } catch (error) {
534
+ Logger.error('[AppointmentMailingService] Error getting timezone display name:', {
535
+ error: error instanceof Error ? error.message : String(error),
536
+ timezone,
537
+ });
538
+ return `Clinic Time - ${timezone}`;
539
+ }
540
+ }
541
+
469
542
  async sendAppointmentConfirmedEmail(data: AppointmentConfirmationEmailData): Promise<any> {
470
543
  Logger.info(
471
544
  `[AppointmentMailingService] Preparing to send appointment confirmation email to ${data.recipientRole}: ${data.recipientProfile.id}`,
@@ -481,11 +554,31 @@ export class AppointmentMailingService extends BaseMailingService {
481
554
  throw new Error('Recipient email address is missing.');
482
555
  }
483
556
 
557
+ // Get clinic timezone from appointment data, default to UTC if not available
558
+ const clinicTimezone = data.appointment.clinic_tz || 'UTC';
559
+
560
+ Logger.debug('[AppointmentMailingService] Formatting appointment time', {
561
+ clinicTimezone,
562
+ utcTime: data.appointment.appointmentStartTime.toDate().toISOString(),
563
+ });
564
+
565
+ // Format time with timezone label for clarity
566
+ const formattedTime = this.formatTimestampInClinicTimezone(
567
+ data.appointment.appointmentStartTime,
568
+ clinicTimezone,
569
+ 'time',
570
+ );
571
+ const timezoneName = this.getTimezoneDisplayName(clinicTimezone);
572
+
484
573
  const templateVariables = {
485
574
  patientName: data.appointment.patientInfo.fullName,
486
575
  procedureName: data.appointment.procedureInfo.name,
487
- appointmentDate: data.appointment.appointmentStartTime.toDate().toLocaleDateString(),
488
- appointmentTime: data.appointment.appointmentStartTime.toDate().toLocaleTimeString(),
576
+ appointmentDate: this.formatTimestampInClinicTimezone(
577
+ data.appointment.appointmentStartTime,
578
+ clinicTimezone,
579
+ 'date',
580
+ ),
581
+ appointmentTime: `${formattedTime} (${timezoneName})`,
489
582
  practitionerName: data.appointment.practitionerInfo.name,
490
583
  clinicName: data.appointment.clinicInfo.name,
491
584
  };
@@ -539,12 +632,32 @@ export class AppointmentMailingService extends BaseMailingService {
539
632
  throw new Error('Clinic contact email address is missing.');
540
633
  }
541
634
 
635
+ // Get clinic timezone from appointment data, default to UTC if not available
636
+ const clinicTimezone = data.appointment.clinic_tz || 'UTC';
637
+
638
+ Logger.debug('[AppointmentMailingService] Formatting appointment time for clinic', {
639
+ clinicTimezone,
640
+ utcTime: data.appointment.appointmentStartTime.toDate().toISOString(),
641
+ });
642
+
643
+ // Format time with timezone label for clarity
644
+ const formattedTime = this.formatTimestampInClinicTimezone(
645
+ data.appointment.appointmentStartTime,
646
+ clinicTimezone,
647
+ 'time',
648
+ );
649
+ const timezoneName = this.getTimezoneDisplayName(clinicTimezone);
650
+
542
651
  const templateVariables = {
543
652
  clinicName: data.clinicProfile.name,
544
653
  patientName: data.appointment.patientInfo.fullName,
545
654
  procedureName: data.appointment.procedureInfo.name,
546
- appointmentDate: data.appointment.appointmentStartTime.toDate().toLocaleDateString(),
547
- appointmentTime: data.appointment.appointmentStartTime.toDate().toLocaleTimeString(),
655
+ appointmentDate: this.formatTimestampInClinicTimezone(
656
+ data.appointment.appointmentStartTime,
657
+ clinicTimezone,
658
+ 'date',
659
+ ),
660
+ appointmentTime: `${formattedTime} (${timezoneName})`,
548
661
  practitionerName: data.appointment.practitionerInfo.name,
549
662
  };
550
663