refactor: replace loggedInUsersTz with timeZone property in busy-times API (#25440)

* refactor: replace loggedInUsersTz with timeZone in busy-times API

- Add new timeZone parameter to CalendarBusyTimesInput
- Mark loggedInUsersTz as deprecated
- Update getBusyTimes endpoint to prioritize timeZone parameter
- Maintain backward compatibility with loggedInUsersTz
- Add validation to ensure at least one timezone parameter is provided
- Update API documentation with new parameter example

Fixes #25423

* fix: improve timezone validation in CalendarBusyTimesInput

- Use custom ValidatorConstraint instead of problematic ValidateIf
- Properly validate that at least one timezone parameter is provided
- Improve code quality and clarity

* fix: update DTOs for getBusyTimes

* cleanup

* fix: abstract normalize timezone logic into platform types util function

* chore: update e2e tests

* fixup

* fixup

---------

Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
This commit is contained in:
Shikhar Varshney
2026-01-19 09:22:19 +00:00
committed by GitHub
co-authored by Ryukemeister Rajiv Sahal
parent c1d0a6bb1b
commit 2227d990b6
6 changed files with 164 additions and 36 deletions
@@ -292,6 +292,75 @@ describe("Platform Calendars Endpoints", () => {
expect(data.destinationCalendar.integration).toEqual(GOOGLE_CALENDAR_TYPE);
});
it(`/GET/v2/calendars/busy-times: should return 400 when neither timeZone nor loggedInUsersTz is provided`, async () => {
const response = await request(app.getHttpServer())
.get(
`/v2/calendars/busy-times?dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=${googleCalendarCredentials.id}&calendarsToLoad[0][externalId]=test@example.com`
)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(400);
expect(response.body.error).toBeDefined();
expect(response.body.error.message).toContain("Either timeZone or loggedInUsersTz must be provided");
});
it(`/GET/v2/calendars/busy-times: should work with valid timeZone parameter`, async () => {
const mockBusyTimes = [
{
start: new Date("2024-12-18T10:00:00Z"),
end: new Date("2024-12-18T11:00:00Z"),
source: "google_calendar",
},
];
const getBusyTimesSpy = jest
.spyOn(CalendarsService.prototype, "getBusyTimes")
.mockResolvedValue(mockBusyTimes);
const response = await request(app.getHttpServer())
.get(
`/v2/calendars/busy-times?timeZone=America/New_York&dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=${googleCalendarCredentials.id}&calendarsToLoad[0][externalId]=test@example.com`
)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeDefined();
expect(Array.isArray(response.body.data)).toBe(true);
getBusyTimesSpy.mockRestore();
});
it(`/GET/v2/calendars/busy-times: should work with loggedInUsersTz parameter (backwards compatibility)`, async () => {
const mockBusyTimes = [
{
start: new Date("2024-12-18T10:00:00Z"),
end: new Date("2024-12-18T11:00:00Z"),
source: "google_calendar",
},
];
const getBusyTimesSpy = jest
.spyOn(CalendarsService.prototype, "getBusyTimes")
.mockResolvedValue(mockBusyTimes);
const response = await request(app.getHttpServer())
.get(
`/v2/calendars/busy-times?loggedInUsersTz=Europe/London&dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=${googleCalendarCredentials.id}&calendarsToLoad[0][externalId]=test@example.com`
)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeDefined();
expect(Array.isArray(response.body.data)).toBe(true);
getBusyTimesSpy.mockRestore();
});
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
const body = {
id: 10,
@@ -114,20 +114,26 @@ export class CalendarsController {
@ApiOperation({
summary: "Get busy times",
description:
"Get busy times from a calendar. Example request URL is `https://api.cal.com/v2/calendars/busy-times?loggedInUsersTz=Europe%2FMadrid&dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=135&calendarsToLoad[0][externalId]=skrauciz%40gmail.com`",
"Get busy times from a calendar. Example request URL is `https://api.cal.com/v2/calendars/busy-times?timeZone=Europe%2FMadrid&dateFrom=2024-12-18&dateTo=2024-12-18&calendarsToLoad[0][credentialId]=135&calendarsToLoad[0][externalId]=skrauciz%40gmail.com`. Note: loggedInUsersTz is deprecated, use timeZone instead.",
})
async getBusyTimes(
@Query() queryParams: CalendarBusyTimesInput,
@GetUser() user: UserWithProfile
): Promise<GetBusyTimesOutput> {
const { loggedInUsersTz, dateFrom, dateTo, calendarsToLoad } = queryParams;
const { loggedInUsersTz, timeZone, dateFrom, dateTo, calendarsToLoad } = queryParams;
const timezone = timeZone || loggedInUsersTz;
if (!timezone) {
throw new BadRequestException("Either timeZone or loggedInUsersTz must be provided");
}
const busyTimes = await this.calendarsService.getBusyTimes(
calendarsToLoad,
user.id,
dateFrom,
dateTo,
loggedInUsersTz
timezone
);
return {
@@ -205,7 +211,7 @@ export class CalendarsController {
try {
// First try to parse as JSON
stateObj = JSON.parse(state) as CalendarState;
} catch (e) {
} catch {
// If JSON parsing fails, try URL params
const stateParams = new URLSearchParams(state);
@@ -1,28 +1,7 @@
import { Transform } from "class-transformer";
import { normalizeTimezone } from "@calcom/platform-types";
export function CapitalizeTimeZone(): PropertyDecorator {
return Transform(({ value }) => {
if (typeof value === "string") {
const parts = value.split("/");
const normalizedParts = parts.map((part) =>
part
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("_")
);
let normalizedTimeZone = normalizedParts.join("/");
// note(Lauris): regex matching GMT, EST, UTC at the start, end, or surrounded by non-letters and capitalizing them
const specialCases = ["GMT", "EST", "UTC"];
specialCases.forEach((specialCase) => {
const regex = new RegExp(`(^|[^a-zA-Z])(${specialCase})([^a-zA-Z]|$)`, "gi");
normalizedTimeZone = normalizedTimeZone.replace(regex, (match, p1, p2, p3) => {
return `${p1}${specialCase}${p3}`;
});
});
return normalizedTimeZone;
}
return value;
});
return Transform(({ value }) => normalizeTimezone(value));
}
@@ -1,10 +1,45 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { Transform } from "class-transformer";
import { IsNumber, IsString, IsArray, ValidateNested, IsDateString } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
IsArray,
IsDateString,
IsNumber,
IsOptional,
IsString,
IsTimeZone,
registerDecorator,
ValidateNested,
type ValidationArguments,
type ValidationOptions,
} from "class-validator";
import { normalizeTimezone } from "../../utils/normalizeTimezone";
function ValidateTimezoneRequired(
validationOptions?: ValidationOptions
): (target: new (...args: unknown[]) => unknown) => void {
return (target: new (...args: unknown[]) => unknown) => {
registerDecorator({
name: "validateTimezoneRequired",
target: target as new (...args: unknown[]) => unknown,
propertyName: "timeZone",
options: validationOptions,
constraints: [],
validator: {
validate(_: unknown, args: ValidationArguments): boolean {
const obj = args.object as CalendarBusyTimesInput;
return !!obj.timeZone || !!obj.loggedInUsersTz;
},
defaultMessage(): string {
return "Either timeZone or loggedInUsersTz must be provided";
},
},
});
};
}
export class Calendar {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@Transform(({ value }: { value: string }) => value && parseInt(value, 10))
@IsNumber()
@ApiProperty()
credentialId!: number;
@@ -14,14 +49,28 @@ export class Calendar {
externalId!: string;
}
@ValidateTimezoneRequired()
export class CalendarBusyTimesInput {
@ApiProperty({
required: true,
description: "The timezone of the logged in user represented as a string",
required: false,
deprecated: true,
description: "Deprecated: Use timeZone instead. The timezone of the user represented as a string",
example: "America/New_York",
})
@IsOptional()
@IsString()
loggedInUsersTz!: string;
loggedInUsersTz?: string;
@ApiPropertyOptional({
required: false,
description: "The timezone for the busy times query represented as a string",
example: "America/New_York",
})
@IsOptional()
@IsString()
@IsTimeZone()
@Transform(({ value }) => normalizeTimezone(value))
timeZone?: string;
@ApiProperty({
required: false,
+1
View File
@@ -16,3 +16,4 @@ export * from "./routing-forms";
export * from "./me";
export * from "./pagination/pagination.input";
export * from "./pagination/pagination.output";
export * from "./utils/normalizeTimezone";
@@ -0,0 +1,24 @@
export function normalizeTimezone(value: unknown): string | unknown {
if (typeof value === "string") {
const parts = value.split("/");
const normalizedParts = parts.map((part) =>
part
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("_")
);
let normalizedTimeZone = normalizedParts.join("/");
// note(Lauris): regex matching GMT, EST, UTC at the start, end, or surrounded by non-letters and capitalizing them
const specialCases = ["GMT", "EST", "UTC"];
specialCases.forEach((specialCase) => {
const regex = new RegExp(`(^|[^a-zA-Z])(${specialCase})([^a-zA-Z]|$)`, "gi");
normalizedTimeZone = normalizedTimeZone.replace(regex, (match, p1, p2, p3) => {
return `${p1}${specialCase}${p3}`;
});
});
return normalizedTimeZone;
}
return value;
}