From b46a902a3dceb9c838fafac3da959c5b15f7bb62 Mon Sep 17 00:00:00 2001 From: Rajiv Sahal Date: Mon, 22 Jul 2024 19:26:39 +0200 Subject: [PATCH] feat: api endpoints for calendar settings atom (#15761) * calendars repository * add disconnect endpoint * update platform libraries to include getCalendar fn * cleanup * fix typings * update calendars service to include delete credentials handler * update typings * add missing return * refactor delete calendar credentials handler * update disconnect endpoint * fix typing * fixup * add handlers to insert and remove selected calendar * init selected calendars controller * fix naming * output response dto for deleting calendar credentials * fix typing * fix merge conflicts * resolve merge conflicts * fixup * cleanup * capitalize controller name * include selected calendars controller in selected calendars module * fix type error * cleanup * fixup * add calendars repository * custom hook for calendar credentials * fix typing * cleanup * take input from query params instead of body in delete request * custom hook to add selected calendar * custom hook to remove selected calendar * better naming * fix typo * fixup * update input for delete calendar query params * address PR feedback * add method to check calendar credentials * abstract logic to check calendar credentials in calendars service * resolve module errors * export custom hooks for calendar settings * e2e for deleting calendar credentials endpoint * fix typo * set authorization header to calendars post * e2e tests for selected calendars controller * fix output typing * better error messages * restructuring * fix imports * remove unused not found exception * fixup! remove unused not found exception --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Morgan Vernay --- .../v2/src/ee/calendars/calendars.module.ts | 2 + .../src/ee/calendars/calendars.repository.ts | 51 ++++++ .../calendars.controller.e2e-spec.ts | 22 +++ .../controllers/calendars.controller.ts | 34 +++- .../delete-calendar-credentials.output.ts | 42 +++++ .../calendars/services/calendars.service.ts | 9 + apps/api/v2/src/ee/gcal/gcal.module.ts | 2 + .../selected-calendars.controller.e2e-spec.ts | 156 ++++++++++++++++++ .../selected-calendars.controller.ts | 76 +++++++++ .../inputs/selected-calendars.input.ts | 23 +++ .../outputs/selected-calendars.output.ts | 34 ++++ .../selected-calendars.module.ts | 16 +- .../selected-calendars.repository.ts | 37 +++++ .../hooks/calendars/useAddSelectedCalendar.ts | 54 ++++++ .../calendars/useDeleteCalendarCredentials.ts | 62 +++++++ .../calendars/useRemoveSelectedCalendar.ts | 60 +++++++ packages/platform/atoms/index.ts | 3 + 17 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 apps/api/v2/src/ee/calendars/calendars.repository.ts rename apps/api/v2/src/ee/calendars/{ => controllers}/calendars.controller.e2e-spec.ts (89%) create mode 100644 apps/api/v2/src/ee/calendars/outputs/delete-calendar-credentials.output.ts create mode 100644 apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.ts create mode 100644 apps/api/v2/src/modules/selected-calendars/inputs/selected-calendars.input.ts create mode 100644 apps/api/v2/src/modules/selected-calendars/outputs/selected-calendars.output.ts create mode 100644 packages/platform/atoms/hooks/calendars/useAddSelectedCalendar.ts create mode 100644 packages/platform/atoms/hooks/calendars/useDeleteCalendarCredentials.ts create mode 100644 packages/platform/atoms/hooks/calendars/useRemoveSelectedCalendar.ts diff --git a/apps/api/v2/src/ee/calendars/calendars.module.ts b/apps/api/v2/src/ee/calendars/calendars.module.ts index 93b37b6f2b..65fa4253e7 100644 --- a/apps/api/v2/src/ee/calendars/calendars.module.ts +++ b/apps/api/v2/src/ee/calendars/calendars.module.ts @@ -1,3 +1,4 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller"; import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service"; import { CalendarsService } from "@/ee/calendars/services/calendars.service"; @@ -21,6 +22,7 @@ import { Module } from "@nestjs/common"; AppleCalendarService, SelectedCalendarsRepository, AppsRepository, + CalendarsRepository, ], controllers: [CalendarsController], exports: [CalendarsService], diff --git a/apps/api/v2/src/ee/calendars/calendars.repository.ts b/apps/api/v2/src/ee/calendars/calendars.repository.ts new file mode 100644 index 0000000000..9d1c08acc7 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/calendars.repository.ts @@ -0,0 +1,51 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; + +const credentialForCalendarRepositorySelect = Prisma.validator()({ + id: true, + appId: true, + type: true, + userId: true, + user: { + select: { + email: true, + }, + }, + teamId: true, + key: true, + invalid: true, +}); + +@Injectable() +export class CalendarsRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async getCalendarCredentials(credentialId: number, userId: number) { + return await this.dbRead.prisma.credential.findFirst({ + where: { + id: credentialId, + userId, + }, + select: { + ...credentialForCalendarRepositorySelect, + app: { + select: { + slug: true, + categories: true, + dirName: true, + }, + }, + }, + }); + } + + async deleteCredentials(credentialId: number) { + return await this.dbWrite.prisma.credential.delete({ + where: { + id: credentialId, + }, + }); + } +} diff --git a/apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.e2e-spec.ts similarity index 89% rename from apps/api/v2/src/ee/calendars/calendars.controller.e2e-spec.ts rename to apps/api/v2/src/ee/calendars/controllers/calendars.controller.e2e-spec.ts index d1c6e63049..346ea9e7a6 100644 --- a/apps/api/v2/src/ee/calendars/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 { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output"; import { CalendarsService } from "@/ee/calendars/services/calendars.service"; import { HttpExceptionFilter } from "@/filters/http-exception.filter"; import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter"; @@ -18,6 +19,7 @@ import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.reposit import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { CalendarsServiceMock } from "test/mocks/calendars-service-mock"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { GOOGLE_CALENDAR, OFFICE_365_CALENDAR, @@ -189,6 +191,26 @@ describe("Platform Calendars Endpoints", () => { .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, + }; + + return request(app.getHttpServer()) + .post(`/v2/calendars/${OFFICE_365_CALENDAR}/disconnect`) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: Promise = response.body; + + expect((await responseBody).status).toEqual(SUCCESS_STATUS); + expect((await responseBody).data).toBeDefined(); + expect((await responseBody).data.id).toEqual(body.id); + expect((await responseBody).data.userId).toEqual(user.id); + }); + }); + afterAll(async () => { await oauthClientRepositoryFixture.delete(oAuthClient.id); await teamRepositoryFixture.delete(organization.id); 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 79dde7b9f8..a456677c73 100644 --- a/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts +++ b/apps/api/v2/src/ee/calendars/controllers/calendars.controller.ts @@ -1,5 +1,10 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output"; import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output"; +import { + DeletedCalendarCredentialsOutputResponseDto, + DeletedCalendarCredentialsOutputDto, +} from "@/ee/calendars/outputs/delete-calendar-credentials.output"; 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"; @@ -27,6 +32,7 @@ import { } from "@nestjs/common"; import { ApiTags as DocsTags } from "@nestjs/swagger"; import { User } from "@prisma/client"; +import { plainToClass } from "class-transformer"; import { Request } from "express"; import { z } from "zod"; @@ -50,7 +56,8 @@ export class CalendarsController { private readonly calendarsService: CalendarsService, private readonly outlookService: OutlookService, private readonly googleCalendarService: GoogleCalendarService, - private readonly appleCalendarService: AppleCalendarService + private readonly appleCalendarService: AppleCalendarService, + private readonly calendarsRepository: CalendarsRepository ) {} @UseGuards(ApiAuthGuard) @@ -183,4 +190,29 @@ export class CalendarsController { ); } } + + @UseGuards(ApiAuthGuard) + @Post("/:calendar/disconnect") + @HttpCode(HttpStatus.OK) + async deleteCalendarCredentials( + @Param("calendar") calendar: string, + @Body() body: { id: number }, + @GetUser() user: UserWithProfile + ): Promise { + const { id: credentialId } = body; + await this.calendarsService.checkCalendarCredentials(credentialId, user.id); + + const { id, type, userId, teamId, appId, invalid } = await this.calendarsRepository.deleteCredentials( + credentialId + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass( + DeletedCalendarCredentialsOutputDto, + { id, type, userId, teamId, appId, invalid }, + { strategy: "excludeAll" } + ), + }; + } } diff --git a/apps/api/v2/src/ee/calendars/outputs/delete-calendar-credentials.output.ts b/apps/api/v2/src/ee/calendars/outputs/delete-calendar-credentials.output.ts new file mode 100644 index 0000000000..fe10dfbd16 --- /dev/null +++ b/apps/api/v2/src/ee/calendars/outputs/delete-calendar-credentials.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 DeletedCalendarCredentialsOutputDto { + @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 DeletedCalendarCredentialsOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => DeletedCalendarCredentialsOutputDto) + data!: DeletedCalendarCredentialsOutputDto; +} 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 9ea5178fbe..fa63654804 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 { CalendarsRepository } from "@/ee/calendars/calendars.repository"; import { AppsRepository } from "@/modules/apps/apps.repository"; import { CredentialsRepository, @@ -29,6 +30,7 @@ export class CalendarsService { private readonly usersRepository: UsersRepository, private readonly credentialsRepository: CredentialsRepository, private readonly appsRepository: AppsRepository, + private readonly calendarsRepository: CalendarsRepository, private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService, private readonly config: ConfigService @@ -136,4 +138,11 @@ export class CalendarsService { return { client_id, client_secret }; } + + async checkCalendarCredentials(credentialId: number, userId: number) { + const credential = await this.calendarsRepository.getCalendarCredentials(credentialId, userId); + if (!credential) { + throw new NotFoundException("Calendar credentials not found"); + } + } } diff --git a/apps/api/v2/src/ee/gcal/gcal.module.ts b/apps/api/v2/src/ee/gcal/gcal.module.ts index e6c40d864a..ea7eae9e53 100644 --- a/apps/api/v2/src/ee/gcal/gcal.module.ts +++ b/apps/api/v2/src/ee/gcal/gcal.module.ts @@ -1,3 +1,4 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; import { CalendarsService } from "@/ee/calendars/services/calendars.service"; import { GcalController } from "@/ee/gcal/gcal.controller"; import { AppsRepository } from "@/modules/apps/apps.repository"; @@ -21,6 +22,7 @@ import { ConfigService } from "@nestjs/config"; GCalService, CalendarsService, UsersRepository, + CalendarsRepository, ], controllers: [GcalController], }) diff --git a/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.e2e-spec.ts b/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.e2e-spec.ts new file mode 100644 index 0000000000..bdbc561f34 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.e2e-spec.ts @@ -0,0 +1,156 @@ +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 { SelectedCalendarOutputResponseDto } from "@/modules/selected-calendars/outputs/selected-calendars.output"; +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 { CalendarsServiceMock } from "test/mocks/calendars-service-mock"; + +import { APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +const CLIENT_REDIRECT_URI = "http://localhost:5555"; + +describe("Platform Selected 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 appleCalendarCredentials: Credential; + let user: User; + 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, + }) + + .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; + appleCalendarCredentials = await credentialsRepositoryFixture.create( + APPLE_CALENDAR_TYPE, + {}, + user.id, + APPLE_CALENDAR_ID + ); + await app.init(); + jest + .spyOn(CalendarsService.prototype, "getCalendars") + .mockImplementation(CalendarsServiceMock.prototype.getCalendars); + }); + + 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(`POST /v2/selected-calendars: it should respond with a 201 returning back the user added selected calendar`, async () => { + const body = { + integration: appleCalendarCredentials.type, + externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/", + credentialId: appleCalendarCredentials.id, + }; + + await request(app.getHttpServer()) + .post("/v2/selected-calendars") + .set("Authorization", `Bearer ${accessTokenSecret}`) + .send(body) + .expect(201) + .then(async (response) => { + const responseBody: SelectedCalendarOutputResponseDto = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.credentialId).toEqual(body.credentialId); + expect(responseBody.data.integration).toEqual(body.integration); + expect(responseBody.data.externalId).toEqual(body.externalId); + expect(responseBody.data.userId).toEqual(user.id); + }); + }); + + it(`DELETE /v2/selected-calendars: it should respond with a 200 returning back the user deleted selected calendar`, async () => { + const integration = appleCalendarCredentials.type; + const externalId = + "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/"; + const credentialId = appleCalendarCredentials.id; + + await request(app.getHttpServer()) + .delete( + `/v2/selected-calendars?credentialId=${credentialId}&integration=${integration}&externalId=${externalId}` + ) + .set("Authorization", `Bearer ${accessTokenSecret}`) + .expect(200) + .then(async (response) => { + const responseBody: SelectedCalendarOutputResponseDto = response.body; + + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseBody.data.credentialId).toEqual(credentialId); + expect(responseBody.data.externalId).toEqual(externalId); + expect(responseBody.data.integration).toEqual(integration); + expect(responseBody.data.userId).toEqual(user.id); + }); + }); + + afterAll(async () => { + await oauthClientRepositoryFixture.delete(oAuthClient.id); + await teamRepositoryFixture.delete(organization.id); + await userRepositoryFixture.deleteByEmail(user.email); + await app.close(); + }); +}); diff --git a/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.ts b/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.ts new file mode 100644 index 0000000000..73561888b4 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/controllers/selected-calendars.controller.ts @@ -0,0 +1,76 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { + SelectedCalendarsInputDto, + SelectedCalendarsQueryParamsInputDto, +} from "@/modules/selected-calendars/inputs/selected-calendars.input"; +import { + SelectedCalendarOutputResponseDto, + SelectedCalendarOutputDto, +} from "@/modules/selected-calendars/outputs/selected-calendars.output"; +import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { Body, Controller, Post, UseGuards, Delete, Query } from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; + +@Controller({ + path: "/v2/selected-calendars", + version: API_VERSIONS_VALUES, +}) +@DocsTags("Selected-Calendars") +export class SelectedCalendarsController { + constructor( + private readonly calendarsRepository: CalendarsRepository, + private readonly selectedCalendarsRepository: SelectedCalendarsRepository, + private readonly calendarsService: CalendarsService + ) {} + + @Post("/") + @UseGuards(ApiAuthGuard) + async addSelectedCalendar( + @Body() input: SelectedCalendarsInputDto, + @GetUser() user: UserWithProfile + ): Promise { + const { integration, externalId, credentialId } = input; + await this.calendarsService.checkCalendarCredentials(Number(credentialId), user.id); + + const newlyAddedCalendarEntry = await this.selectedCalendarsRepository.addUserSelectedCalendar( + user.id, + integration, + externalId, + credentialId + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass(SelectedCalendarOutputDto, newlyAddedCalendarEntry, { strategy: "excludeAll" }), + }; + } + + @Delete("/") + @UseGuards(ApiAuthGuard) + async removeSelectedCalendar( + @Query() queryParams: SelectedCalendarsQueryParamsInputDto, + @GetUser() user: UserWithProfile + ): Promise { + const { integration, externalId, credentialId } = queryParams; + await this.calendarsService.checkCalendarCredentials(Number(credentialId), user.id); + + const removedCalendarEntry = await this.selectedCalendarsRepository.removeUserSelectedCalendar( + user.id, + integration, + externalId + ); + + return { + status: SUCCESS_STATUS, + data: plainToClass(SelectedCalendarOutputDto, removedCalendarEntry, { strategy: "excludeAll" }), + }; + } +} diff --git a/apps/api/v2/src/modules/selected-calendars/inputs/selected-calendars.input.ts b/apps/api/v2/src/modules/selected-calendars/inputs/selected-calendars.input.ts new file mode 100644 index 0000000000..1339cc36d7 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/inputs/selected-calendars.input.ts @@ -0,0 +1,23 @@ +import { IsInt, IsString } from "class-validator"; + +export class SelectedCalendarsInputDto { + @IsString() + readonly integration!: string; + + @IsString() + readonly externalId!: string; + + @IsInt() + readonly credentialId!: number; +} + +export class SelectedCalendarsQueryParamsInputDto { + @IsString() + readonly integration!: string; + + @IsString() + readonly externalId!: string; + + @IsString() + readonly credentialId!: string; +} diff --git a/apps/api/v2/src/modules/selected-calendars/outputs/selected-calendars.output.ts b/apps/api/v2/src/modules/selected-calendars/outputs/selected-calendars.output.ts new file mode 100644 index 0000000000..1461274a99 --- /dev/null +++ b/apps/api/v2/src/modules/selected-calendars/outputs/selected-calendars.output.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsString, ValidateNested, IsEnum } from "class-validator"; + +import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants"; + +export class SelectedCalendarOutputDto { + @IsInt() + @Expose() + readonly userId!: number; + + @IsString() + @Expose() + readonly integration!: string; + + @IsString() + @Expose() + readonly externalId!: string; + + @IsInt() + @Expose() + readonly credentialId!: number | null; +} + +export class SelectedCalendarOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => SelectedCalendarOutputDto) + data!: SelectedCalendarOutputDto; +} diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts index 8153f2ac4f..0537eb4f8b 100644 --- a/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.module.ts @@ -1,10 +1,24 @@ +import { CalendarsRepository } from "@/ee/calendars/calendars.repository"; +import { CalendarsService } from "@/ee/calendars/services/calendars.service"; +import { AppsRepository } from "@/modules/apps/apps.repository"; +import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { SelectedCalendarsController } from "@/modules/selected-calendars/controllers/selected-calendars.controller"; import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository"; +import { UsersRepository } from "@/modules/users/users.repository"; import { Module } from "@nestjs/common"; @Module({ imports: [PrismaModule], - providers: [SelectedCalendarsRepository], + providers: [ + SelectedCalendarsRepository, + CalendarsRepository, + CalendarsService, + UsersRepository, + CredentialsRepository, + AppsRepository, + ], + controllers: [SelectedCalendarsController], exports: [SelectedCalendarsRepository], }) export class SelectedCalendarsModule {} diff --git a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts index b56ae971c3..bdf98d4fce 100644 --- a/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts +++ b/apps/api/v2/src/modules/selected-calendars/selected-calendars.repository.ts @@ -37,4 +37,41 @@ export class SelectedCalendarsRepository { }, }); } + + async addUserSelectedCalendar( + userId: number, + integration: string, + externalId: string, + credentialId: number + ) { + return await this.dbWrite.prisma.selectedCalendar.upsert({ + where: { + userId_integration_externalId: { + userId, + integration, + externalId, + }, + }, + create: { + userId, + integration, + externalId, + credentialId, + }, + // already exists + update: {}, + }); + } + + async removeUserSelectedCalendar(userId: number, integration: string, externalId: string) { + return await this.dbWrite.prisma.selectedCalendar.delete({ + where: { + userId_integration_externalId: { + userId, + externalId, + integration, + }, + }, + }); + } } diff --git a/packages/platform/atoms/hooks/calendars/useAddSelectedCalendar.ts b/packages/platform/atoms/hooks/calendars/useAddSelectedCalendar.ts new file mode 100644 index 0000000000..31b36b1a88 --- /dev/null +++ b/packages/platform/atoms/hooks/calendars/useAddSelectedCalendar.ts @@ -0,0 +1,54 @@ +import { useMutation } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types"; + +import http from "../../lib/http"; + +interface IUseAddSelectedCalendar { + onSuccess?: (res: ApiResponse) => void; + onError?: (err: ApiErrorResponse | Error) => void; +} + +export const useAddSelectedCalendar = ( + { onSuccess, onError }: IUseAddSelectedCalendar = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + const newlyAddedCalendarEntry = useMutation< + ApiResponse<{ + status: string; + data: { + userId: number; + integration: string; + externalId: string; + credentialId: number | null; + }; + }>, + unknown, + { credentialId: number; integration: string; externalId: string } + >({ + mutationFn: (data) => { + return http.post(`/selected-calendars`, data).then((res) => { + return res.data; + }); + }, + onSuccess: (data) => { + if (data.status === SUCCESS_STATUS) { + onSuccess?.(data); + } else { + onError?.(data); + } + }, + onError: (err) => { + onError?.(err as ApiErrorResponse); + }, + }); + + return newlyAddedCalendarEntry; +}; diff --git a/packages/platform/atoms/hooks/calendars/useDeleteCalendarCredentials.ts b/packages/platform/atoms/hooks/calendars/useDeleteCalendarCredentials.ts new file mode 100644 index 0000000000..66223cd945 --- /dev/null +++ b/packages/platform/atoms/hooks/calendars/useDeleteCalendarCredentials.ts @@ -0,0 +1,62 @@ +import { useMutation } from "@tanstack/react-query"; + +import type { CALENDARS } from "@calcom/platform-constants"; +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types"; + +import http from "../../lib/http"; + +interface IUseDeleteCalendarCredentials { + onSuccess?: (res: ApiResponse) => void; + onError?: (err: ApiErrorResponse | Error) => void; +} + +export const useDeleteCalendarCredentials = ( + { onSuccess, onError }: IUseDeleteCalendarCredentials = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + const deleteCalendarCredentials = useMutation< + ApiResponse<{ + status: string; + data: { + id: number; + type: string; + userId: number | null; + teamId: number | null; + appId: string | null; + invalid: boolean | null; + }; + }>, + unknown, + { id: number; calendar: (typeof CALENDARS)[number] } + >({ + mutationFn: (data) => { + const { id, calendar } = data; + const body = { + id, + }; + + return http.post(`/calendars/${calendar}/disconnect`, body).then((res) => { + return res.data; + }); + }, + onSuccess: (data) => { + if (data.status === SUCCESS_STATUS) { + onSuccess?.(data); + } else { + onError?.(data); + } + }, + onError: (err) => { + onError?.(err as ApiErrorResponse); + }, + }); + + return deleteCalendarCredentials; +}; diff --git a/packages/platform/atoms/hooks/calendars/useRemoveSelectedCalendar.ts b/packages/platform/atoms/hooks/calendars/useRemoveSelectedCalendar.ts new file mode 100644 index 0000000000..bd27c46024 --- /dev/null +++ b/packages/platform/atoms/hooks/calendars/useRemoveSelectedCalendar.ts @@ -0,0 +1,60 @@ +import { useMutation } from "@tanstack/react-query"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types"; + +import http from "../../lib/http"; + +interface IUseRemoveSelectedCalendar { + onSuccess?: (res: ApiResponse) => void; + onError?: (err: ApiErrorResponse | Error) => void; +} + +export const useRemoveSelectedCalendar = ( + { onSuccess, onError }: IUseRemoveSelectedCalendar = { + onSuccess: () => { + return; + }, + onError: () => { + return; + }, + } +) => { + const deletedCalendarEntry = useMutation< + ApiResponse<{ + status: string; + data: { + userId: number; + integration: string; + externalId: string; + credentialId: number | null; + }; + }>, + unknown, + { credentialId: number; integration: string; externalId: string } + >({ + mutationFn: (data) => { + const { credentialId, externalId, integration } = data; + + return http + .delete( + `/selected-calendars?credentialId=${credentialId}&integration=${integration}&externalId=${externalId}` + ) + .then((res) => { + return res.data; + }); + }, + onSuccess: (data) => { + if (data.status === SUCCESS_STATUS) { + onSuccess?.(data); + } else { + onError?.(data); + } + }, + onError: (err) => { + onError?.(err as ApiErrorResponse); + }, + }); + + return deletedCalendarEntry; +}; diff --git a/packages/platform/atoms/index.ts b/packages/platform/atoms/index.ts index 0ace2cbf53..08ac93f2f3 100644 --- a/packages/platform/atoms/index.ts +++ b/packages/platform/atoms/index.ts @@ -15,3 +15,6 @@ export { useMe } from "./hooks/useMe"; export { OutlookConnect } from "./connect/outlook/OutlookConnect"; export * as Connect from "./connect"; export { BookerEmbed } from "./booker-embed"; +export { useDeleteCalendarCredentials } from "./hooks/calendars/useDeleteCalendarCredentials"; +export { useAddSelectedCalendar } from "./hooks/calendars/useAddSelectedCalendar"; +export { useRemoveSelectedCalendar } from "./hooks/calendars/useRemoveSelectedCalendar";