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:
@@ -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>);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user