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 <me@alexvanandel.com>
This commit is contained in:
Morgan
2025-08-06 01:55:47 +01:00
committed by GitHub
co-authored by Alex van Andel
parent a1bd8b6259
commit e4baf15b53
9 changed files with 164 additions and 46 deletions
+1 -1
View File
@@ -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": "*",
@@ -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
),
});
}
}
@@ -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,
});
}
}
@@ -1114,4 +1114,18 @@ export default class GoogleCalendarService implements Calendar {
throw error;
}
}
async getMainTimeZone(): Promise<string> {
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;
}
}
}
@@ -625,4 +625,21 @@ export default class Office365CalendarService implements Calendar {
return response.json();
};
async getMainTimeZone(): Promise<string> {
try {
const response = await this.fetcher(`${await this.getUserEndpoint()}/mailboxSettings/timeZone`);
const timezone = await handleErrorsJson<string>(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;
}
}
}
@@ -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<UserAvailabilityService>(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE);
@@ -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<keyof IUserAvailabilityService, symbol>);
+113 -33
View File
@@ -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<ReturnType<(typeof UserAvailabilityService)["prototype"]["_getEventType"]>>;
type GetUser = Awaited<ReturnType<(typeof UserAvailabilityService)["prototype"]["_getUser"]>>;
@@ -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<string | null> {
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<string>(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<string>(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");
}
}
+5 -5
View File
@@ -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