feat: added office 365 video to conferencing atoms (#18067)

* feat: added office 365 video to conferencing atoms

* added documentation for conferencing atoms

* added props table to the documentation

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Somay Chauhan
2024-12-16 16:20:59 +02:00
committed by GitHub
co-authored by Morgan
parent 79d488faca
commit f8be2b3833
13 changed files with 337 additions and 41 deletions
@@ -3,6 +3,7 @@ import { ConferencingController } from "@/modules/conferencing/controllers/confe
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 { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -21,6 +22,7 @@ import { ConfigModule } from "@nestjs/config";
UsersRepository,
TokensRepository,
ZoomVideoService,
Office365VideoService,
AppsRepository,
],
exports: [],
@@ -15,6 +15,7 @@ import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferenci
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 { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -39,7 +40,7 @@ import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { Request } from "express";
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS } from "@calcom/platform-constants";
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO } from "@calcom/platform-constants";
export type OAuthCallbackState = {
accessToken: string;
@@ -61,7 +62,8 @@ export class ConferencingController {
private readonly tokensRepository: TokensRepository,
private readonly conferencingService: ConferencingService,
private readonly googleMeetService: GoogleMeetService,
private readonly zoomVideoService: ZoomVideoService
private readonly zoomVideoService: ZoomVideoService,
private readonly office365VideoService: Office365VideoService
) {}
@Post("/:app/connect")
@@ -116,8 +118,18 @@ export class ConferencingController {
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
case OFFICE_365_VIDEO:
credential = await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
return {
status: SUCCESS_STATUS,
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
default:
throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", "));
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
}
}
@@ -147,8 +159,14 @@ export class ConferencingController {
case ZOOM:
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId);
case OFFICE_365_VIDEO:
return await this.office365VideoService.connectOffice365App(decodedCallbackState, code, userId);
default:
throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", "));
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
[ZOOM, OFFICE_365_VIDEO].join(", ")
);
}
} catch (error) {
return {
@@ -0,0 +1,121 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { BadRequestException, Logger, NotFoundException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import { OFFICE_365_VIDEO, OFFICE_365_VIDEO_TYPE } from "@calcom/platform-constants";
import stringify = require("qs-stringify");
const zoomAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
@Injectable()
export class Office365VideoService {
private logger = new Logger("Office365VideoService");
private redirectUri = `${this.config.get("api.url")}/conferencing/${OFFICE_365_VIDEO}/oauth/callback`;
private scopes = ["OnlineMeetings.ReadWrite", "offline_access"];
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
async getOffice365AppKeys() {
const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO);
const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys);
if (!client_id) {
throw new NotFoundException("Office365 app not found");
}
if (!client_secret) {
throw new NotFoundException("Office365 app not found");
}
return { client_id, client_secret };
}
async generateOffice365AuthUrl(state: string) {
const { client_id } = await this.getOffice365AppKeys();
const params = {
response_type: "code",
client_id,
scope: this.scopes.join(" "),
redirect_uri: this.redirectUri,
state: state,
};
const query = stringify(params);
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
return { url };
}
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) {
const { client_id, client_secret } = await this.getOffice365AppKeys();
const toUrlEncoded = (payload: Record<string, string>) =>
Object.keys(payload)
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
.join("&");
const body = toUrlEncoded({
client_id,
grant_type: "authorization_code",
code,
scope: this.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();
if (!response.ok) {
throw new BadRequestException(responseBody.error);
}
const whoami = await fetch("https://graph.microsoft.com/v1.0/me", {
headers: { Authorization: `Bearer ${responseBody.access_token}` },
});
const graphUser = await whoami.json();
// In some cases, graphUser.mail is null. Then graphUser.userPrincipalName most likely contains the email address.
responseBody.email = graphUser.mail ?? graphUser.userPrincipalName;
responseBody.expiry_date = Math.round(+new Date() / 1000 + responseBody.expires_in); // set expiry date in seconds
delete responseBody.expires_in;
const existingCredentialOffice365Video = await this.appsRepository.findAppCredential({
type: OFFICE_365_VIDEO_TYPE,
userId,
appId: OFFICE_365_VIDEO,
});
const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id);
if (credentialIdsToDelete.length > 0) {
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
}
await this.appsRepository.createAppCredential(
OFFICE_365_VIDEO_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
userId,
OFFICE_365_VIDEO
);
return { url: state.returnTo ?? "" };
}
}
@@ -18,7 +18,7 @@ const zoomAppKeysSchema = z.object({
@Injectable()
export class ZoomVideoService {
private logger = new Logger("ZoomVideoService");
private redirectUri = `${this.config.get("api.url")}/conferencing/zoom/oauth/callback`;
private redirectUri = `${this.config.get("api.url")}/conferencing/${ZOOM}/oauth/callback`;
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}