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:
co-authored by
Ryukemeister
Rajiv Sahal
parent
c1d0a6bb1b
commit
2227d990b6
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user