From 2227d990b64be88f891978ef096659158e8cb06e Mon Sep 17 00:00:00 2001 From: Shikhar Varshney Date: Mon, 19 Jan 2026 14:52:19 +0530 Subject: [PATCH] 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 Co-authored-by: Rajiv Sahal --- .../calendars.controller.e2e-spec.ts | 69 +++++++++++++++++++ .../controllers/calendars.controller.ts | 14 ++-- .../v2/src/lib/inputs/capitalize-timezone.ts | 27 +------- .../calendars/inputs/busy-times.input.ts | 65 ++++++++++++++--- packages/platform/types/index.ts | 1 + .../platform/types/utils/normalizeTimezone.ts | 24 +++++++ 6 files changed, 164 insertions(+), 36 deletions(-) create mode 100644 packages/platform/types/utils/normalizeTimezone.ts diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.e2e-spec.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.e2e-spec.ts index 534176bfa5..30b6800773 100644 --- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.e2e-spec.ts +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.e2e-spec.ts @@ -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, diff --git a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts index cd17f220bf..6739d40aec 100644 --- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -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 { - 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); diff --git a/apps/api/v2/src/lib/inputs/capitalize-timezone.ts b/apps/api/v2/src/lib/inputs/capitalize-timezone.ts index c94a9dc4c4..fb933e4f34 100644 --- a/apps/api/v2/src/lib/inputs/capitalize-timezone.ts +++ b/apps/api/v2/src/lib/inputs/capitalize-timezone.ts @@ -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)); } diff --git a/packages/platform/types/calendars/inputs/busy-times.input.ts b/packages/platform/types/calendars/inputs/busy-times.input.ts index 4feb14ae62..d25b05da21 100644 --- a/packages/platform/types/calendars/inputs/busy-times.input.ts +++ b/packages/platform/types/calendars/inputs/busy-times.input.ts @@ -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, diff --git a/packages/platform/types/index.ts b/packages/platform/types/index.ts index 002c4ec17a..46ff3dfd26 100644 --- a/packages/platform/types/index.ts +++ b/packages/platform/types/index.ts @@ -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"; diff --git a/packages/platform/types/utils/normalizeTimezone.ts b/packages/platform/types/utils/normalizeTimezone.ts new file mode 100644 index 0000000000..70534e6955 --- /dev/null +++ b/packages/platform/types/utils/normalizeTimezone.ts @@ -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; +}