Skip to content

Commit e4baf15

Browse files
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>
1 parent a1bd8b6 commit e4baf15

9 files changed

Lines changed: 164 additions & 46 deletions

File tree

apps/api/v2/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"@axiomhq/winston": "^1.2.0",
3939
"@calcom/platform-constants": "*",
4040
"@calcom/platform-enums": "*",
41-
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.285",
41+
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.286",
4242
"@calcom/platform-types": "*",
4343
"@calcom/platform-utils": "*",
4444
"@calcom/prisma": "*",

apps/api/v2/src/lib/services/available-slots.service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { RedisService } from "@/modules/redis/redis.service";
1313
import { Injectable } from "@nestjs/common";
1414

1515
import { AvailableSlotsService as BaseAvailableSlotsService } from "@calcom/platform-libraries/slots";
16+
1617
import { UserAvailabilityService } from "./user-availability.service";
1718

1819
@Injectable()
@@ -41,7 +42,12 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
4142
redisClient: redisService,
4243
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
4344
cacheService: new CacheService(featuresRepository),
44-
userAvailabilityService: new UserAvailabilityService(oooRepoDependency, bookingRepository, eventTypeRepository)
45+
userAvailabilityService: new UserAvailabilityService(
46+
oooRepoDependency,
47+
bookingRepository,
48+
eventTypeRepository,
49+
redisService
50+
),
4551
});
4652
}
4753
}
Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
22
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
3-
43
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
5-
4+
import { RedisService } from "@/modules/redis/redis.service";
65
import { Injectable } from "@nestjs/common";
76

87
import { UserAvailabilityService as BaseUserAvailabilityService } from "@calcom/platform-libraries/schedules";
@@ -13,15 +12,13 @@ export class UserAvailabilityService extends BaseUserAvailabilityService {
1312
oooRepoDependency: PrismaOOORepository,
1413
bookingRepository: PrismaBookingRepository,
1514
eventTypeRepository: PrismaEventTypeRepository,
16-
17-
15+
redisService: RedisService
1816
) {
1917
super({
2018
oooRepo: oooRepoDependency,
21-
2219
bookingRepo: bookingRepository,
23-
2420
eventTypeRepo: eventTypeRepository,
21+
redisClient: redisService,
2522
});
2623
}
2724
}

packages/app-store/googlecalendar/lib/CalendarService.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,4 +1114,18 @@ export default class GoogleCalendarService implements Calendar {
11141114
throw error;
11151115
}
11161116
}
1117+
1118+
async getMainTimeZone(): Promise<string> {
1119+
try {
1120+
const primaryCalendar = await this.getPrimaryCalendar();
1121+
if (!primaryCalendar?.timeZone) {
1122+
this.log.warn("No timezone found in primary calendar, defaulting to UTC");
1123+
return "UTC";
1124+
}
1125+
return primaryCalendar.timeZone;
1126+
} catch (error) {
1127+
this.log.error("Error getting main timezone from Google Calendar", { error });
1128+
throw error;
1129+
}
1130+
}
11171131
}

packages/app-store/office365calendar/lib/CalendarService.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,4 +625,21 @@ export default class Office365CalendarService implements Calendar {
625625

626626
return response.json();
627627
};
628+
629+
async getMainTimeZone(): Promise<string> {
630+
try {
631+
const response = await this.fetcher(`${await this.getUserEndpoint()}/mailboxSettings/timeZone`);
632+
const timezone = await handleErrorsJson<string>(response);
633+
634+
if (!timezone) {
635+
this.log.warn("No timezone found in mailbox settings, defaulting to Europe/London");
636+
return "Europe/London";
637+
}
638+
639+
return timezone;
640+
} catch (error) {
641+
this.log.error("Error getting main timezone from Office365 Calendar", { error });
642+
throw error;
643+
}
644+
}
628645
}

packages/lib/di/containers/get-user-availability.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getUserAvailabilityModule } from "../modules/get-user-availability";
77
import { bookingRepositoryModule } from "../modules/booking";
88

99
import { eventTypeRepositoryModule } from "../modules/eventType";
10+
import { redisModule } from "@calcom/features/redis/di/redisModule";
1011

1112
import { oooRepositoryModule } from "../modules/ooo";
1213
import { UserAvailabilityService } from "../../getUserAvailability";
@@ -17,6 +18,8 @@ container.load(DI_TOKENS.OOO_REPOSITORY_MODULE, oooRepositoryModule);
1718
container.load(DI_TOKENS.BOOKING_REPOSITORY_MODULE, bookingRepositoryModule);
1819
container.load(DI_TOKENS.EVENT_TYPE_REPOSITORY_MODULE, eventTypeRepositoryModule);
1920
container.load(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE_MODULE, getUserAvailabilityModule);
21+
container.load(DI_TOKENS.REDIS_CLIENT, redisModule);
22+
2023

2124
export function getUserAvailabilityService() {
2225
return container.get<UserAvailabilityService>(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE);

packages/lib/di/modules/get-user-availability.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ getUserAvailabilityModule.bind(DI_TOKENS.GET_USER_AVAILABILITY_SERVICE).toClass(
88
oooRepo: DI_TOKENS.OOO_REPOSITORY,
99
bookingRepo: DI_TOKENS.BOOKING_REPOSITORY,
1010
eventTypeRepo: DI_TOKENS.EVENT_TYPE_REPOSITORY,
11+
redisClient: DI_TOKENS.REDIS_CLIENT,
1112
} satisfies Record<keyof IUserAvailabilityService, symbol>);

packages/lib/getUserAvailability.ts

Lines changed: 113 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ import type {
88
} from "@prisma/client";
99
import * as Sentry from "@sentry/nextjs";
1010
import { z } from "zod";
11-
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
1211

12+
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
1313
import type { Dayjs } from "@calcom/dayjs";
1414
import dayjs from "@calcom/dayjs";
15+
import type { IRedisService } from "@calcom/features/redis/IRedisService";
1516
import { getWorkingHours } from "@calcom/lib/availability";
1617
import type { DateOverride, WorkingHours } from "@calcom/lib/date-ranges";
1718
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
@@ -27,15 +28,16 @@ import {
2728
import logger from "@calcom/lib/logger";
2829
import { safeStringify } from "@calcom/lib/safeStringify";
2930
import { findUsersForAvailabilityCheck } from "@calcom/lib/server/findUsersForAvailabilityCheck";
31+
import type { BookingRepository } from "@calcom/lib/server/repository/booking";
3032
import { EventTypeRepository } from "@calcom/lib/server/repository/eventTypeRepository";
33+
import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo";
3134
import { SchedulingType } from "@calcom/prisma/enums";
3235
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
3336
import type { EventBusyDetails, IntervalLimitUnit } from "@calcom/types/Calendar";
3437
import type { TimeRange } from "@calcom/types/schedule";
3538

3639
import { getBusyTimes } from "./getBusyTimes";
3740
import { withReporting } from "./sentryWrapper";
38-
import type { PrismaOOORepository } from "@calcom/lib/server/repository/ooo";
3941

4042
const log = logger.getSubLogger({ prefix: ["getUserAvailability"] });
4143
const availabilitySchema = z
@@ -55,9 +57,7 @@ const availabilitySchema = z
5557
})
5658
.refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in.");
5759

58-
export type EventType = Awaited<
59-
ReturnType<(typeof UserAvailabilityService)["prototype"]["_getEventType"]>
60-
>;
60+
export type EventType = Awaited<ReturnType<(typeof UserAvailabilityService)["prototype"]["_getEventType"]>>;
6161

6262
type GetUser = Awaited<ReturnType<(typeof UserAvailabilityService)["prototype"]["_getUser"]>>;
6363

@@ -157,13 +157,71 @@ export interface IUserAvailabilityService {
157157
eventTypeRepo: EventTypeRepository;
158158
oooRepo: PrismaOOORepository;
159159
bookingRepo: BookingRepository;
160+
redisClient: IRedisService;
160161
}
161162

162163
export class UserAvailabilityService {
163164
constructor(public readonly dependencies: IUserAvailabilityService) {}
164165

166+
// Fetch timezones from outlook or google using delegated credentials (formely known as domain wide delegatiion)
167+
async getTimezoneFromDelegatedCalendars(user: GetAvailabilityUser): Promise<string | null> {
168+
if (!user.credentials || user.credentials.length === 0) {
169+
return null;
170+
}
171+
172+
const delegatedCredentials = user.credentials.filter(
173+
(credential) => credential.type.endsWith("_calendar") && Boolean(credential.delegatedToId)
174+
);
175+
176+
if (!delegatedCredentials || delegatedCredentials.length === 0) {
177+
return null;
178+
}
179+
180+
const cacheKey = `user-timezone:${user.id}`;
181+
182+
try {
183+
const cachedTimezone = await this.dependencies.redisClient.get<string>(cacheKey);
184+
185+
if (cachedTimezone) {
186+
log.debug(`Got timezone ${cachedTimezone} from Redis cache for user ${user.id}`);
187+
return cachedTimezone;
188+
}
189+
} catch (error) {
190+
log.warn(`Failed to get timezone from Redis cache for user ${user.id}:`, error);
191+
}
192+
193+
if (delegatedCredentials.length === 0) {
194+
return null;
195+
}
196+
197+
for (const credential of delegatedCredentials) {
198+
try {
199+
const calendar = await getCalendar(credential);
200+
if (calendar && "getMainTimeZone" in calendar && typeof calendar.getMainTimeZone === "function") {
201+
const timezone = await calendar.getMainTimeZone();
202+
if (timezone && timezone !== "UTC") {
203+
log.debug(`Got timezone ${timezone} from calendar service ${credential.type}`);
204+
205+
try {
206+
await this.dependencies.redisClient.set<string>(cacheKey, timezone, { ttl: 3600 * 6 * 1000 }); // 6 hours ttl in ms;
207+
log.debug(`Cached timezone ${timezone} in Redis for user ${user.id}`);
208+
} catch (error) {
209+
log.warn(`Failed to set timezone in Redis cache for user ${user.id}:`, error);
210+
}
211+
212+
return timezone;
213+
}
214+
}
215+
} catch (error) {
216+
log.warn(`Failed to get timezone from calendar service ${credential.type}:`, error);
217+
}
218+
}
219+
220+
return null;
221+
}
222+
165223
async _getEventType(id: number) {
166-
const eventType = await this.dependencies.eventTypeRepo.findByIdForUserAvailability({id})
224+
const eventType = await this.dependencies.eventTypeRepo.findByIdForUserAvailability({ id });
167225
if (!eventType) {
168226
return eventType;
169227
}
@@ -200,9 +258,11 @@ export class UserAvailabilityService {
200258
schedulingType === SchedulingType.ROUND_ROBIN ||
201259
schedulingType === SchedulingType.COLLECTIVE;
202260

203-
const bookings = await this.dependencies.bookingRepo.findAcceptedBookingByEventTypeId({eventTypeId: id, dateFrom: dateFrom.format(), dateTo: dateTo.format()})
204-
205-
261+
const bookings = await this.dependencies.bookingRepo.findAcceptedBookingByEventTypeId({
262+
eventTypeId: id,
263+
dateFrom: dateFrom.format(),
264+
dateTo: dateTo.format(),
265+
});
206266

207267
return bookings.map((booking) => {
208268
const attendees = isTeamEvent
@@ -290,10 +350,15 @@ export class UserAvailabilityService {
290350
timeZone: fallbackTimezoneIfScheduleIsMissing,
291351
};
292352

293-
const schedule =
294-
(eventType?.schedule ? eventType.schedule : hostSchedule ? hostSchedule : userSchedule) ??
295-
fallbackSchedule;
296-
const timeZone = schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing;
353+
// possible timezones that have been set by or for a user
354+
const potentialSchedule = eventType?.schedule
355+
? eventType.schedule
356+
: hostSchedule
357+
? hostSchedule
358+
: userSchedule;
359+
360+
// if no schedules set by or for a user, use fallbackSchedule
361+
const schedule = potentialSchedule ?? fallbackSchedule;
297362

298363
const bookingLimits =
299364
eventType?.bookingLimits &&
@@ -309,6 +374,25 @@ export class UserAvailabilityService {
309374
? parseDurationLimit(eventType.durationLimits)
310375
: null;
311376

377+
// TODO: only query what we need after applying limits (shrink date range)
378+
const getBusyTimesStart = dateFrom.toISOString();
379+
const getBusyTimesEnd = dateTo.toISOString();
380+
381+
const selectedCalendars = eventType?.useEventLevelSelectedCalendars
382+
? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id })
383+
: user.userLevelSelectedCalendars;
384+
385+
const isTimezoneSet = Boolean(potentialSchedule && potentialSchedule.timeZone !== null);
386+
387+
// this timezone is synced with google/outlook calendars timezone usingg delegated credentials
388+
// it's a fallback for delegated credentials users who want to sync their timezone with third party calendars
389+
const calendarTimezone = !isTimezoneSet ? await this.getTimezoneFromDelegatedCalendars(user) : null;
390+
391+
const finalTimezone =
392+
!isTimezoneSet && calendarTimezone
393+
? calendarTimezone
394+
: schedule?.timeZone || fallbackTimezoneIfScheduleIsMissing;
395+
312396
let busyTimesFromLimits: EventBusyDetails[] = [];
313397

314398
if (initialData?.busyTimesFromLimits && initialData?.eventTypeForLimits) {
@@ -318,12 +402,12 @@ export class UserAvailabilityService {
318402
busyTimesFromLimits = await getBusyTimesFromLimits(
319403
bookingLimits,
320404
durationLimits,
321-
dateFrom.tz(timeZone),
322-
dateTo.tz(timeZone),
405+
dateFrom.tz(finalTimezone),
406+
dateTo.tz(finalTimezone),
323407
duration,
324408
eventType,
325409
initialData?.busyTimesFromLimitsBookings ?? [],
326-
timeZone,
410+
finalTimezone,
327411
initialData?.rescheduleUid ?? undefined
328412
);
329413
}
@@ -344,23 +428,15 @@ export class UserAvailabilityService {
344428
busyTimesFromTeamLimits = await getBusyTimesFromTeamLimits(
345429
user,
346430
teamBookingLimits,
347-
dateFrom.tz(timeZone),
348-
dateTo.tz(timeZone),
431+
dateFrom.tz(finalTimezone),
432+
dateTo.tz(finalTimezone),
349433
teamForBookingLimits.id,
350434
teamForBookingLimits.includeManagedEventsInLimits,
351-
timeZone,
435+
finalTimezone,
352436
initialData?.rescheduleUid ?? undefined
353437
);
354438
}
355439

356-
// TODO: only query what we need after applying limits (shrink date range)
357-
const getBusyTimesStart = dateFrom.toISOString();
358-
const getBusyTimesEnd = dateTo.toISOString();
359-
360-
const selectedCalendars = eventType?.useEventLevelSelectedCalendars
361-
? EventTypeRepository.getSelectedCalendarsFromUser({ user, eventTypeId: eventType.id })
362-
: user.userLevelSelectedCalendars;
363-
364440
let busyTimes = [];
365441
try {
366442
busyTimes = await getBusyTimes({
@@ -385,7 +461,7 @@ export class UserAvailabilityService {
385461
log.error(`Error fetching busy times for user ${username}:`, error);
386462
return {
387463
busy: [],
388-
timeZone,
464+
timeZone: finalTimezone,
389465
dateRanges: [],
390466
oooExcludedDateRanges: [],
391467
workingHours: [],
@@ -434,7 +510,7 @@ export class UserAvailabilityService {
434510
userId: user.id,
435511
}));
436512

437-
const workingHours = getWorkingHours({ timeZone }, availability);
513+
const workingHours = getWorkingHours({ timeZone: finalTimezone }, availability);
438514

439515
const dateOverrides: TimeRange[] = [];
440516
// NOTE: getSchedule is currently calling this function for every user in a team event
@@ -466,15 +542,19 @@ export class UserAvailabilityService {
466542

467543
const outOfOfficeDays =
468544
initialData?.outOfOfficeDays ??
469-
(await this.dependencies.oooRepo.findUserOOODays({userId: user.id, dateFrom: dateFrom.toISOString(), dateTo: dateTo.toISOString()}));
545+
(await this.dependencies.oooRepo.findUserOOODays({
546+
userId: user.id,
547+
dateFrom: dateFrom.toISOString(),
548+
dateTo: dateTo.toISOString(),
549+
}));
470550

471551
const datesOutOfOffice: IOutOfOfficeData = this.calculateOutOfOfficeRanges(outOfOfficeDays, availability);
472552

473553
const { dateRanges, oooExcludedDateRanges } = buildDateRanges({
474554
dateFrom,
475555
dateTo,
476556
availability,
477-
timeZone,
557+
timeZone: finalTimezone,
478558
travelSchedules: isDefaultSchedule
479559
? user.travelSchedules.map((schedule) => {
480560
return {
@@ -497,7 +577,7 @@ export class UserAvailabilityService {
497577

498578
const result = {
499579
busy: detailedBusyTimes,
500-
timeZone,
580+
timeZone: finalTimezone,
501581
dateRanges: dateRangesInWhichUserIsAvailable,
502582
oooExcludedDateRanges: dateRangesInWhichUserIsAvailableWithoutOOO,
503583
workingHours,
@@ -610,4 +690,4 @@ export class UserAvailabilityService {
610690
}
611691

612692
getUsersAvailability = withReporting(this._getUsersAvailability.bind(this), "getUsersAvailability");
613-
}
693+
}

0 commit comments

Comments
 (0)