From 1a4a156d9442aec0dfa844dbec80add58fd440d8 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Fri, 20 Sep 2024 10:03:23 +0300 Subject: [PATCH 1/5] feat: ics-feed calendar api-v2 --- .../src/ee/calendars/calendars.interface.ts | 11 ++ .../v2/src/ee/calendars/calendars.module.ts | 2 + .../calendars.controller.e2e-spec.ts | 52 ++++++++ .../controllers/calendars.controller.ts | 23 +++- .../ee/calendars/input/create-ics.input.ts | 52 ++++++++ .../ee/calendars/input/create-ics.output.ts | 42 ++++++ .../ee/calendars/services/ics-feed.service.ts | 116 +++++++++++++++++ apps/api/v2/swagger/documentation.json | 122 ++++++++++++++++++ .../v2/test/mocks/calendars-service-mock.ts | 21 +++ .../test/mocks/ics-calendar-service-mock.ts | 27 ++++ packages/platform/constants/apps.ts | 5 + packages/platform/libraries/index.ts | 3 + 12 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 apps/api/v2/src/ee/calendars/input/create-ics.input.ts create mode 100644 apps/api/v2/src/ee/calendars/input/create-ics.output.ts create mode 100644 apps/api/v2/src/ee/calendars/services/ics-feed.service.ts create mode 100644 apps/api/v2/test/mocks/ics-calendar-service-mock.ts diff --git a/apps/api/v2/src/ee/calendars/calendars.interface.ts b/apps/api/v2/src/ee/calendars/calendars.interface.ts index 9767e7ee814216..03fca182dd6ccd 100644 --- a/apps/api/v2/src/ee/calendars/calendars.interface.ts +++ b/apps/api/v2/src/ee/calendars/calendars.interface.ts @@ -1,3 +1,4 @@ +import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output"; import { Request } from "express"; import { ApiResponse } from "@calcom/platform-types"; @@ -12,6 +13,16 @@ export interface CredentialSyncCalendarApp { check(userId: number): Promise; } +export interface ICSFeedCalendarApp { + save( + userId: number, + userEmail: string, + urls: string[], + readonly?: boolean + ): Promise; + check(userId: number): Promise; +} + export interface OAuthCalendarApp extends CalendarApp { connect(authorization: string, req: Request): Promise>; } diff --git a/apps/api/v2/src/ee/calendars/calendars.module.ts b/apps/api/v2/src/ee/calendars/calendars.module.ts index 65fa4253e7d66e..09c367369fe818 100644 --- a/apps/api/v2/src/ee/calendars/calendars.module.ts +++ b/apps/api/v2/src/ee/calendars/calendars.module.ts @@ -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"; @@ -20,6 +21,7 @@ import { Module } from "@nestjs/common"; OutlookService, GoogleCalendarService, AppleCalendarService, + IcsFeedService, SelectedCalendarsRepository, AppsRepository, CalendarsRepository, 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 5c1ab8ff0639a7..558676f545af63 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 @@ -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"; @@ -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 { @@ -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"; @@ -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({ @@ -192,6 +197,52 @@ describe("Platform Calendars Endpoints", () => { .expect(200); }); + it(`/GET/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"], + read_only: 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(`/GET/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"], + read_only: 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, @@ -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(); }); 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 0401fc54a99422..b4a0056d8aedef 100644 --- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -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"; @@ -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"; @@ -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"; @@ -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 { + return await this.icsFeedService.save(userId, userEmail, body.urls, body.read_only); + } + + @Get("/ics-feed/check") + @UseGuards(ApiAuthGuard) + async CheckIcsFeed(@GetUser("id") userId: number): Promise { + return await this.icsFeedService.check(userId); + } + @UseGuards(ApiAuthGuard) @Get("/busy-times") async getBusyTimes( @@ -167,7 +188,7 @@ export class CalendarsController { default: throw new BadRequestException( "Invalid calendar type, available calendars are: ", - CALENDARS.join(", ") + CREDENTIAL_CALENDARS.join(", ") ); } } diff --git a/apps/api/v2/src/ee/calendars/input/create-ics.input.ts b/apps/api/v2/src/ee/calendars/input/create-ics.input.ts new file mode 100644 index 00000000000000..b4e5821e696421 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/input/create-ics.input.ts @@ -0,0 +1,52 @@ +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 { + 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 { + @IsArray() + @ArrayNotEmpty() + @IsNotEmpty({ each: true }) + @Validate(IsICSUrlConstraint, { each: true }) // Apply the custom validator to each element in the array + urls!: string[]; + + @IsBoolean() + @ApiProperty({ + example: false, + description: "Whether to allowing writing to the calendar or not", + type: "boolean", + required: false, + default: true, + }) + @IsOptional() + read_only?: boolean = true; +} diff --git a/apps/api/v2/src/ee/calendars/input/create-ics.output.ts b/apps/api/v2/src/ee/calendars/input/create-ics.output.ts new file mode 100644 index 00000000000000..b170e0fe1f950e --- /dev/null +++ b/apps/api/v2/src/ee/calendars/input/create-ics.output.ts @@ -0,0 +1,42 @@ +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() + readonly id!: number; + + @IsString() + @Expose() + readonly type!: string; + + @IsInt() + @Expose() + readonly userId!: number | null; + + @IsInt() + @Expose() + readonly teamId!: number | null; + + @IsString() + @Expose() + readonly appId!: string | null; + + @IsBoolean() + @Expose() + 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; +} diff --git a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts new file mode 100644 index 00000000000000..0e505706e15779 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts @@ -0,0 +1,116 @@ +import { ICSFeedCalendarApp } from "@/ee/calendars/calendars.interface"; +import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { BadRequestException, UnauthorizedException, Logger } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; + +import { SUCCESS_STATUS, ICS_CALENDAR_TYPE, ICS_CALENDAR } from "@calcom/platform-constants"; +import { symmetricEncrypt, IcsFeedCalendarService } from "@calcom/platform-libraries"; + +@Injectable() +export class IcsFeedService implements ICSFeedCalendarApp { + constructor( + private readonly calendarsService: CalendarsService, + private readonly credentialRepository: CredentialsRepository + ) {} + + private logger = new Logger("IcsFeedService"); + + async save( + userId: number, + userEmail: string, + urls: string[], + readonly = true + ): Promise { + return await this.saveCalendarCredentials(userId, userEmail, urls, readonly); + } + + async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + return await this.checkIfCalendarConnected(userId); + } + + async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId); + + if (!icsFeedCredentials) { + throw new BadRequestException("Credentials for Ics Feed calendar not found."); + } + + if (icsFeedCredentials.invalid) { + throw new BadRequestException("Invalid Ics Feed credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const icsCalendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === ICS_CALENDAR_TYPE + ); + + if (!icsCalendar) { + throw new UnauthorizedException("Ics Feed not connected."); + } + if (icsCalendar.error?.message) { + throw new UnauthorizedException(icsCalendar.error?.message); + } + + return { + status: SUCCESS_STATUS, + }; + } + + async saveCalendarCredentials( + userId: number, + userEmail: string, + urls: string[], + readonly = true + ): Promise { + const data = { + type: ICS_CALENDAR_TYPE, + ICS_CALENDAR, + key: symmetricEncrypt( + JSON.stringify({ urls, skipWriting: readonly }), + process.env.CALENDSO_ENCRYPTION_KEY || "" + ), + userId: userId, + teamId: null, + appId: ICS_CALENDAR, + invalid: false, + }; + + try { + const dav = new IcsFeedCalendarService({ + id: 0, + ...data, + user: { email: userEmail }, + }); + + const listedCals = await dav.listCalendars(); + + if (listedCals.length !== urls.length) { + throw new BadRequestException( + `Listed cals and URLs mismatch: ${listedCals.length} vs. ${urls.length}` + ); + } + + const credential = await this.credentialRepository.upsertAppCredential( + ICS_CALENDAR_TYPE, + data.key, + userId + ); + return { + status: SUCCESS_STATUS, + data: { + id: credential.id, + type: credential.type, + userId: credential.userId, + teamId: credential.teamId, + appId: credential.appId, + invalid: credential.invalid, + }, + }; + } catch (e) { + this.logger.error("Could not add ICS feeds", e); + throw new BadRequestException("Could not add ICS feeds, try using private ics feed."); + } + } +} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 6fd37c188b3c3e..a54902ee51f2f8 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -3518,6 +3518,58 @@ ] } }, + "/v2/calendars/ics-feed/save": { + "post": { + "operationId": "CalendarsController_createIcsFeed", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIcsFeedInputDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateIcsFeedOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/ics-feed/check": { + "post": { + "operationId": "CalendarsController_CheckIcsFeed", + "parameters": [], + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, "/v2/calendars/busy-times": { "get": { "operationId": "CalendarsController_getBusyTimes", @@ -10600,6 +10652,76 @@ "data" ] }, + "CreateIcsFeedInputDto": { + "type": "object", + "properties": { + "read_only": { + "type": "boolean", + "default": true, + "example": false, + "description": "Whether to allowing writing to the calendar or not" + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "urls" + ] + }, + "CreateIcsFeedOutput": { + "type": "object", + "properties": { + "primary": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "readOnly": { + "type": "boolean" + }, + "email": { + "type": "string" + }, + "primaryEmail": { + "type": "string" + }, + "credentialId": { + "type": "number", + "nullable": true + }, + "integrationTitle": { + "type": "string" + } + } + }, + "CreateIcsFeedOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreateIcsFeedOutput" + } + } + }, + "required": [ + "status", + "data" + ] + }, "BusyTimesOutput": { "type": "object", "properties": { diff --git a/apps/api/v2/test/mocks/calendars-service-mock.ts b/apps/api/v2/test/mocks/calendars-service-mock.ts index 9730237aa5f38d..85dd5a0f81a668 100644 --- a/apps/api/v2/test/mocks/calendars-service-mock.ts +++ b/apps/api/v2/test/mocks/calendars-service-mock.ts @@ -1,5 +1,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { ICS_CALENDAR_ID, ICS_CALENDAR_TYPE } from "@calcom/platform-constants"; + export class CalendarsServiceMock { async getCalendars() { return { @@ -42,6 +44,25 @@ export class CalendarsServiceMock { credentialId: 2, error: { message: "" }, }, + { + integration: { + installed: false, + type: ICS_CALENDAR_TYPE, + title: "ics-feed_calendar", + name: "ics-feed_calendar", + description: "", + variant: "calendar", + slug: ICS_CALENDAR_ID, + locationOption: null, + categories: ["calendar"], + logo: "", + publisher: "", + url: "", + email: "", + }, + credentialId: 2, + error: { message: "" }, + }, ], destinationCalendar: { name: "destinationCalendar", diff --git a/apps/api/v2/test/mocks/ics-calendar-service-mock.ts b/apps/api/v2/test/mocks/ics-calendar-service-mock.ts new file mode 100644 index 00000000000000..6c9ddcfdd15326 --- /dev/null +++ b/apps/api/v2/test/mocks/ics-calendar-service-mock.ts @@ -0,0 +1,27 @@ +export class IcsCalendarServiceMock { + async listCalendars() { + return [ + { + name: "name", + readOnly: true, + externalId: "externalId", + integrationName: "ics-feed_calendar", + primary: true, + email: "email", + primaryEmail: "primaryEmail", + credentialId: 1, + integrationTitle: "integrationTitle", + }, + ] satisfies { + primary?: boolean; + name?: string; + readOnly?: boolean; + email?: string; + primaryEmail?: string; + credentialId?: number | null; + integrationTitle?: string; + externalId?: string; + integrationName?: string; + }[]; + } +} diff --git a/packages/platform/constants/apps.ts b/packages/platform/constants/apps.ts index 8872c142c9bf92..8469988d89e0bc 100644 --- a/packages/platform/constants/apps.ts +++ b/packages/platform/constants/apps.ts @@ -5,12 +5,17 @@ export const OFFICE_365_CALENDAR_ID = "office365-calendar"; export const GOOGLE_CALENDAR = "google"; export const OFFICE_365_CALENDAR = "office365"; export const APPLE_CALENDAR = "apple"; +export const ICS_CALENDAR = "ics-feed"; +export const ICS_CALENDAR_ID = "ics-feed"; export const APPLE_CALENDAR_TYPE = "apple_calendar"; +export const ICS_CALENDAR_TYPE = "ics-feed_calendar"; export const APPLE_CALENDAR_ID = "apple-calendar"; export const CALENDARS = [GOOGLE_CALENDAR, OFFICE_365_CALENDAR, APPLE_CALENDAR] as const; +export const CREDENTIAL_CALENDARS = [APPLE_CALENDAR] as const; export const APPS_TYPE_ID_MAPPING = { [GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR_ID, [OFFICE_365_CALENDAR_TYPE]: OFFICE_365_CALENDAR_ID, [APPLE_CALENDAR_TYPE]: APPLE_CALENDAR_ID, + [ICS_CALENDAR_TYPE]: ICS_CALENDAR_ID, } as const; diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 3dab22821d6b36..4c32e8bf8ffd44 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -1,5 +1,6 @@ import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { CalendarService } from "@calcom/app-store/applecalendar/lib"; +import { CalendarService as IcsFeedCalendarService } from "@calcom/app-store/ics-feedcalendar/lib"; import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking"; import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo"; import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking"; @@ -133,3 +134,5 @@ export { getTranslation }; export { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries"; export { ErrorCode } from "@calcom/lib/errorCodes"; + +export { IcsFeedCalendarService }; From e0b576e24cb48d0e75d48845dec8d1a891c21089 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Fri, 20 Sep 2024 11:53:17 +0300 Subject: [PATCH 2/5] fixup! feat: ics-feed calendar api-v2 --- .../calendars.controller.e2e-spec.ts | 8 +- .../controllers/calendars.controller.ts | 4 +- .../ee/calendars/input/create-ics.input.ts | 16 +++- .../ee/calendars/input/create-ics.output.ts | 15 ++++ .../ee/calendars/services/ics-feed.service.ts | 69 ++++++--------- apps/api/v2/swagger/documentation.json | 85 ++++++++++++------- 6 files changed, 116 insertions(+), 81 deletions(-) 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 558676f545af63..1ad339e1e9e0ab 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 @@ -197,10 +197,10 @@ describe("Platform Calendars Endpoints", () => { .expect(200); }); - it(`/GET/v2/calendars/${ICS_CALENDAR}/save with access token should fail to create a new ics feed calendar credentials with invalid urls`, async () => { + 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"], - read_only: false, + readOnly: false, }; await request(app.getHttpServer()) .post(`/v2/calendars/${ICS_CALENDAR}/save`) @@ -210,10 +210,10 @@ describe("Platform Calendars Endpoints", () => { .expect(400); }); - it(`/GET/v2/calendars/${ICS_CALENDAR}/save with access token should create a new ics feed calendar credentials`, async () => { + 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"], - read_only: false, + readOnly: false, }; jest .spyOn(IcsFeedCalendarService.prototype, "listCalendars") 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 b4a0056d8aedef..a05d59fa653cad 100644 --- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -73,12 +73,12 @@ export class CalendarsController { @GetUser("email") userEmail: string, @Body() body: CreateIcsFeedInputDto ): Promise { - return await this.icsFeedService.save(userId, userEmail, body.urls, body.read_only); + return await this.icsFeedService.save(userId, userEmail, body.urls, body.readOnly); } @Get("/ics-feed/check") @UseGuards(ApiAuthGuard) - async CheckIcsFeed(@GetUser("id") userId: number): Promise { + async checkIcsFeed(@GetUser("id") userId: number): Promise { return await this.icsFeedService.check(userId); } diff --git a/apps/api/v2/src/ee/calendars/input/create-ics.input.ts b/apps/api/v2/src/ee/calendars/input/create-ics.input.ts index b4e5821e696421..17e9893c0316ad 100644 --- a/apps/api/v2/src/ee/calendars/input/create-ics.input.ts +++ b/apps/api/v2/src/ee/calendars/input/create-ics.input.ts @@ -19,8 +19,8 @@ export class IsICSUrlConstraint implements ValidatorConstraintInterface { try { const urlObject = new URL(url); return ( - urlObject.protocol === "http:" || - (urlObject.protocol === "https:" && urlObject.pathname.endsWith(".ics")) + (urlObject.protocol === "http:" || urlObject.protocol === "https:") && + urlObject.pathname.endsWith(".ics") ); } catch (error) { return false; @@ -33,6 +33,16 @@ export class IsICSUrlConstraint implements ValidatorConstraintInterface { } 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 }) @@ -48,5 +58,5 @@ export class CreateIcsFeedInputDto { default: true, }) @IsOptional() - read_only?: boolean = true; + readOnly?: boolean = true; } diff --git a/apps/api/v2/src/ee/calendars/input/create-ics.output.ts b/apps/api/v2/src/ee/calendars/input/create-ics.output.ts index b170e0fe1f950e..6f9de1c3002020 100644 --- a/apps/api/v2/src/ee/calendars/input/create-ics.output.ts +++ b/apps/api/v2/src/ee/calendars/input/create-ics.output.ts @@ -7,26 +7,41 @@ import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; export class CreateIcsFeedOutput { @IsInt() @Expose() + @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; } diff --git a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts index 0e505706e15779..c18da622bc2e1b 100644 --- a/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts +++ b/apps/api/v2/src/ee/calendars/services/ics-feed.service.ts @@ -22,47 +22,6 @@ export class IcsFeedService implements ICSFeedCalendarApp { userEmail: string, urls: string[], readonly = true - ): Promise { - return await this.saveCalendarCredentials(userId, userEmail, urls, readonly); - } - - async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { - return await this.checkIfCalendarConnected(userId); - } - - async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { - const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId); - - if (!icsFeedCredentials) { - throw new BadRequestException("Credentials for Ics Feed calendar not found."); - } - - if (icsFeedCredentials.invalid) { - throw new BadRequestException("Invalid Ics Feed credentials."); - } - - const { connectedCalendars } = await this.calendarsService.getCalendars(userId); - const icsCalendar = connectedCalendars.find( - (cal: { integration: { type: string } }) => cal.integration.type === ICS_CALENDAR_TYPE - ); - - if (!icsCalendar) { - throw new UnauthorizedException("Ics Feed not connected."); - } - if (icsCalendar.error?.message) { - throw new UnauthorizedException(icsCalendar.error?.message); - } - - return { - status: SUCCESS_STATUS, - }; - } - - async saveCalendarCredentials( - userId: number, - userEmail: string, - urls: string[], - readonly = true ): Promise { const data = { type: ICS_CALENDAR_TYPE, @@ -113,4 +72,32 @@ export class IcsFeedService implements ICSFeedCalendarApp { throw new BadRequestException("Could not add ICS feeds, try using private ics feed."); } } + + async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId); + + if (!icsFeedCredentials) { + throw new BadRequestException("Credentials for Ics Feed calendar not found."); + } + + if (icsFeedCredentials.invalid) { + throw new BadRequestException("Invalid Ics Feed credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const icsCalendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === ICS_CALENDAR_TYPE + ); + + if (!icsCalendar) { + throw new UnauthorizedException("Ics Feed not connected."); + } + if (icsCalendar.error?.message) { + throw new UnauthorizedException(icsCalendar.error?.message); + } + + return { + status: SUCCESS_STATUS, + }; + } } diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index a54902ee51f2f8..4bca584cf7601d 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -3550,11 +3550,11 @@ } }, "/v2/calendars/ics-feed/check": { - "post": { - "operationId": "CalendarsController_CheckIcsFeed", + "get": { + "operationId": "CalendarsController_checkIcsFeed", "parameters": [], "responses": { - "201": { + "200": { "description": "", "content": { "application/json": { @@ -10655,17 +10655,23 @@ "CreateIcsFeedInputDto": { "type": "object", "properties": { - "read_only": { - "type": "boolean", - "default": true, - "example": false, - "description": "Whether to allowing writing to the calendar or not" - }, "urls": { "type": "array", + "example": [ + "https://cal.com/ics/feed.ics", + "http://cal.com/ics/feed.ics" + ], + "description": "An array of ICS URLs", "items": { - "type": "string" + "type": "string", + "example": "https://cal.com/ics/feed.ics" } + }, + "readOnly": { + "type": "boolean", + "default": true, + "example": false, + "description": "Whether to allowing writing to the calendar or not" } }, "required": [ @@ -10675,29 +10681,49 @@ "CreateIcsFeedOutput": { "type": "object", "properties": { - "primary": { - "type": "boolean" - }, - "name": { - "type": "string" + "id": { + "type": "number", + "example": 1234567890, + "description": "The id of the calendar credential" }, - "readOnly": { - "type": "boolean" + "type": { + "type": "string", + "example": "ics-feed_calendar", + "description": "The type of the calendar" }, - "email": { - "type": "string" + "userId": { + "type": "integer", + "nullable": true, + "example": 1234567890, + "description": "The user id of the user that created the calendar" }, - "primaryEmail": { - "type": "string" + "teamId": { + "type": "integer", + "nullable": true, + "example": 1234567890, + "description": "The team id of the user that created the calendar" }, - "credentialId": { - "type": "number", - "nullable": true + "appId": { + "type": "string", + "nullable": true, + "example": "ics-feed", + "description": "The slug of the calendar" }, - "integrationTitle": { - "type": "string" + "invalid": { + "type": "boolean", + "nullable": true, + "example": false, + "description": "Whether the calendar credentials are valid or not" } - } + }, + "required": [ + "id", + "type", + "userId", + "teamId", + "appId", + "invalid" + ] }, "CreateIcsFeedOutputResponseDto": { "type": "object", @@ -10711,10 +10737,7 @@ ] }, "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CreateIcsFeedOutput" - } + "$ref": "#/components/schemas/CreateIcsFeedOutput" } }, "required": [ From 3c1adb7061eeb5f5180a9860d1a9c54ced939a12 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Tue, 24 Sep 2024 13:41:30 +0300 Subject: [PATCH 3/5] chore: bump platform libraries --- apps/api/v2/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 94c876b7b9a44e..704c089eb254fa 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -28,7 +28,7 @@ "dependencies": { "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.39", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.40", "@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", From 66b6b1fb6ec29cfdeea4eed2ed1f7521604dfb8f Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Tue, 24 Sep 2024 14:46:09 +0300 Subject: [PATCH 4/5] fixup! Merge branch 'main' into feat-ics-feed-api-v2 --- apps/api/v2/test/setEnvVars.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts index 102a16fc138dd6..96db5269165174 100644 --- a/apps/api/v2/test/setEnvVars.ts +++ b/apps/api/v2/test/setEnvVars.ts @@ -25,4 +25,5 @@ process.env = { NEXT_PUBLIC_VAPID_PUBLIC_KEY: "BIds0AQJ96xGBjTSMHTOqLBLutQE7Lu32KKdgSdy7A2cS4mKI2cgb3iGkhDJa5Siy-stezyuPm8qpbhmNxdNHMw", VAPID_PRIVATE_KEY: "6cJtkASCar5sZWguIAW7OjvyixpBw9p8zL8WDDwk9Jk", + CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX", }; From a64fa8854a746f5f5cbf93f55decdbf15ad0ea87 Mon Sep 17 00:00:00 2001 From: Morgan Vernay Date: Tue, 24 Sep 2024 14:48:01 +0300 Subject: [PATCH 5/5] fixup! fixup! Merge branch 'main' into feat-ics-feed-api-v2 --- apps/api/v2/test/setEnvVars.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts index 96db5269165174..1f50272d845ccf 100644 --- a/apps/api/v2/test/setEnvVars.ts +++ b/apps/api/v2/test/setEnvVars.ts @@ -22,6 +22,7 @@ const env: Partial> = { process.env = { ...env, ...process.env, + // fake keys for testing NEXT_PUBLIC_VAPID_PUBLIC_KEY: "BIds0AQJ96xGBjTSMHTOqLBLutQE7Lu32KKdgSdy7A2cS4mKI2cgb3iGkhDJa5Siy-stezyuPm8qpbhmNxdNHMw", VAPID_PRIVATE_KEY: "6cJtkASCar5sZWguIAW7OjvyixpBw9p8zL8WDDwk9Jk",