feat: destination calendar settings for CalendarSettings atom (#16133)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Morgan Vernay <morgan@cal.com> Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
co-authored by
Morgan
Morgan Vernay
Peer Richelsen
parent
5df14c344e
commit
f81074c4f5
+171
@@ -0,0 +1,171 @@
|
||||
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 { DestinationCalendarsOutputResponseDto } from "@/modules/destination-calendars/outputs/destination-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 { 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 Destination Calendar 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();
|
||||
|
||||
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
|
||||
);
|
||||
jest.spyOn(CalendarsService.prototype, "getCalendars").mockReturnValue(
|
||||
Promise.resolve({
|
||||
connectedCalendars: [
|
||||
{
|
||||
integration: {
|
||||
installed: false,
|
||||
type: "apple_calendar",
|
||||
title: "",
|
||||
name: "",
|
||||
description: "",
|
||||
variant: "calendar",
|
||||
slug: "",
|
||||
locationOption: null,
|
||||
categories: ["calendar"],
|
||||
logo: "",
|
||||
publisher: "",
|
||||
url: "",
|
||||
email: "",
|
||||
},
|
||||
calendars: {
|
||||
externalId:
|
||||
"https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
|
||||
readOnly: false,
|
||||
integration: "apple_calendar",
|
||||
credentialId: appleCalendarCredentials.id,
|
||||
primary: true,
|
||||
email: user.email,
|
||||
},
|
||||
error: { message: "" },
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
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(`POST /v2/destination-calendars: it should respond with a 200 returning back the user updated destination calendar`, async () => {
|
||||
const body = {
|
||||
integration: appleCalendarCredentials.type,
|
||||
externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.put("/v2/destination-calendars")
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.send(body)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: DestinationCalendarsOutputResponseDto = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.credentialId).toEqual(appleCalendarCredentials.id);
|
||||
expect(responseBody.data.integration).toEqual(body.integration);
|
||||
expect(responseBody.data.externalId).toEqual(body.externalId);
|
||||
expect(responseBody.data.userId).toEqual(user.id);
|
||||
});
|
||||
});
|
||||
|
||||
it(`POST /v2/destination-calendars: should fail 400 if calendar type is invalid`, async () => {
|
||||
const body = {
|
||||
integration: "not-supported-calendar",
|
||||
externalId: "https://caldav.icloud.com/20961146906/calendars/83C4F9A1-F1D0-41C7-8FC3-0B$9AE22E813/",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.put("/v2/destination-calendars")
|
||||
.set("Authorization", `Bearer ${accessTokenSecret}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
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 { DestinationCalendarsInputBodyDto } from "@/modules/destination-calendars/inputs/destination-calendars.input";
|
||||
import {
|
||||
DestinationCalendarsOutputDto,
|
||||
DestinationCalendarsOutputResponseDto,
|
||||
} from "@/modules/destination-calendars/outputs/destination-calendars.output";
|
||||
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Body, Controller, Put, UseGuards } 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/destination-calendars",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Destination-Calendars")
|
||||
export class DestinationCalendarsController {
|
||||
constructor(private readonly destinationCalendarsService: DestinationCalendarsService) {}
|
||||
|
||||
@Put("/")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@DocsTags("Select a third party destination calendar where events will be created")
|
||||
async updateDestinationCalendars(
|
||||
@Body() input: DestinationCalendarsInputBodyDto,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<DestinationCalendarsOutputResponseDto> {
|
||||
const { integration, externalId } = input;
|
||||
const updatedDestinationCalendar = await this.destinationCalendarsService.updateDestinationCalendars(
|
||||
integration,
|
||||
externalId,
|
||||
user.id
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(DestinationCalendarsOutputDto, updatedDestinationCalendar, {
|
||||
strategy: "excludeAll",
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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 { DestinationCalendarsController } from "@/modules/destination-calendars/controllers/destination-calendars.controller";
|
||||
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
|
||||
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [
|
||||
CalendarsRepository,
|
||||
CalendarsService,
|
||||
DestinationCalendarsService,
|
||||
DestinationCalendarsRepository,
|
||||
UsersRepository,
|
||||
CredentialsRepository,
|
||||
AppsRepository,
|
||||
],
|
||||
controllers: [DestinationCalendarsController],
|
||||
exports: [DestinationCalendarsRepository],
|
||||
})
|
||||
export class DestinationCalendarsModule {}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class DestinationCalendarsRepository {
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async updateCalendar(
|
||||
integration: string,
|
||||
externalId: string,
|
||||
credentialId: number,
|
||||
userId: number,
|
||||
primaryEmail: string | null
|
||||
) {
|
||||
return await this.dbWrite.prisma.destinationCalendar.upsert({
|
||||
update: {
|
||||
integration,
|
||||
externalId,
|
||||
credentialId,
|
||||
primaryEmail,
|
||||
},
|
||||
create: {
|
||||
integration,
|
||||
externalId,
|
||||
credentialId,
|
||||
primaryEmail,
|
||||
userId,
|
||||
},
|
||||
where: {
|
||||
userId: userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose } from "class-transformer";
|
||||
import { IsString, IsEnum } from "class-validator";
|
||||
|
||||
import {
|
||||
APPLE_CALENDAR_TYPE,
|
||||
GOOGLE_CALENDAR_TYPE,
|
||||
OFFICE_365_CALENDAR_TYPE,
|
||||
} from "@calcom/platform-constants";
|
||||
|
||||
export class DestinationCalendarsInputBodyDto {
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
example: APPLE_CALENDAR_TYPE,
|
||||
description: "The calendar service you want to integrate, as returned by the /calendars endpoint",
|
||||
enum: [APPLE_CALENDAR_TYPE, GOOGLE_CALENDAR_TYPE, OFFICE_365_CALENDAR_TYPE],
|
||||
required: true,
|
||||
})
|
||||
@IsEnum([APPLE_CALENDAR_TYPE, GOOGLE_CALENDAR_TYPE, OFFICE_365_CALENDAR_TYPE])
|
||||
readonly integration!:
|
||||
| typeof APPLE_CALENDAR_TYPE
|
||||
| typeof GOOGLE_CALENDAR_TYPE
|
||||
| typeof OFFICE_365_CALENDAR_TYPE;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
@ApiProperty({
|
||||
example: "https://caldav.icloud.com/26962146906/calendars/1644422A-1945-4438-BBC0-4F0Q23A57R7S/",
|
||||
description:
|
||||
"Unique identifier used to represent the specfic calendar, as returned by the /calendars endpoint",
|
||||
type: "string",
|
||||
required: true,
|
||||
})
|
||||
readonly externalId!: string;
|
||||
}
|
||||
@@ -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 DestinationCalendarsOutputDto {
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly userId!: number;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly integration!: string;
|
||||
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly externalId!: string;
|
||||
|
||||
@IsInt()
|
||||
@Expose()
|
||||
readonly credentialId!: number | null;
|
||||
}
|
||||
|
||||
export class DestinationCalendarsOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => DestinationCalendarsOutputDto)
|
||||
data!: DestinationCalendarsOutputDto;
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { ConnectedCalendar, Calendar } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
@Injectable()
|
||||
export class DestinationCalendarsService {
|
||||
constructor(
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly destinationCalendarsRepository: DestinationCalendarsRepository
|
||||
) {}
|
||||
|
||||
async updateDestinationCalendars(integration: string, externalId: string, userId: number) {
|
||||
const userCalendars = await this.calendarsService.getCalendars(userId);
|
||||
const allCalendars = userCalendars.connectedCalendars
|
||||
.map((cal: ConnectedCalendar) => cal.calendars ?? [])
|
||||
.flat();
|
||||
const credentialId = allCalendars.find(
|
||||
(cal: Calendar) =>
|
||||
cal.externalId === externalId && cal.integration === integration && cal.readOnly === false
|
||||
)?.credentialId;
|
||||
|
||||
if (!credentialId) {
|
||||
throw new NotFoundException(`Could not find calendar ${externalId}`);
|
||||
}
|
||||
|
||||
const primaryEmail =
|
||||
allCalendars.find((cal: Calendar) => cal.primary && cal.credentialId === credentialId)?.email ?? null;
|
||||
|
||||
const {
|
||||
integration: updatedCalendarIntegration,
|
||||
externalId: updatedCalendarExternalId,
|
||||
credentialId: updatedCalendarCredentialId,
|
||||
userId: updatedCalendarUserId,
|
||||
} = await this.destinationCalendarsRepository.updateCalendar(
|
||||
integration,
|
||||
externalId,
|
||||
credentialId,
|
||||
userId,
|
||||
primaryEmail
|
||||
);
|
||||
|
||||
return {
|
||||
userId: updatedCalendarUserId,
|
||||
integration: updatedCalendarIntegration,
|
||||
externalId: updatedCalendarExternalId,
|
||||
credentialId: updatedCalendarCredentialId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
@@ -16,6 +17,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
TimezoneModule,
|
||||
UsersModule,
|
||||
WebhooksModule,
|
||||
DestinationCalendarsModule,
|
||||
],
|
||||
})
|
||||
export class EndpointsModule implements NestModule {
|
||||
|
||||
Reference in New Issue
Block a user