Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ics-feed calendar api-v2 #16735

Merged
merged 13 commits into from
Sep 24, 2024
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/api/v2/src/ee/calendars/calendars.interface.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
import { Request } from "express";

import { ApiResponse } from "@calcom/platform-types";
Expand All @@ -12,6 +13,16 @@ export interface CredentialSyncCalendarApp {
check(userId: number): Promise<ApiResponse>;
}

export interface ICSFeedCalendarApp {
save(
userId: number,
userEmail: string,
urls: string[],
readonly?: boolean
): Promise<CreateIcsFeedOutputResponseDto>;
check(userId: number): Promise<ApiResponse>;
}

export interface OAuthCalendarApp extends CalendarApp {
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
}
2 changes: 2 additions & 0 deletions apps/api/v2/src/ee/calendars/calendars.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CalendarsController } from "@/ee/calendars/controllers/calendars.contro
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
Expand All @@ -20,6 +21,7 @@ import { Module } from "@nestjs/common";
OutlookService,
GoogleCalendarService,
AppleCalendarService,
IcsFeedService,
SelectedCalendarsRepository,
AppsRepository,
CalendarsRepository,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateIcsFeedOutput, CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
Expand All @@ -18,6 +19,7 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
import { IcsCalendarServiceMock } from "test/mocks/ics-calendar-service-mock";

import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
Expand All @@ -27,6 +29,8 @@ import {
GOOGLE_CALENDAR_ID,
} from "@calcom/platform-constants";
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
import { ICS_CALENDAR } from "@calcom/platform-constants/apps";
import { IcsFeedCalendarService } from "@calcom/platform-libraries";

const CLIENT_REDIRECT_URI = "http://localhost:5555";

Expand All @@ -45,6 +49,7 @@ describe("Platform Calendars Endpoints", () => {
let googleCalendarCredentials: Credential;
let accessTokenSecret: string;
let refreshTokenSecret: string;
let icsCalendarCredentials: CreateIcsFeedOutput;

beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
Expand Down Expand Up @@ -192,6 +197,52 @@ describe("Platform Calendars Endpoints", () => {
.expect(200);
});

it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should fail to create a new ics feed calendar credentials with invalid urls`, async () => {
const body = {
urls: ["https://cal.com/ics/feed.ics", "https://not-an-ics-feed.com"],
readOnly: false,
};
await request(app.getHttpServer())
.post(`/v2/calendars/${ICS_CALENDAR}/save`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.send(body)
.expect(400);
});

it(`/POST/v2/calendars/${ICS_CALENDAR}/save with access token should create a new ics feed calendar credentials`, async () => {
const body = {
urls: ["https://cal.com/ics/feed.ics"],
readOnly: false,
};
jest
.spyOn(IcsFeedCalendarService.prototype, "listCalendars")
.mockImplementation(IcsCalendarServiceMock.prototype.listCalendars);
await request(app.getHttpServer())
.post(`/v2/calendars/${ICS_CALENDAR}/save`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: CreateIcsFeedOutputResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.userId).toBeDefined();
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.id).toBeDefined();
icsCalendarCredentials = responseBody.data;
});
});

it(`/GET/v2/calendars/${ICS_CALENDAR}/check with access token`, async () => {
await request(app.getHttpServer())
.get(`/v2/calendars/${ICS_CALENDAR}/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
});

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,
Expand All @@ -217,6 +268,7 @@ describe("Platform Calendars Endpoints", () => {
await teamRepositoryFixture.delete(organization.id);
await credentialsRepositoryFixture.delete(office365Credentials.id);
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
await credentialsRepositoryFixture.delete(icsCalendarCredentials.id);
await userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
Expand Down
23 changes: 22 additions & 1 deletion apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CreateIcsFeedInputDto } from "@/ee/calendars/input/create-ics.input";
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
import { DeleteCalendarCredentialsInputBodyDto } from "@/ee/calendars/input/delete-calendar-credentials.input";
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
Expand All @@ -9,6 +11,7 @@ import {
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
Expand Down Expand Up @@ -44,6 +47,7 @@ import {
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
APPLE_CALENDAR,
CREDENTIAL_CALENDARS,
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";

Expand All @@ -58,9 +62,26 @@ export class CalendarsController {
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService,
private readonly appleCalendarService: AppleCalendarService,
private readonly icsFeedService: IcsFeedService,
private readonly calendarsRepository: CalendarsRepository
) {}

@Post("/ics-feed/save")
@UseGuards(ApiAuthGuard)
async createIcsFeed(
@GetUser("id") userId: number,
@GetUser("email") userEmail: string,
@Body() body: CreateIcsFeedInputDto
): Promise<CreateIcsFeedOutputResponseDto> {
return await this.icsFeedService.save(userId, userEmail, body.urls, body.readOnly);
}

@Get("/ics-feed/check")
@UseGuards(ApiAuthGuard)
async checkIcsFeed(@GetUser("id") userId: number): Promise<ApiResponse> {
return await this.icsFeedService.check(userId);
}

@UseGuards(ApiAuthGuard)
@Get("/busy-times")
async getBusyTimes(
Expand Down Expand Up @@ -167,7 +188,7 @@ export class CalendarsController {
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
CREDENTIAL_CALENDARS.join(", ")
);
}
}
Expand Down
62 changes: 62 additions & 0 deletions apps/api/v2/src/ee/calendars/input/create-ics.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { ApiProperty } from "@nestjs/swagger";
import {
ArrayNotEmpty,
IsBoolean,
IsOptional,
Validate,
ValidatorConstraint,
ValidatorConstraintInterface,
} from "class-validator";
import { IsNotEmpty, IsArray } from "class-validator";

// Custom constraint to validate ICS URLs
@ValidatorConstraint({ async: false })
export class IsICSUrlConstraint implements ValidatorConstraintInterface {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we move this under DTO so file reads top to bottom?

validate(url: unknown) {
if (typeof url !== "string") return false;

// Check if it's a valid URL and ends with .ics
try {
const urlObject = new URL(url);
return (
(urlObject.protocol === "http:" || urlObject.protocol === "https:") &&
urlObject.pathname.endsWith(".ics")
);
} catch (error) {
return false;
}
}

defaultMessage() {
return "The URL must be a valid ICS URL (ending with .ics)";
}
}

export class CreateIcsFeedInputDto {
@ApiProperty({
example: ["https://cal.com/ics/feed.ics", "http://cal.com/ics/feed.ics"],
description: "An array of ICS URLs",
type: "array",
items: {
type: "string",
example: "https://cal.com/ics/feed.ics",
},
required: true,
})
@IsArray()
@ArrayNotEmpty()
@IsNotEmpty({ each: true })
@Validate(IsICSUrlConstraint, { each: true }) // Apply the custom validator to each element in the array
ThyMinimalDev marked this conversation as resolved.
Show resolved Hide resolved
urls!: string[];

@IsBoolean()
@ApiProperty({
example: false,
description: "Whether to allowing writing to the calendar or not",
type: "boolean",
required: false,
default: true,
})
@IsOptional()
readOnly?: boolean = true;
}
57 changes: 57 additions & 0 deletions apps/api/v2/src/ee/calendars/input/create-ics.output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum, IsInt, IsBoolean } from "class-validator";

import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";

export class CreateIcsFeedOutput {
@IsInt()
@Expose()
ThyMinimalDev marked this conversation as resolved.
Show resolved Hide resolved
@ApiProperty({ example: 1234567890, description: "The id of the calendar credential" })
readonly id!: number;

@IsString()
@Expose()
@ApiProperty({ example: "ics-feed_calendar", description: "The type of the calendar" })
readonly type!: string;

@IsInt()
@Expose()
@ApiProperty({
example: 1234567890,
description: "The user id of the user that created the calendar",
type: "integer",
})
readonly userId!: number | null;

@IsInt()
@Expose()
@ApiProperty({
example: 1234567890,
nullable: true,
description: "The team id of the user that created the calendar",
type: "integer",
})
readonly teamId!: number | null;

@IsString()
@Expose()
@ApiProperty({ example: "ics-feed", description: "The slug of the calendar" })
readonly appId!: string | null;

@IsBoolean()
@Expose()
@ApiProperty({ example: false, description: "Whether the calendar credentials are valid or not" })
readonly invalid!: boolean | null;
}

export class CreateIcsFeedOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;

@Expose()
@ValidateNested()
@Type(() => CreateIcsFeedOutput)
data!: CreateIcsFeedOutput;
}
Loading
Loading