From e4baf15b534dde473df73d531ea8a9a5ee3333fd Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 6 Aug 2025 03:55:47 +0300 Subject: [PATCH] feat: Sync timezone for users having delegation credentials for google/outlook (#22904) * feat: sync timezone with google/outlook for delegated credentials users * chore: dynamic sync timezone in get availble slots * redis cache for get delegated timezone * Update packages/lib/getUserAvailability.ts --------- Co-authored-by: Alex van Andel --- apps/api/v2/package.json | 2 +- .../lib/services/available-slots.service.ts | 8 +- .../lib/services/user-availability.service.ts | 9 +- .../googlecalendar/lib/CalendarService.ts | 14 ++ .../office365calendar/lib/CalendarService.ts | 17 ++ .../di/containers/get-user-availability.ts | 3 + .../lib/di/modules/get-user-availability.ts | 1 + packages/lib/getUserAvailability.ts | 146 ++++++++++++++---- yarn.lock | 10 +- 9 files changed, 164 insertions(+), 46 deletions(-) diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 33d311b9c2..a155065e20 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.285", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.286", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/apps/api/v2/src/lib/services/available-slots.service.ts b/apps/api/v2/src/lib/services/available-slots.service.ts index 47ed0c4d4b..e9ec45e4aa 100644 --- a/apps/api/v2/src/lib/services/available-slots.service.ts +++ b/apps/api/v2/src/lib/services/available-slots.service.ts @@ -13,6 +13,7 @@ import { RedisService } from "@/modules/redis/redis.service"; import { Injectable } from "@nestjs/common"; import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots"; + import { UserAvailabilityService } from "./user-availability.service"; @Injectable() @@ -41,7 +42,12 @@ export class AvailableSlotsService extends BaseAvailableSlotsService { redisClient: redisService, checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository), cacheService: new CacheService(featuresRepository), - userAvailabilityService: new UserAvailabilityService(oooRepoDependency, bookingRepository, eventTypeRepository) + userAvailabilityService: new UserAvailabilityService( + oooRepoDependency, + bookingRepository, + eventTypeRepository, + redisService + ), }); } } diff --git a/apps/api/v2/src/lib/services/user-availability.service.ts b/apps/api/v2/src/lib/services/user-availability.service.ts index b735b4a2a3..18796128b9 100644 --- a/apps/api/v2/src/lib/services/user-availability.service.ts +++ b/apps/api/v2/src/lib/services/user-availability.service.ts @@ -1,8 +1,7 @@ import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository"; import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository"; - import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository"; - +import { RedisService } from "@/modules/redis/redis.service"; import { Injectable } from "@nestjs/common"; import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules"; @@ -13,15 +12,13 @@ export class UserAvailabilityService extends BaseUserAvailabilityService { oooRepoDependency: PrismaOOORepository, bookingRepository: PrismaBookingRepository, eventTypeRepository: PrismaEventTypeRepository, - - + redisService: RedisService ) { super({ oooRepo: oooRepoDependency, - bookingRepo: bookingRepository, - eventTypeRepo: eventTypeRepository, + redisClient: redisService, }); } } diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index e593557ed7..90c96113ad 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -1114,4 +1114,18 @@ export default class GoogleCalendarService implements Calendar { throw error; } } + + async getMainTimeZone(): Promise { + try { + const primaryCalendar = await this.getPrimaryCalendar(); + if (!primaryCalendar?.timeZone) { + this.log.warn("No timezone found in primary calendar, defaulting to UTC"); + return "UTC"; + } + return primaryCalendar.timeZone; + } catch (error) { + this.log.error("Error getting main timezone from Google Calendar", { error }); + throw error; + } + } } diff --git a/packages/app-store/office365calendar/lib/CalendarService.ts b/packages/app-store/office365calendar/lib/CalendarService.ts index 2ee9990f63..6579b0949d 100644 --- a/packages/app-store/office365calendar/lib/CalendarService.ts +++ b/packages/app-store/office365calendar/lib/CalendarService.ts @@ -625,4 +625,21 @@ export default class Office365CalendarService implements Calendar { return response.json(); }; + + async getMainTimeZone(): Promise { + try { + const response = await this.fetcher(`${await this.getUserEndpoint()}/mailboxSettings/timeZone`); + const timezone = await handleErrorsJson(response); + + if (!timezone) { + this.log.warn("No timezone found in mailbox settings, defaulting to Europe/London"); + return "Europe/London"; + } + + return timezone; + } catch (error) { + this.log.error("Error getting main timezone from Office365 Calendar", { error }); + throw error; + } + } } diff --git a/packages/lib/di/containers/get-user-availability.ts b/packages/lib/di/containers/get-user-availability.ts index c8e047500c..5faeed3699 100644 --- a/packages/lib/di/containers/get-user-availability.ts +++ b/packages/lib/di/containers/get-user-availability.ts @@ -7,6 +7,7 @@ import { getUserAvailabilityModule } from "../modules/get-user-availability"; import { bookingRepositoryModule } from "../modules/booking"; import { eventTypeRepositoryModule } from "../modules/eventType"; +import { redisModule } from "@calcom/features/redis/di/redisModule"; import { oooRepositoryModule } from "../modules/ooo"; import { UserAvailabilityService } from "../../getUserAvailability"; @@ -17,6 +18,8 @@ container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule); container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule); container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule); container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule); +container.load(DI_TOKENS.REDIS_CLIENT, redisModule); + export function getUserAvailabilityService() { return container.get(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE); diff --git a/packages/lib/di/modules/get-user-availability.ts b/packages/lib/di/modules/get-user-availability.ts index 1db28db1a4..aac6e49987 100644 --- a/packages/lib/di/modules/get-user-availability.ts +++ b/packages/lib/di/modules/get-user-availability.ts @@ -8,4 +8,5 @@ getUserAvailabilityModule.bind(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE).toClass( oooRepo: DI_TOKENS.OOO_REPOSITORY, bookingRepo: DI_TOKENS.BOOKING_REPOSITORY, eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY, + redisClient: DI_TOKENS.REDIS_CLIENT, } satisfies Record); diff --git a/packages/lib/getUserAvailability.ts b/packages/lib/getUserAvailability.ts index 39a181d8c1..45adb4dbf5 100644 --- a/packages/lib/getUserAvailability.ts +++ b/packages/lib/getUserAvailability.ts @@ -8,10 +8,11 @@ import type { } from "@prisma/client"; import * as Sentry from "@sentry/nextjs"; import { z } from "zod"; -import type { BookingRepository } from "@calcom/lib/server/repository/booking"; +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import type { Dayjs } from "@calcom/dayjs"; import dayjs from "@calcom/dayjs"; +import type { IRedisService } from "@calcom/features/redis/IRedisService"; import { getWorkingHours } from "@calcom/lib/availability"; import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges"; import { buildDateRanges, subtract } from "@calcom/lib/date-ranges"; @@ -27,7 +28,9 @@ import { import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; import { findUsersForAvailabilityCheck } from "@calcom/lib/server/findUsersForAvailabilityCheck"; +import type { BookingRepository } from "@calcom/lib/server/repository/booking"; import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository"; +import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo"; import { SchedulingType } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import type { EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar"; @@ -35,7 +38,6 @@ import type { TimeRange } from "@calcom/types/schedule"; import { getBusyTimes } from "./getBusyTimes"; import { withReporting } from "./sentryWrapper"; -import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo"; const log = logger.getSubLogger({ prefix: ["getUserAvailability"] }); const availabilitySchema = z @@ -55,9 +57,7 @@ const availabilitySchema = z }) .refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in."); -export type EventType = Awaited< - ReturnType<(typeof UserAvailabilityService)["prototype"]["_getEventType"]> ->; +export type EventType = Awaited>; type GetUser = Awaited>; @@ -157,13 +157,71 @@ export interface IUserAvailabilityService { eventTypeRepo: EventTypeRepository; oooRepo: PrismaOOORepository; bookingRepo: BookingRepository; + redisClient: IRedisService; } export class UserAvailabilityService { constructor(public readonly dependencies: IUserAvailabilityService) {} + // Fetch timezones from outlook or google using delegated credentials (formely known as domain wide delegatiion) + async getTimezoneFromDelegatedCalendars(user: GetAvailabilityUser): Promise { + if (!user.credentials || user.credentials.length === 0) { + return null; + } + + const delegatedCredentials = user.credentials.filter( + (credential) => credential.type.endsWith("_calendar") && Boolean(credential.delegatedToId) + ); + + if (!delegatedCredentials || delegatedCredentials.length === 0) { + return null; + } + + const cacheKey = `user-timezone:${user.id}`; + + try { + const cachedTimezone = await this.dependencies.redisClient.get(cacheKey); + + if (cachedTimezone) { + log.debug(`Got timezone ${cachedTimezone} from Redis cache for user ${user.id}`); + return cachedTimezone; + } + } catch (error) { + log.warn(`Failed to get timezone from Redis cache for user ${user.id}:`, error); + } + + if (delegatedCredentials.length === 0) { + return null; + } + + for (const credential of delegatedCredentials) { + try { + const calendar = await getCalendar(credential); + if (calendar && "getMainTimeZone" in calendar && typeof calendar.getMainTimeZone === "function") { + const timezone = await calendar.getMainTimeZone(); + if (timezone && timezone !== "UTC") { + log.debug(`Got timezone ${timezone} from calendar service ${credential.type}`); + + try { + await this.dependencies.redisClient.set(cacheKey, timezone, { ttl: 3600 * 6 * 1000 }); // 6 hours ttl in ms; + log.debug(`Cached timezone ${timezone} in Redis for user ${user.id}`); + } catch (error) { + log.warn(`Failed to set timezone in Redis cache for user ${user.id}:`, error); + } + + return timezone; + } + } + } catch (error) { + log.warn(`Failed to get timezone from calendar service ${credential.type}:`, error); + } + } + + return null; + } + async _getEventType(id: number) { - const eventType = await this.dependencies.eventTypeRepo.findByIdForUserAvailability({id}) + const eventType = await this.dependencies.eventTypeRepo.findByIdForUserAvailability({ id }); if (!eventType) { return eventType; } @@ -200,9 +258,11 @@ export class UserAvailabilityService { schedulingType === SchedulingType.ROUND_ROBIN || schedulingType === SchedulingType.COLLECTIVE; - const bookings = await this.dependencies.bookingRepo.findAcceptedBookingByEventTypeId({eventTypeId: id, dateFrom: dateFrom.format(), dateTo: dateTo.format()}) - - + const bookings = await this.dependencies.bookingRepo.findAcceptedBookingByEventTypeId({ + eventTypeId: id, + dateFrom: dateFrom.format(), + dateTo: dateTo.format(), + }); return bookings.map((booking) => { const attendees = isTeamEvent @@ -290,10 +350,15 @@ export class UserAvailabilityService { timeZone: fallbackTimezoneIfScheduleIsMissing, }; - const schedule = - (eventType?.schedule ? eventType.schedule : hostSchedule ? hostSchedule : userSchedule) ?? - fallbackSchedule; - const timeZone = schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing; + // possible timezones that have been set by or for a user + const potentialSchedule = eventType?.schedule + ? eventType.schedule + : hostSchedule + ? hostSchedule + : userSchedule; + + // if no schedules set by or for a user, use fallbackSchedule + const schedule = potentialSchedule ?? fallbackSchedule; const bookingLimits = eventType?.bookingLimits && @@ -309,6 +374,25 @@ export class UserAvailabilityService { ? parseDurationLimit(eventType.durationLimits) : null; + // TODO: only query what we need after applying limits (shrink date range) + const getBusyTimesStart = dateFrom.toISOString(); + const getBusyTimesEnd = dateTo.toISOString(); + + const selectedCalendars = eventType?.useEventLevelSelectedCalendars + ? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id }) + : user.userLevelSelectedCalendars; + + const isTimezoneSet = Boolean(potentialSchedule && potentialSchedule.timeZone !== null); + + // this timezone is synced with google/outlook calendars timezone usingg delegated credentials + // it's a fallback for delegated credentials users who want to sync their timezone with third party calendars + const calendarTimezone = !isTimezoneSet ? await this.getTimezoneFromDelegatedCalendars(user) : null; + + const finalTimezone = + !isTimezoneSet && calendarTimezone + ? calendarTimezone + : schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing; + let busyTimesFromLimits: EventBusyDetails[] = []; if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) { @@ -318,12 +402,12 @@ export class UserAvailabilityService { busyTimesFromLimits = await getBusyTimesFromLimits( bookingLimits, durationLimits, - dateFrom.tz(timeZone), - dateTo.tz(timeZone), + dateFrom.tz(finalTimezone), + dateTo.tz(finalTimezone), duration, eventType, initialData?.busyTimesFromLimitsBookings ?? [], - timeZone, + finalTimezone, initialData?.rescheduleUid ?? undefined ); } @@ -344,23 +428,15 @@ export class UserAvailabilityService { busyTimesFromTeamLimits = await getBusyTimesFromTeamLimits( user, teamBookingLimits, - dateFrom.tz(timeZone), - dateTo.tz(timeZone), + dateFrom.tz(finalTimezone), + dateTo.tz(finalTimezone), teamForBookingLimits.id, teamForBookingLimits.includeManagedEventsInLimits, - timeZone, + finalTimezone, initialData?.rescheduleUid ?? undefined ); } - // TODO: only query what we need after applying limits (shrink date range) - const getBusyTimesStart = dateFrom.toISOString(); - const getBusyTimesEnd = dateTo.toISOString(); - - const selectedCalendars = eventType?.useEventLevelSelectedCalendars - ? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id }) - : user.userLevelSelectedCalendars; - let busyTimes = []; try { busyTimes = await getBusyTimes({ @@ -385,7 +461,7 @@ export class UserAvailabilityService { log.error(`Error fetching busy times for user ${username}:`, error); return { busy: [], - timeZone, + timeZone: finalTimezone, dateRanges: [], oooExcludedDateRanges: [], workingHours: [], @@ -434,7 +510,7 @@ export class UserAvailabilityService { userId: user.id, })); - const workingHours = getWorkingHours({ timeZone }, availability); + const workingHours = getWorkingHours({ timeZone: finalTimezone }, availability); const dateOverrides: TimeRange[] = []; // NOTE: getSchedule is currently calling this function for every user in a team event @@ -466,7 +542,11 @@ export class UserAvailabilityService { const outOfOfficeDays = initialData?.outOfOfficeDays ?? - (await this.dependencies.oooRepo.findUserOOODays({userId: user.id, dateFrom: dateFrom.toISOString(), dateTo: dateTo.toISOString()})); + (await this.dependencies.oooRepo.findUserOOODays({ + userId: user.id, + dateFrom: dateFrom.toISOString(), + dateTo: dateTo.toISOString(), + })); const datesOutOfOffice: IOutOfOfficeData = this.calculateOutOfOfficeRanges(outOfOfficeDays, availability); @@ -474,7 +554,7 @@ export class UserAvailabilityService { dateFrom, dateTo, availability, - timeZone, + timeZone: finalTimezone, travelSchedules: isDefaultSchedule ? user.travelSchedules.map((schedule) => { return { @@ -497,7 +577,7 @@ export class UserAvailabilityService { const result = { busy: detailedBusyTimes, - timeZone, + timeZone: finalTimezone, dateRanges: dateRangesInWhichUserIsAvailable, oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO, workingHours, @@ -610,4 +690,4 @@ export class UserAvailabilityService { } getUsersAvailability = withReporting(this._getUsersAvailability.bind(this), "getUsersAvailability"); -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index d987f88371..3d0d207eaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2500,7 +2500,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.285" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.286" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3556,13 +3556,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.285": - version: 0.0.285 - resolution: "@calcom/platform-libraries@npm:0.0.285" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.286": + version: 0.0.286 + resolution: "@calcom/platform-libraries@npm:0.0.286" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: 2faebf08e2565a1eb75e70a1bf68af3a7593b8a2c35f4d05af97fdfba7fda6fba962984934a47c3a7fbf0a1a5b4f1ca4dd89029d710b6684b0f6fafa96526e11 + checksum: b6402b589456fdc7f0aa86368e75dfcab4ec2c9c813c6bd30efcb7b2c58634441908856a9893788055ca5a71b655a9632ed9f559af7d23bd59293924adcbfe4d languageName: node linkType: hard