From 9770aff153b2b3d4b87b4ff655bffb4d7d516bb9 Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Mon, 3 Jun 2024 17:17:57 +0530 Subject: [PATCH] feat: outlook 365 calendar endpoints (#15077) * init endpoints for office 365 syncing * get web app url for oauth * update typings * nethods for office 365 calendar * update office 365 connect endpoint * update packages * add calendar type and if office 365 calendar * add SelectedCalendarsRepository and TokensModule * add functions for getting oauth credentials, getting default calendars and redirect url for office 365 calendar * update controllers * e2e tests for office 365 calendar endpoints * fixup! Merge branch 'main' into rajiv/cal-3544-outlookoffice365-integration-connect-atom * fixup! fixup! Merge branch 'main' into rajiv/cal-3544-outlookoffice365-integration-connect-atom * fix merge conflict * update typings * abstract microsoft outlook into its own module * add one common interface for calendar apps * make calendars dynamic * abstract every calendar app into its own service * cleanup * make all calendar endpoints dynamic * calendar app constants * cleanup * remove unused repositories * rename microsoft calendar type * fix typings * add google and outlook calendar service to calendars module * fixup * some more e2e tests including google calendar * chore: small type change and apple calendar not implemented yet --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Morgan Vernay Co-authored-by: Rajiv Sahal --- apps/api/v2/package.json | 2 + .../calendars.controller.e2e-spec.ts | 211 ++++++++++++++++++ .../src/ee/calendars/calendars.interface.ts | 12 + .../v2/src/ee/calendars/calendars.module.ts | 16 +- .../controllers/calendars.controller.ts | 99 +++++++- .../calendars/services/calendars.service.ts | 29 ++- .../src/ee/calendars/services/gcal.service.ts | 156 +++++++++++++ .../ee/calendars/services/outlook.service.ts | 180 +++++++++++++++ apps/api/v2/swagger/documentation.json | 107 +++++++++ packages/platform/constants/apps.ts | 8 + 10 files changed, 812 insertions(+), 8 deletions(-) create mode 100644 apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/ee/calendars/calendars.interface.ts create mode 100644 apps/api/v2/src/ee/calendars/services/gcal.service.ts create mode 100644 apps/api/v2/src/ee/calendars/services/outlook.service.ts diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index a42ec119ae..da0cde4891 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -30,6 +30,7 @@ "@calcom/platform-utils": "*", "@calcom/prisma": "*", "@golevelup/ts-jest": "^0.4.0", + "@microsoft/microsoft-graph-types-beta": "^0.42.0-preview", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", @@ -55,6 +56,7 @@ "next-auth": "^4.22.1", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "querystring": "^0.2.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "stripe": "^15.3.0", diff --git a/apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts b/apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts new file mode 100644 index 0000000000..26b4305f43 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts @@ -0,0 +1,211 @@ +import { bootstrap } from "@/app"; +import { AppModule } from "@/app.module"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { HttpExceptionFilter } from "@/filters/http-exception.filter"; +import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import { PlatformOAuthClient, Team, User, Credential } from "@prisma/client"; +import * as request from "supertest"; +import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture"; +import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; +import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; +import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; + +import { + GOOGLE_CALENDAR, + OFFICE_365_CALENDAR, + GOOGLE_CALENDAR_TYPE, + GOOGLE_CALENDAR_ID, +} from "@calcom/platform-constants"; +import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants"; + +const CLIENT_REDIRECT_URI = "http://localhost:5555"; + +class CalendarsServiceMock { + async getCalendars() { + return { + connectedCalendars: [ + { + integration: { + type: "google_calendar", + }, + }, + ], + }; + } +} + +describe("Platform Calendars Endpoints", () => { + let app: INestApplication; + + let oAuthClient: PlatformOAuthClient; + let organization: Team; + let userRepositoryFixture: UserRepositoryFixture; + let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; + let teamRepositoryFixture: TeamRepositoryFixture; + let tokensRepositoryFixture: TokensRepositoryFixture; + let credentialsRepositoryFixture: CredentialsRepositoryFixture; + let user: User; + let office365Credentials: Credential; + let googleCalendarCredentials: Credential; + let accessTokenSecret: string; + let refreshTokenSecret: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + providers: [PrismaExceptionFilter, HttpExceptionFilter], + imports: [AppModule, UsersModule, TokensModule], + }) + .overrideGuard(PermissionsGuard) + .useValue({ + canActivate: () => true, + }) + .overrideProvider(CalendarsService) + .useClass(CalendarsServiceMock) + .compile(); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef); + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); + tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef); + credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef); + organization = await teamRepositoryFixture.create({ name: "organization" }); + oAuthClient = await createOAuthClient(organization.id); + user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id); + const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id); + accessTokenSecret = tokens.accessToken; + refreshTokenSecret = tokens.refreshToken; + await app.init(); + }); + + async function createOAuthClient(organizationId: number) { + const data = { + logo: "logo-url", + name: "name", + redirectUris: [CLIENT_REDIRECT_URI], + permissions: 32, + }; + const secret = "secret"; + + const client = await oauthClientRepositoryFixture.create(organizationId, data, secret); + return client; + } + + it("should be defined", () => { + expect(oauthClientRepositoryFixture).toBeDefined(); + expect(userRepositoryFixture).toBeDefined(); + expect(oAuthClient).toBeDefined(); + expect(accessTokenSecret).toBeDefined(); + expect(refreshTokenSecret).toBeDefined(); + expect(user).toBeDefined(); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should respond 401 with invalid access token`, async () => { + await request(app.getHttpServer()) + .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`) + .set("Authorization", `Bearer invalid_access_token`) + .expect(401); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => { + const response = await request(app.getHttpServer()) + .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + const data = response.body.data; + expect(data.authUrl).toBeDefined(); + }); + + it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => { + const response = await request(app.getHttpServer()) + .get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + const data = response.body.data; + expect(data.authUrl).toBeDefined(); + }); + + it(`/GET/v2/calendars/random-calendar/connect: it should respond 400 with a message saying the calendar type is invalid`, async () => { + await request(app.getHttpServer()) + .get(`/v2/calendars/random-calendar/connect`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(400); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without access token`, async () => { + await request(app.getHttpServer()) + .get( + `/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access` + ) + .expect(400); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without origin`, async () => { + await request(app.getHttpServer()) + .get( + `/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access` + ) + .expect(400); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check without access token`, async () => { + await request(app.getHttpServer()).get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`).expect(401); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with no credentials`, async () => { + await request(app.getHttpServer()) + .get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(400); + }); + + it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with access token, origin and office365 credentials`, async () => { + office365Credentials = await credentialsRepositoryFixture.create( + OFFICE_365_CALENDAR_TYPE, + {}, + user.id, + OFFICE_365_CALENDAR_ID + ); + await request(app.getHttpServer()) + .get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + }); + + it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/check with access token, origin and google calendar credentials`, async () => { + googleCalendarCredentials = await credentialsRepositoryFixture.create( + GOOGLE_CALENDAR_TYPE, + {}, + user.id, + GOOGLE_CALENDAR_ID + ); + await request(app.getHttpServer()) + .get(`/v2/calendars/${GOOGLE_CALENDAR}/check`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .set("Origin", CLIENT_REDIRECT_URI) + .expect(200); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await credentialsRepositoryFixture.delete(office365Credentials.id); + await credentialsRepositoryFixture.delete(googleCalendarCredentials.id); + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/ee/calendars/calendars.interface.ts b/apps/api/v2/src/ee/calendars/calendars.interface.ts new file mode 100644 index 0000000000..4ea4ec3b69 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.interface.ts @@ -0,0 +1,12 @@ +import { Request } from "express"; + +import { ApiResponse } from "@calcom/platform-types"; + +export interface CalendarApp { + save(state: string, code: string, origin: string): Promise<{ url: string }>; + 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 fe6b382605..17e39fc9b2 100644 --- a/apps/api/v2/src/ee/calendars/calendars.module.ts +++ b/apps/api/v2/src/ee/calendars/calendars.module.ts @@ -1,13 +1,25 @@ import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller"; import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service"; +import { OutlookService } from "@/ee/calendars/services/outlook.service"; +import { AppsRepository } from "@/modules/apps/apps.repository"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensModule } from "@/modules/tokens/tokens.module"; import { UsersModule } from "@/modules/users/users.module"; import { Module } from "@nestjs/common"; @Module({ - imports: [PrismaModule, UsersModule], - providers: [CredentialsRepository, CalendarsService], + imports: [PrismaModule, UsersModule, TokensModule], + providers: [ + CredentialsRepository, + CalendarsService, + OutlookService, + GoogleCalendarService, + SelectedCalendarsRepository, + AppsRepository, + ], controllers: [CalendarsController], exports: [CalendarsService], }) 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 4a1e705672..2cfb9b4de9 100644 --- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -1,24 +1,47 @@ import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output"; import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output"; import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service"; +import { OutlookService } from "@/ee/calendars/services/outlook.service"; import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator"; import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard"; +import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard"; import { UserWithProfile } from "@/modules/users/users.repository"; -import { Controller, Get, UseGuards, Query } from "@nestjs/common"; +import { + Controller, + Get, + UseGuards, + Query, + HttpStatus, + HttpCode, + Req, + Param, + Headers, + Redirect, + BadRequestException, +} from "@nestjs/common"; import { ApiTags as DocsTags } from "@nestjs/swagger"; +import { Request } from "express"; +import { z } from "zod"; -import { SUCCESS_STATUS } from "@calcom/platform-constants"; -import { CalendarBusyTimesInput } from "@calcom/platform-types"; +import { APPS_READ } from "@calcom/platform-constants"; +import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants"; +import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types"; @Controller({ path: "/calendars", version: "2", }) -@UseGuards(AccessTokenGuard) @DocsTags("Calendars") export class CalendarsController { - constructor(private readonly calendarsService: CalendarsService) {} + constructor( + private readonly calendarsService: CalendarsService, + private readonly outlookService: OutlookService, + private readonly googleCalendarService: GoogleCalendarService + ) {} + @UseGuards(AccessTokenGuard) @Get("/busy-times") async getBusyTimes( @Query() queryParams: CalendarBusyTimesInput, @@ -55,4 +78,70 @@ export class CalendarsController { data: calendars, }; } + + @UseGuards(AccessTokenGuard) + @Get("/:calendar/connect") + @HttpCode(HttpStatus.OK) + async redirect( + @Req() req: Request, + @Headers("Authorization") authorization: string, + @Param("calendar") calendar: string + ): Promise> { + switch (calendar) { + case OFFICE_365_CALENDAR: + return await this.outlookService.connect(authorization, req); + case GOOGLE_CALENDAR: + return await this.googleCalendarService.connect(authorization, req); + default: + throw new BadRequestException( + "Invalid calendar type, available calendars are: ", + CALENDARS.join(", ") + ); + } + } + + @Get("/:calendar/save") + @HttpCode(HttpStatus.OK) + @Redirect(undefined, 301) + async save( + @Query("state") state: string, + @Query("code") code: string, + @Param("calendar") calendar: string + ): Promise<{ url: string }> { + // state params contains our user access token + const stateParams = new URLSearchParams(state); + const { accessToken, origin } = z + .object({ accessToken: z.string(), origin: z.string() }) + .parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") }); + + switch (calendar) { + case OFFICE_365_CALENDAR: + return await this.outlookService.save(code, accessToken, origin); + case GOOGLE_CALENDAR: + return await this.googleCalendarService.save(code, accessToken, origin); + default: + throw new BadRequestException( + "Invalid calendar type, available calendars are: ", + CALENDARS.join(", ") + ); + } + } + + @Get("/:calendar/check") + @HttpCode(HttpStatus.OK) + @UseGuards(AccessTokenGuard, PermissionsGuard) + @Permissions([APPS_READ]) + async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise { + switch (calendar) { + case OFFICE_365_CALENDAR: + return await this.outlookService.check(userId); + case GOOGLE_CALENDAR: + return await this.googleCalendarService.check(userId); + default: + throw new BadRequestException( + "Invalid calendar type, available calendars are: ", + CALENDARS.join(", ") + ); + } + } } diff --git a/apps/api/v2/src/ee/calendars/services/calendars.service.ts b/apps/api/v2/src/ee/calendars/services/calendars.service.ts index 6fee7cb40e..7d6ab93891 100644 --- a/apps/api/v2/src/ee/calendars/services/calendars.service.ts +++ b/apps/api/v2/src/ee/calendars/services/calendars.service.ts @@ -1,3 +1,4 @@ +import { AppsRepository } from "@/modules/apps/apps.repository"; import { CredentialsRepository, CredentialsWithUserEmail, @@ -11,8 +12,10 @@ import { UnauthorizedException, NotFoundException, } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; import { User } from "@prisma/client"; import { DateTime } from "luxon"; +import { z } from "zod"; import { getConnectedDestinationCalendars } from "@calcom/platform-libraries"; import { getBusyCalendarTimes } from "@calcom/platform-libraries"; @@ -21,11 +24,15 @@ import { PrismaClient } from "@calcom/prisma"; @Injectable() export class CalendarsService { + private oAuthCalendarResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() }); + constructor( private readonly usersRepository: UsersRepository, private readonly credentialsRepository: CredentialsRepository, + private readonly appsRepository: AppsRepository, private readonly dbRead: PrismaReadService, - private readonly dbWrite: PrismaWriteService + private readonly dbWrite: PrismaWriteService, + private readonly config: ConfigService ) {} async getCalendars(userId: number) { @@ -110,4 +117,24 @@ export class CalendarsService { }); return composedSelectedCalendars; } + + async getAppKeys(appName: string) { + const app = await this.appsRepository.getAppBySlug(appName); + + if (!app) { + throw new NotFoundException(); + } + + const { client_id, client_secret } = this.oAuthCalendarResponseSchema.parse(app.keys); + + if (!client_id) { + throw new NotFoundException(); + } + + if (!client_secret) { + throw new NotFoundException(); + } + + return { client_id, client_secret }; + } } diff --git a/apps/api/v2/src/ee/calendars/services/gcal.service.ts b/apps/api/v2/src/ee/calendars/services/gcal.service.ts new file mode 100644 index 0000000000..17ebbeff72 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/gcal.service.ts @@ -0,0 +1,156 @@ +import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import { Logger, NotFoundException } from "@nestjs/common"; +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Prisma } from "@prisma/client"; +import { Request } from "express"; +import { google } from "googleapis"; +import { z } from "zod"; + +import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants"; + +const CALENDAR_SCOPES = [ + "https://www.googleapis.com/auth/calendar.readonly", + "https://www.googleapis.com/auth/calendar.events", +]; + +@Injectable() +export class GoogleCalendarService implements OAuthCalendarApp { + private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`; + private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() }); + private logger = new Logger("GcalService"); + + constructor( + private readonly config: ConfigService, + private readonly appsRepository: AppsRepository, + private readonly credentialRepository: CredentialsRepository, + private readonly calendarsService: CalendarsService, + private readonly tokensRepository: TokensRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository + ) {} + + async connect( + authorization: string, + req: Request + ): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> { + const accessToken = authorization.replace("Bearer ", ""); + const origin = req.get("origin") ?? req.get("host"); + const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? ""); + + return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } }; + } + + async save(code: string, accessToken: string, origin: string): Promise<{ url: string }> { + return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin); + } + + async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + return await this.checkIfCalendarConnected(userId); + } + + async getCalendarRedirectUrl(accessToken: string, origin: string) { + const oAuth2Client = await this.getOAuthClient(this.redirectUri); + + const authUrl = oAuth2Client.generateAuthUrl({ + access_type: "offline", + scope: CALENDAR_SCOPES, + prompt: "consent", + state: `accessToken=${accessToken}&origin=${origin}`, + }); + + return authUrl; + } + + async getOAuthClient(redirectUri: string) { + this.logger.log("Getting Google Calendar OAuth Client"); + const app = await this.appsRepository.getAppBySlug("google-calendar"); + + if (!app) { + throw new NotFoundException(); + } + + const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys); + + const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri); + return oAuth2Client; + } + + async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId); + + if (!gcalCredentials) { + throw new BadRequestException("Credentials for google_calendar not found."); + } + + if (gcalCredentials.invalid) { + throw new BadRequestException("Invalid google oauth credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const googleCalendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE + ); + if (!googleCalendar) { + throw new UnauthorizedException("Google Calendar not connected."); + } + if (googleCalendar.error?.message) { + throw new UnauthorizedException(googleCalendar.error?.message); + } + + return { status: SUCCESS_STATUS }; + } + + async saveCalendarCredentialsAndRedirect(code: string, accessToken: string, origin: string) { + // User chose not to authorize your app or didn't authorize your app + // redirect directly without oauth code + if (!code) { + return { url: origin }; + } + + const parsedCode = z.string().parse(code); + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException("Invalid Access token."); + } + + const oAuth2Client = await this.getOAuthClient(this.redirectUri); + const token = await oAuth2Client.getToken(parsedCode); + // Google oAuth Credentials are stored in token.tokens + const key = token.tokens; + const credential = await this.credentialRepository.createAppCredential( + GOOGLE_CALENDAR_TYPE, + key as Prisma.InputJsonValue, + ownerId + ); + + oAuth2Client.setCredentials(key); + + const calendar = google.calendar({ + version: "v3", + auth: oAuth2Client, + }); + + const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" }); + + const primaryCal = cals.data.items?.find((cal) => cal.primary); + + if (primaryCal?.id) { + await this.selectedCalendarsRepository.createSelectedCalendar( + primaryCal.id, + credential.id, + ownerId, + GOOGLE_CALENDAR_TYPE + ); + } + + return { url: origin }; + } +} diff --git a/apps/api/v2/src/ee/calendars/services/outlook.service.ts b/apps/api/v2/src/ee/calendars/services/outlook.service.ts new file mode 100644 index 0000000000..b1b2d50f7d --- /dev/null +++ b/apps/api/v2/src/ee/calendars/services/outlook.service.ts @@ -0,0 +1,180 @@ +import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { TokensRepository } from "@/modules/tokens/tokens.repository"; +import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta"; +import { BadRequestException, UnauthorizedException } from "@nestjs/common"; +import { Injectable } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Request } from "express"; +import { stringify } from "querystring"; +import { z } from "zod"; + +import { OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { OFFICE_365_CALENDAR_ID } from "@calcom/platform-constants"; + +@Injectable() +export class OutlookService implements OAuthCalendarApp { + private redirectUri = `${this.config.get("api.url")}/calendars/office365/save`; + + constructor( + private readonly config: ConfigService, + private readonly calendarsService: CalendarsService, + private readonly credentialRepository: CredentialsRepository, + private readonly tokensRepository: TokensRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository + ) {} + + async connect( + authorization: string, + req: Request + ): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> { + const accessToken = authorization.replace("Bearer ", ""); + const origin = req.get("origin") ?? req.get("host"); + const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? ""); + + return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } }; + } + + async save(code: string, accessToken: string, origin: string): Promise<{ url: string }> { + return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin); + } + + async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + return await this.checkIfCalendarConnected(userId); + } + + async getCalendarRedirectUrl(accessToken: string, origin: string) { + const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID); + + const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"]; + const params = { + response_type: "code", + scope: scopes.join(" "), + client_id, + prompt: "select_account", + redirect_uri: this.redirectUri, + state: `accessToken=${accessToken}&origin=${origin}`, + }; + + const query = stringify(params); + + const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`; + + return url; + } + + async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> { + const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId( + "office365_calendar", + userId + ); + + if (!office365CalendarCredentials) { + throw new BadRequestException("Credentials for office_365_calendar not found."); + } + + if (office365CalendarCredentials.invalid) { + throw new BadRequestException("Invalid office 365 calendar credentials."); + } + + const { connectedCalendars } = await this.calendarsService.getCalendars(userId); + const office365Calendar = connectedCalendars.find( + (cal: { integration: { type: string } }) => cal.integration.type === OFFICE_365_CALENDAR_TYPE + ); + if (!office365Calendar) { + throw new UnauthorizedException("Office 365 calendar not connected."); + } + if (office365Calendar.error?.message) { + throw new UnauthorizedException(office365Calendar.error?.message); + } + + return { + status: SUCCESS_STATUS, + }; + } + + async getOAuthCredentials(code: string) { + const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"]; + const { client_id, client_secret } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID); + + const toUrlEncoded = (payload: Record) => + Object.keys(payload) + .map((key) => `${key}=${encodeURIComponent(payload[key])}`) + .join("&"); + + const body = toUrlEncoded({ + client_id, + grant_type: "authorization_code", + code, + scope: scopes.join(" "), + redirect_uri: this.redirectUri, + client_secret, + }); + + const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8", + }, + body, + }); + + const responseBody = await response.json(); + + return responseBody; + } + + async getDefaultCalendar(accessToken: string): Promise { + const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar", { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + const responseBody = await response.json(); + + return responseBody as OfficeCalendar; + } + + async saveCalendarCredentialsAndRedirect(code: string, accessToken: string, origin: string) { + // if code is not defined, user denied to authorize office 365 app, just redirect straight away + if (!code) { + return { url: origin }; + } + + const parsedCode = z.string().parse(code); + + const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken); + + if (!ownerId) { + throw new UnauthorizedException("Invalid Access token."); + } + + const office365OAuthCredentials = await this.getOAuthCredentials(parsedCode); + + const defaultCalendar = await this.getDefaultCalendar(accessToken); + + if (defaultCalendar?.id) { + const credential = await this.credentialRepository.createAppCredential( + OFFICE_365_CALENDAR_TYPE, + office365OAuthCredentials, + ownerId + ); + + await this.selectedCalendarsRepository.createSelectedCalendar( + defaultCalendar.id, + credential.id, + ownerId, + OFFICE_365_CALENDAR_TYPE + ); + } + + return { + url: origin, + }; + } +} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 6b97504229..33742906f1 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1214,6 +1214,113 @@ ] } }, + "/v2/calendars/{calendar}/connect": { + "get": { + "operationId": "CalendarsController_redirect", + "parameters": [ + { + "name": "Authorization", + "required": true, + "in": "header", + "schema": { + "type": "string" + } + }, + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/{calendar}/save": { + "get": { + "operationId": "CalendarsController_save", + "parameters": [ + { + "name": "state", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "code", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "Calendars" + ] + } + }, + "/v2/calendars/{calendar}/check": { + "get": { + "operationId": "CalendarsController_check", + "parameters": [ + { + "name": "calendar", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Calendars" + ] + } + }, "/v2/bookings": { "get": { "operationId": "BookingsController_getBookings", diff --git a/packages/platform/constants/apps.ts b/packages/platform/constants/apps.ts index d86ac6d797..41675b29b4 100644 --- a/packages/platform/constants/apps.ts +++ b/packages/platform/constants/apps.ts @@ -1,6 +1,14 @@ export const GOOGLE_CALENDAR_TYPE = "google_calendar"; export const GOOGLE_CALENDAR_ID = "google-calendar"; +export const OFFICE_365_CALENDAR_TYPE = "office365_calendar"; +export const OFFICE_365_CALENDAR_ID = "office365-calendar"; +export const GOOGLE_CALENDAR = "google"; +export const OFFICE_365_CALENDAR = "office365"; +export const APPLE_CALENDAR = "apple"; +// APPLE_CALENDAR is not implemented yet +export const CALENDARS = [GOOGLE_CALENDAR, OFFICE_365_CALENDAR] as const; export const APPS_TYPE_ID_MAPPING = { [GOOGLE_CALENDAR_TYPE]: GOOGLE_CALENDAR_ID, + [OFFICE_365_CALENDAR_TYPE]: OFFICE_365_CALENDAR_ID, } as const;