feat: ics-feed calendar api-v2 (#16735)

* feat: ics-feed calendar api-v2

* fixup! feat: ics-feed calendar api-v2

* chore: bump platform libraries

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
This commit is contained in:
Morgan
2024-09-24 17:48:25 +05:30
committed by GitHub
co-authored by Keith Williams
parent f94494b91e
commit 9a40838fa4
14 changed files with 513 additions and 2 deletions
+1 -1
View File
@@ -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": "*",
@@ -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<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 }>>;
}
@@ -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,
@@ -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(`/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,
@@ -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();
});
@@ -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<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")
@ApiOperation({ summary: "Get busy times" })
@@ -172,7 +193,7 @@ export class CalendarsController {
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
CREDENTIAL_CALENDARS.join(", ")
);
}
}
@@ -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 {
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
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;
}
@@ -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()
@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;
}
@@ -0,0 +1,103 @@
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<CreateIcsFeedOutputResponseDto> {
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.");
}
}
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,
};
}
}
+145
View File
@@ -3622,6 +3622,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": {
"get": {
"operationId": "CalendarsController_checkIcsFeed",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Calendars"
]
}
},
"/v2/calendars/busy-times": {
"get": {
"operationId": "CalendarsController_getBusyTimes",
@@ -11017,6 +11069,99 @@
"data"
]
},
"CreateIcsFeedInputDto": {
"type": "object",
"properties": {
"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",
"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": [
"urls"
]
},
"CreateIcsFeedOutput": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 1234567890,
"description": "The id of the calendar credential"
},
"type": {
"type": "string",
"example": "ics-feed_calendar",
"description": "The type of the calendar"
},
"userId": {
"type": "integer",
"nullable": true,
"example": 1234567890,
"description": "The user id of the user that created the calendar"
},
"teamId": {
"type": "integer",
"nullable": true,
"example": 1234567890,
"description": "The team id of the user that created the calendar"
},
"appId": {
"type": "string",
"nullable": true,
"example": "ics-feed",
"description": "The slug of the calendar"
},
"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",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/CreateIcsFeedOutput"
}
},
"required": [
"status",
"data"
]
},
"BusyTimesOutput": {
"type": "object",
"properties": {
@@ -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",
@@ -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;
}[];
}
}
+2
View File
@@ -22,7 +22,9 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
process.env = {
...env,
...process.env,
// fake keys for testing
NEXT_PUBLIC_VAPID_PUBLIC_KEY:
"BIds0AQJ96xGBjTSMHTOqLBLutQE7Lu32KKdgSdy7A2cS4mKI2cgb3iGkhDJa5Siy-stezyuPm8qpbhmNxdNHMw",
VAPID_PRIVATE_KEY: "6cJtkASCar5sZWguIAW7OjvyixpBw9p8zL8WDDwk9Jk",
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
};
+5
View File
@@ -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;
+3
View File
@@ -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";
@@ -141,4 +142,6 @@ export { getTranslation };
export { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
export { ErrorCode } from "@calcom/lib/errorCodes";
export { IcsFeedCalendarService };
export { validateCustomEventName } from "@calcom/core/event";