feat: google meet conferencing api v2 (#17023)

* feat: google meet conferencing api v2

* feat: add set default google meet conferencing app

* fixup! feat: add set default google meet conferencing app
This commit is contained in:
Morgan authored and GitHub committed 2024-10-10 09:28:53 +00:00
1 parent 34afc7b674
commit 5594e5c713
14 files changed
+772 -1

No files matched your search

@@ -0,0 +1,22 @@
import { ConferencingController } from "@/modules/conferencing/controllers/conferencing.controller";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [
ConferencingService,
ConferencingRepository,
GoogleMeetService,
CredentialsRepository,
UsersRepository,
],
exports: [],
controllers: [ConferencingController],
})
export class ConferencingModule {}
@@ -0,0 +1,137 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { ConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-conferencing-apps.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
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 { User } from "@prisma/client";
import * as request from "supertest";
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import {
ERROR_STATUS,
GOOGLE_CALENDAR_ID,
GOOGLE_CALENDAR_TYPE,
GOOGLE_MEET,
GOOGLE_MEET_TYPE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import { ApiErrorResponse, ApiSuccessResponse } from "@calcom/platform-types";
describe("Conferencing Endpoints", () => {
describe("conferencing controller e2e tests", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
const userEmail = "conferencing-controller-user-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should get all the conferencing apps of the auth user", async () => {
return request(app.getHttpServer())
.get(`/v2/conferencing`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ConferencingAppsOutputDto[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toEqual([]);
});
});
it("should fail to connect google meet if google calendar is not connected ", async () => {
return request(app.getHttpServer())
.post(`/v2/conferencing/google-meet/connect`)
.expect(400)
.then(async () => {
await credentialsRepositoryFixture.create(GOOGLE_CALENDAR_TYPE, {}, user.id, GOOGLE_CALENDAR_ID);
});
});
it("should connect google meet if google calendar is connected ", async () => {
return request(app.getHttpServer())
.post(`/v2/conferencing/google-meet/connect`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ConferencingAppsOutputDto[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
});
});
it("should set google meet as default conferencing app", async () => {
return request(app.getHttpServer())
.post(`/v2/conferencing/google-meet/default`)
.expect(200)
.then(async () => {
const updatedUser = await userRepositoryFixture.get(user.id);
expect(updatedUser).toBeDefined();
if (updatedUser) {
const metadata = updatedUser.metadata as { defaultConferencingApp?: { appSlug?: string } };
expect(metadata?.defaultConferencingApp?.appSlug).toEqual(GOOGLE_MEET);
}
});
});
it("should get all the conferencing apps of the auth user, and contain google meet", async () => {
return request(app.getHttpServer())
.get(`/v2/conferencing`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ConferencingAppsOutputDto[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const googleMeet = responseBody.data.find((app) => app.type === GOOGLE_MEET_TYPE);
expect(googleMeet?.userId).toEqual(user.id);
});
});
it("should disconnect google meet", async () => {
return request(app.getHttpServer()).delete(`/v2/conferencing/google-meet/disconnect`).expect(200);
});
it("should get all the conferencing apps of the auth user, and not contain google meet", async () => {
return request(app.getHttpServer())
.get(`/v2/conferencing`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ConferencingAppsOutputDto[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const googleMeet = responseBody.data.find((app) => app.type === GOOGLE_MEET_TYPE);
expect(googleMeet).toBeUndefined();
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
});
});
@@ -0,0 +1,121 @@
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 {
ConferencingAppsOutputResponseDto,
ConferencingAppOutputResponseDto,
ConferencingAppsOutputDto,
} from "@/modules/conferencing/outputs/get-conferencing-apps.output";
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import {
Controller,
Get,
HttpCode,
HttpStatus,
Logger,
UseGuards,
Post,
Param,
BadRequestException,
Delete,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { CONFERENCING_APPS, GOOGLE_MEET, SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/conferencing",
version: API_VERSIONS_VALUES,
})
@DocsTags("Platform / Conferencing")
export class ConferencingController {
private readonly logger = new Logger("Platform Gcal Provider");
constructor(
private readonly conferencingService: ConferencingService,
private readonly googleMeetService: GoogleMeetService
) {}
@Post("/:app/connect")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Connect your conferencing application" })
async connect(
@GetUser("id") userId: number,
@Param("app") app: string
): Promise<ConferencingAppOutputResponseDto> {
switch (app) {
case GOOGLE_MEET:
const credential = await this.googleMeetService.connectGoogleMeetApp(userId);
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
CONFERENCING_APPS.join(", ")
);
}
}
@Get("/")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "List your conferencing applications" })
async listConferencingApps(@GetUser("id") userId: number): Promise<ConferencingAppsOutputResponseDto> {
const conferencingApps = await this.conferencingService.getConferencingApps(userId);
const data = conferencingApps.map((conferencingApps) =>
plainToInstance(ConferencingAppsOutputDto, conferencingApps)
);
return { status: SUCCESS_STATUS, data };
}
@Post("/:app/default")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Set your default conferencing application" })
async default(
@GetUser("id") userId: number,
@Param("app") app: string
): Promise<SetDefaultConferencingAppOutputResponseDto> {
switch (app) {
case GOOGLE_MEET:
await this.googleMeetService.setDefault(userId);
return { status: SUCCESS_STATUS };
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
CONFERENCING_APPS.join(", ")
);
}
}
@Delete("/:app/disconnect")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Disconnect your conferencing application" })
async disconnect(
@GetUser("id") userId: number,
@Param("app") app: string
): Promise<ConferencingAppOutputResponseDto> {
switch (app) {
case GOOGLE_MEET:
const credential = await this.googleMeetService.disconnectGoogleMeetApp(userId);
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
CONFERENCING_APPS.join(", ")
);
}
}
}
@@ -0,0 +1,50 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum, IsNumber, IsOptional, IsBoolean } from "class-validator";
import { ERROR_STATUS, GOOGLE_MEET_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
export class ConferencingAppsOutputDto {
@Expose()
@IsNumber()
@ApiProperty({ description: "Id of the conferencing app credentials" })
id!: number;
@ApiProperty({ example: GOOGLE_MEET_TYPE, description: "Type of conferencing app" })
@Expose()
@IsString()
type!: string;
@ApiProperty({ description: "Id of the user associated to the conferencing app" })
@Expose()
@IsNumber()
userId!: number;
@ApiProperty({ example: true, description: "Whether if the connection is working or not." })
@Expose()
@IsBoolean()
@IsOptional()
invalid?: boolean | null;
}
export class ConferencingAppsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => ConferencingAppsOutputDto)
data!: ConferencingAppsOutputDto[];
}
export class ConferencingAppOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => ConferencingAppsOutputDto)
data!: ConferencingAppsOutputDto;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class SetDefaultConferencingAppOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,25 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
@Injectable()
export class ConferencingRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async findConferencingApps(userId: number) {
return this.dbRead.prisma.credential.findMany({
where: {
userId,
type: { endsWith: "_video" },
},
});
}
async findGoogleMeet(userId: number) {
return this.dbRead.prisma.credential.findFirst({
where: { userId, type: GOOGLE_MEET_TYPE },
});
}
}
@@ -0,0 +1,14 @@
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
@Injectable()
export class ConferencingService {
private logger = new Logger("ConferencingService");
constructor(private readonly conferencingRepository: ConferencingRepository) {}
async getConferencingApps(userId: number) {
return this.conferencingRepository.findConferencingApps(userId);
}
}
@@ -0,0 +1,71 @@
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE, GOOGLE_MEET } from "@calcom/platform-constants";
@Injectable()
export class GoogleMeetService {
private logger = new Logger("GoogleMeetService");
constructor(
private readonly conferencingRepository: ConferencingRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly usersRepository: UsersRepository
) {}
async connectGoogleMeetApp(userId: number) {
const googleCalendar = await this.credentialsRepository.getByTypeAndUserId(GOOGLE_CALENDAR_TYPE, userId);
if (!googleCalendar) {
throw new BadRequestException("Google Meet app requires a Google Calendar connection");
}
if (googleCalendar.invalid) {
throw new BadRequestException(
"Google Meet app requires a valid Google Calendar connection, please reconnect Google Calendar."
);
}
const googleMeet = await this.conferencingRepository.findGoogleMeet(userId);
if (googleMeet) {
throw new BadRequestException("Google Meet is already connected.");
}
const googleMeetCredential = await this.credentialsRepository.upsertAppCredential(
GOOGLE_MEET_TYPE,
{},
userId
);
return googleMeetCredential;
}
async disconnectGoogleMeetApp(userId: number) {
const googleMeet = await this.conferencingRepository.findGoogleMeet(userId);
if (!googleMeet) {
throw new BadRequestException("Google Meet is not connected.");
}
const googleMeetCredential = await this.credentialsRepository.deleteUserCredentialById(
userId,
googleMeet.id
);
return googleMeetCredential;
}
async setDefault(userId: number) {
const user = await this.usersRepository.setDefaultConferencingApp(userId, GOOGLE_MEET);
const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } };
if (metadata?.defaultConferencingApp?.appSlug !== GOOGLE_MEET) {
throw new InternalServerErrorException("Could not set Google Meet as default conferencing app");
}
return true;
}
}
@@ -88,6 +88,12 @@ export class CredentialsRepository {
},
});
}
async deleteUserCredentialById(userId: number, credentialId: number) {
return await this.dbWrite.prisma.credential.delete({
where: { id: credentialId, userId },
});
}
}
export type CredentialsWithUserEmail = Awaited<
@@ -1,6 +1,7 @@
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
import { AtomsModule } from "@/modules/atoms/atoms.module";
import { BillingModule } from "@/modules/billing/billing.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
@@ -22,6 +23,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
DestinationCalendarsModule,
AtomsModule,
StripeModule,
ConferencingModule,
],
})
export class EndpointsModule implements NestModule {
@@ -2,7 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
import { Injectable } from "@nestjs/common";
import { Injectable, NotFoundException } from "@nestjs/common";
import type { Profile, User, Team } from "@prisma/client";
export type UserWithProfile = User & {
@@ -223,4 +223,29 @@ export class UsersRepository {
});
return profiles.map((profile) => profile.user);
}
async setDefaultConferencingApp(userId: number, appSlug?: string, appLink?: string) {
const user = await this.findById(userId);
if (!user) {
throw new NotFoundException("user not found");
}
return await this.dbWrite.prisma.user.update({
data: {
metadata:
typeof user.metadata === "object"
? {
...user.metadata,
defaultConferencingApp: {
appSlug: appSlug,
appLink: appLink,
},
}
: {},
},
where: { id: userId },
});
}
}
+203
View File
@@ -63,6 +63,121 @@
]
}
},
"/v2/conferencing/{app}/connect": {
"post": {
"operationId": "ConferencingController_connect",
"summary": "connect your conferencing application",
"parameters": [
{
"name": "app",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConferencingAppOutputResponseDto"
}
}
}
}
},
"tags": [
"Platform / Conferencing"
]
}
},
"/v2/conferencing": {
"get": {
"operationId": "ConferencingController_listConferencingApps",
"summary": "list your conferencing applications",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConferencingAppsOutputResponseDto"
}
}
}
}
},
"tags": [
"Platform / Conferencing"
]
}
},
"/v2/conferencing/{app}/default": {
"post": {
"operationId": "ConferencingController_default",
"summary": "set your default conferencing application",
"parameters": [
{
"name": "app",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetDefaultConferencingAppOutputResponseDto"
}
}
}
}
},
"tags": [
"Platform / Conferencing"
]
}
},
"/v2/conferencing/{app}/disconnect": {
"delete": {
"operationId": "ConferencingController_disconnect",
"summary": "disconnect your conferencing application",
"parameters": [
{
"name": "app",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConferencingAppOutputResponseDto"
}
}
}
}
},
"tags": [
"Platform / Conferencing"
]
}
},
"/v2/gcal/oauth/auth-url": {
"get": {
"operationId": "GcalController_redirect",
@@ -13000,6 +13115,94 @@
"status",
"data"
]
},
"ConferencingAppsOutputDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "Id of the conferencing app credentials"
},
"type": {
"type": "string",
"example": "google_video",
"description": "type of conferencing app"
},
"userId": {
"type": "number",
"description": "Id of the user associated to the conferencing app"
},
"invalid": {
"type": "boolean",
"nullable": true,
"example": true,
"description": "Whether if the connection is working or not."
}
},
"required": [
"id",
"type",
"userId"
]
},
"ConferencingAppOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/ConferencingAppsOutputDto"
}
},
"required": [
"status",
"data"
]
},
"ConferencingAppsOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ConferencingAppsOutputDto"
}
}
},
"required": [
"status",
"data"
]
},
"SetDefaultConferencingAppOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
}
},
"required": [
"status"
]
}
}
}
+78
View File
@@ -59,6 +59,84 @@
"tags": ["Platform / Cal Provider"]
}
},
"/v2/conferencing": {
"get": {
"operationId": "ConferencingController_listConferencingApps",
"summary": "List your conferencing applications",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConferencingAppsOutputResponseDto"
}
}
}
}
},
"tags": ["Platform / Conferencing"]
}
},
"/v2/conferencing/{app}/default": {
"post": {
"operationId": "ConferencingController_default",
"summary": "Set your default conferencing application",
"parameters": [
{
"name": "app",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SetDefaultConferencingAppOutputResponseDto"
}
}
}
}
},
"tags": ["Platform / Conferencing"]
}
},
"/v2/conferencing/{app}/disconnect": {
"delete": {
"operationId": "ConferencingController_disconnect",
"summary": "Disconnect your conferencing application",
"parameters": [
{
"name": "app",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConferencingAppOutputResponseDto"
}
}
}
}
},
"tags": ["Platform / Conferencing"]
}
},
"/v2/gcal/oauth/auth-url": {
"get": {
"operationId": "GcalController_redirect",
+7
View File
@@ -13,9 +13,16 @@ 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 GOOGLE_MEET = "google-meet";
export const GOOGLE_MEET_TYPE = "google_video";
export const GOOGLE_MEET_ID = "google-meet";
export const CONFERENCING_APPS = [GOOGLE_MEET];
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,
[GOOGLE_MEET_TYPE]: GOOGLE_MEET_ID,
} as const;