feat: allow conferencing apps to be installed on teams (#19784)
* feat: added zoom, office365-video as valid event-type locations * added docs * Update stripe.service.ts * enable conferencing apps for teams initial commit * fix: make conferencing atom work with teams * update team events in bulk update default location * feat: Separate endpoints for team conferencing apps instead of query params * Display all team events in 'Bulk update existing event types' modal when conferencing atom is enabled for Teams * fix: bulk update default location for team events * removed the organizations endpoints * Refactor: Separate functions for team events to follow SRP * undo extra changes * undo extra changes * fix: platform libraries build breaking * feat: only allow integrations which are installed in create/update team/user event-type endpoints * fix: merge conflicts * remove redundant imports * Update ConferencingAppsViewPlatformWrapper.tsx * Update useBanners.ts * fix: axios error response interceptor * feat: update :app param docs in `organizations-conferencing.controller` * Revert "fix: axios error response interceptor" This reverts commit 729eac3eee9f81c19b5368bbae4b1666884221fd. * Update useAtomsGetInstalledConferencingApps.ts * upgrade platform libraries * Update apps.ts --------- Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Co-authored-by: Morgan Vernay <morgan@cal.com>
This commit is contained in:
62 files changed
+1443
-288
No files matched your search
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.164",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.166",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class BookingsRepository_2024_08_13 {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
@@ -122,7 +122,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -150,11 +149,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
expect(response.body.status).toEqual(SUCCESS_STATUS);
|
||||
expect(response.body.data).toBeDefined();
|
||||
|
||||
const googleCalendarLink = response.body.data.find((item: { id: string }) => item.id === 'googleCalendar').link;
|
||||
const microsoftOfficeLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOffice').link;
|
||||
const microsoftOutlookLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOutlook').link;
|
||||
const icsLink = response.body.data.find((item: { id: string }) => item.id === 'ics').link;
|
||||
|
||||
const googleCalendarLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "googleCalendar"
|
||||
).link;
|
||||
const microsoftOfficeLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "microsoftOffice"
|
||||
).link;
|
||||
const microsoftOutlookLink = response.body.data.find(
|
||||
(item: { id: string }) => item.id === "microsoftOutlook"
|
||||
).link;
|
||||
const icsLink = response.body.data.find((item: { id: string }) => item.id === "ics").link;
|
||||
|
||||
expect(googleCalendarLink).toMatch(/^https:\/\/calendar\.google\.com\//);
|
||||
expect(microsoftOfficeLink).toMatch(/^https:\/\/outlook\.office\.com\//);
|
||||
|
||||
@@ -30,7 +30,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
const appleCalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
@@ -94,7 +94,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
}
|
||||
|
||||
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
|
||||
await this.credentialRepository.upsertAppCredential(
|
||||
await this.credentialRepository.upsertUserAppCredential(
|
||||
APPLE_CALENDAR_TYPE,
|
||||
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
|
||||
userId,
|
||||
@@ -126,7 +126,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
|
||||
user: { email: userEmail },
|
||||
});
|
||||
await dav?.listCalendars();
|
||||
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
await this.credentialRepository.upsertUserAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
|
||||
} catch (reason) {
|
||||
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export class CalendarsService {
|
||||
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
const credential = await this.credentialsRepository.upsertAppCredential(
|
||||
const credential = await this.credentialsRepository.upsertUserAppCredential(
|
||||
calendarType,
|
||||
key,
|
||||
userId,
|
||||
|
||||
@@ -84,7 +84,10 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"google_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
|
||||
@@ -52,7 +52,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
);
|
||||
}
|
||||
|
||||
const credential = await this.credentialRepository.upsertAppCredential(
|
||||
const credential = await this.credentialRepository.upsertUserAppCredential(
|
||||
ICS_CALENDAR_TYPE,
|
||||
data.key,
|
||||
userId
|
||||
@@ -75,7 +75,10 @@ export class IcsFeedService implements ICSFeedCalendarApp {
|
||||
}
|
||||
|
||||
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId);
|
||||
const icsFeedCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
ICS_CALENDAR_TYPE,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!icsFeedCredentials) {
|
||||
throw new BadRequestException("Credentials for Ics Feed calendar not found.");
|
||||
|
||||
@@ -71,7 +71,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
}
|
||||
|
||||
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
|
||||
const office365CalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"office365_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
+38
@@ -377,6 +377,27 @@ describe("Event types Endpoints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with integration not installed on user", async () => {
|
||||
const body: CreateEventTypeInput_2024_06_14 = {
|
||||
title: "Coding class",
|
||||
slug: "coding-class",
|
||||
description: "Let's learn how to code like a pro.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "zoom",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should create an event type", async () => {
|
||||
const nameBookingField: NameDefaultFieldInput_2024_06_14 = {
|
||||
type: "name",
|
||||
@@ -1090,6 +1111,23 @@ describe("Event types Endpoints", () => {
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it("should not allow to update event type with integration not installed on user", async () => {
|
||||
const body: UpdateEventTypeInput_2024_06_14 = {
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "office365-video",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${eventType.id}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it(`/GET/:id`, async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v2/event-types/${eventType.id}`)
|
||||
|
||||
+2
-2
@@ -75,7 +75,7 @@ export class EventTypesController_2024_06_14 {
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<CreateEventTypeOutput_2024_06_14> {
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput(
|
||||
user.id,
|
||||
user,
|
||||
body
|
||||
);
|
||||
|
||||
@@ -150,7 +150,7 @@ export class EventTypesController_2024_06_14 {
|
||||
): Promise<UpdateEventTypeOutput_2024_06_14> {
|
||||
const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput(
|
||||
body,
|
||||
user.id,
|
||||
user,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
@@ -18,7 +19,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
|
||||
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
|
||||
providers: [
|
||||
EventTypesRepository_2024_06_14,
|
||||
EventTypesService_2024_06_14,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
|
||||
+28
-7
@@ -1,6 +1,7 @@
|
||||
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -49,13 +50,15 @@ interface ValidationContext {
|
||||
export class InputEventTypesService_2024_06_14 {
|
||||
constructor(
|
||||
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
|
||||
private readonly calendarsService: CalendarsService
|
||||
private readonly calendarsService: CalendarsService,
|
||||
private readonly conferencingService: ConferencingService
|
||||
) {}
|
||||
|
||||
async transformAndValidateCreateEventTypeInput(
|
||||
userId: UserWithProfile["id"],
|
||||
user: UserWithProfile,
|
||||
inputEventType: CreateEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(user, inputEventType.locations);
|
||||
const transformedBody = this.transformInputCreateEventType(inputEventType);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
@@ -66,19 +69,21 @@ export class InputEventTypesService_2024_06_14 {
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
|
||||
async transformAndValidateUpdateEventTypeInput(
|
||||
inputEventType: UpdateEventTypeInput_2024_06_14,
|
||||
userId: UserWithProfile["id"],
|
||||
user: UserWithProfile,
|
||||
eventTypeId: number
|
||||
) {
|
||||
await this.validateInputLocations(user, inputEventType.locations);
|
||||
|
||||
const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId);
|
||||
|
||||
await this.validateEventTypeInputs({
|
||||
@@ -90,10 +95,10 @@ export class InputEventTypesService_2024_06_14 {
|
||||
});
|
||||
|
||||
transformedBody.destinationCalendar &&
|
||||
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
|
||||
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
|
||||
|
||||
transformedBody.useEventTypeDestinationCalendarEmail &&
|
||||
(await this.validateInputUseDestinationCalendarEmail(userId));
|
||||
(await this.validateInputUseDestinationCalendarEmail(user.id));
|
||||
|
||||
return transformedBody;
|
||||
}
|
||||
@@ -448,4 +453,20 @@ export class InputEventTypesService_2024_06_14 {
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async validateInputLocations(
|
||||
user: UserWithProfile,
|
||||
inputLocations: CreateEventTypeInput_2024_06_14["locations"] | undefined
|
||||
) {
|
||||
await Promise.all(
|
||||
inputLocations?.map(async (location) => {
|
||||
if (location.type === "integration") {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (location.integration !== "cal-video") {
|
||||
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,10 @@ export class GcalController {
|
||||
@Permissions([APPS_READ])
|
||||
@ApiOperation({ summary: "Check a calendar connection status" })
|
||||
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
|
||||
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
|
||||
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"google_calendar",
|
||||
userId
|
||||
);
|
||||
|
||||
if (!gcalCredentials) {
|
||||
throw new BadRequestException("Credentials for google_calendar not found.");
|
||||
|
||||
@@ -31,6 +31,15 @@ export class AppsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTeamAppCredentials(credentialIdsToDelete: number[], teamId: number) {
|
||||
return this.dbWrite.prisma.credential.deleteMany({
|
||||
where: {
|
||||
id: { in: credentialIdsToDelete },
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
|
||||
return this.dbWrite.prisma.credential.create({
|
||||
data: {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -24,6 +25,7 @@ import { Module } from "@nestjs/common";
|
||||
AtomsRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
RedisService,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
controllers: [AtomsController],
|
||||
|
||||
@@ -5,8 +5,15 @@ import {
|
||||
} from "@/modules/atoms/inputs/event-types-app.input";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
@@ -60,14 +67,27 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/organizations/:orgId/teams/:teamId/event-types")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/event-types")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventTypes(userId);
|
||||
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
|
||||
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
data: eventTypes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -116,6 +136,22 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async bulkUpdateAtomTeamEventTypes(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
|
||||
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@@ -135,9 +171,11 @@ export class AtomsController {
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
async updateAtomTeamEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@@ -155,13 +193,33 @@ export class AtomsController {
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
@Get("/organizations/:orgId/teams/:teamId/conferencing")
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Version(VERSION_NEUTRAL)
|
||||
async listTeamInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/conferencing")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(user);
|
||||
|
||||
return { status: SUCCESS_STATUS, data: conferencingApps };
|
||||
async listUserInstalledConferencingApps(
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ApiResponse<ConnectedApps>> {
|
||||
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export class ConferencingAtomsService {
|
||||
|
||||
constructor(private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async getConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
|
||||
async getUserConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
|
||||
return getConnectedApps({
|
||||
user,
|
||||
input: {
|
||||
@@ -22,4 +22,17 @@ export class ConferencingAtomsService {
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
async getTeamConferencingApps(user: UserWithProfile, teamId: number): Promise<ConnectedApps> {
|
||||
return getConnectedApps({
|
||||
user,
|
||||
input: {
|
||||
variant: "conferencing",
|
||||
onlyInstalled: true,
|
||||
teamId,
|
||||
includeTeamInstalledApps: true,
|
||||
},
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,11 @@ import type {
|
||||
LocationOption,
|
||||
} from "@calcom/platform-libraries/app-store";
|
||||
import {
|
||||
getBulkEventTypes,
|
||||
getEventTypeById,
|
||||
bulkUpdateEventsToDefaultLocation,
|
||||
bulkUpdateTeamEventsToDefaultLocation,
|
||||
getBulkUserEventTypes,
|
||||
getBulkTeamEventTypes,
|
||||
} from "@calcom/platform-libraries/event-types";
|
||||
import {
|
||||
updateEventType,
|
||||
@@ -83,7 +85,11 @@ export class EventTypesAtomService {
|
||||
}
|
||||
|
||||
async getUserEventTypes(userId: number) {
|
||||
return getBulkEventTypes(userId);
|
||||
return getBulkUserEventTypes(userId);
|
||||
}
|
||||
|
||||
async getTeamEventTypes(teamId: number) {
|
||||
return getBulkTeamEventTypes(teamId);
|
||||
}
|
||||
|
||||
async updateTeamEventType(
|
||||
@@ -345,4 +351,12 @@ export class EventTypesAtomService {
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
});
|
||||
}
|
||||
|
||||
async bulkUpdateTeamEventTypesDefaultLocation(eventTypeIds: number[], teamId: number) {
|
||||
return bulkUpdateTeamEventsToDefaultLocation({
|
||||
eventTypeIds,
|
||||
prisma: this.dbWrite.prisma as unknown as PrismaClient,
|
||||
teamId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,22 @@
|
||||
import { ApiKeysModule } from "@/modules/api-keys/api-keys.module";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthStrategy } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.strategy";
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
|
||||
@Module({
|
||||
@@ -23,8 +29,28 @@ import { PassportModule } from "@nestjs/passport";
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
forwardRef(() => OrganizationsModule),
|
||||
],
|
||||
providers: [
|
||||
NextAuthGuard,
|
||||
NextAuthStrategy,
|
||||
ApiAuthGuard,
|
||||
ApiAuthStrategy,
|
||||
OAuthFlowService,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
exports: [
|
||||
NextAuthGuard,
|
||||
ApiAuthGuard,
|
||||
IsTeamInOrg,
|
||||
RolesGuard,
|
||||
IsOrgGuard,
|
||||
IsAdminAPIEnabledGuard,
|
||||
PlatformPlanGuard,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -21,7 +21,21 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
|
||||
const { canAccess } = await this.checkPlatformPlanAccess({ teamId, orgId, user, minimumPlan });
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkPlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
}: {
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
user: ApiAuthGuardUser;
|
||||
minimumPlan: PlatformPlanType;
|
||||
}): Promise<{ canAccess: boolean }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||
teamId ?? "none"
|
||||
}:guard:platformbilling:${minimumPlan}`;
|
||||
@@ -29,7 +43,7 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
return { canAccess: cachedAccess };
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
@@ -56,7 +70,7 @@ export class PlatformPlanGuard implements CanActivate {
|
||||
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
if (canAccess) {
|
||||
return canAccess;
|
||||
return { canAccess };
|
||||
}
|
||||
throw new ForbiddenException(
|
||||
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
|
||||
|
||||
@@ -18,22 +18,34 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { organization?: Team }>();
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
if (!organizationId) {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
|
||||
if (organization) {
|
||||
request.organization = organization;
|
||||
}
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkAdminAPIEnabled(
|
||||
organizationId: string
|
||||
): Promise<{ canAccess: boolean; organization?: Team | null }> {
|
||||
let canAccess = false;
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
return {
|
||||
canAccess: cachedCanAccess,
|
||||
organization: cachedOrg,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +69,6 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
|
||||
"EX",
|
||||
300
|
||||
));
|
||||
return canAccess;
|
||||
return { canAccess, organization: org };
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ export class IsOrgGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
let canAccess = false;
|
||||
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
|
||||
const organizationId: string = request.params.orgId;
|
||||
|
||||
@@ -26,33 +25,45 @@ export class IsOrgGuard implements CanActivate {
|
||||
throw new ForbiddenException("No organization id found in request params.");
|
||||
}
|
||||
|
||||
const { canAccess, org } = await this.checkOrgAccess(organizationId);
|
||||
|
||||
if (canAccess && org) {
|
||||
request.organization = org;
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
|
||||
let canAccess = false;
|
||||
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
|
||||
|
||||
if (cachedData) {
|
||||
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
|
||||
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
|
||||
request.organization = cachedOrg;
|
||||
return cachedCanAccess;
|
||||
return {
|
||||
org: cachedOrg,
|
||||
canAccess: cachedCanAccess,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const org = await this.organizationsRepository.findById(Number(organizationId));
|
||||
|
||||
if (org?.isOrganization) {
|
||||
request.organization = org;
|
||||
canAccess = true;
|
||||
}
|
||||
|
||||
if (org) {
|
||||
await this.redisService.redis.set(
|
||||
REDIS_CACHE_KEY,
|
||||
JSON.stringify({ org: org, canAccess } satisfies CachedData),
|
||||
JSON.stringify({ org, canAccess } satisfies CachedData),
|
||||
"EX",
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
return canAccess;
|
||||
return { canAccess, org };
|
||||
}
|
||||
}
|
||||
@@ -24,13 +24,22 @@ export class RolesGuard implements CanActivate {
|
||||
const orgId = request.params.orgId as string;
|
||||
const user = request.user as ApiAuthGuardUser;
|
||||
const allowedRole = this.reflector.get(Roles, context.getHandler());
|
||||
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
|
||||
return canAccess;
|
||||
}
|
||||
async checkUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
teamId: string,
|
||||
allowedRole: string
|
||||
): Promise<{ canAccess: boolean }> {
|
||||
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
|
||||
teamId ?? "none"
|
||||
}:guard:roles:${allowedRole}`;
|
||||
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
|
||||
|
||||
if (cachedAccess) {
|
||||
return cachedAccess;
|
||||
return { canAccess: cachedAccess };
|
||||
}
|
||||
|
||||
let canAccess = false;
|
||||
@@ -130,7 +139,7 @@ export class RolesGuard implements CanActivate {
|
||||
}
|
||||
}
|
||||
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
|
||||
return canAccess;
|
||||
return { canAccess };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export class IsTeamInOrg implements CanActivate {
|
||||
constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
|
||||
const request = context.switchToHttp().getRequest<Request & { team?: Team }>();
|
||||
const teamId: string = request.params.teamId;
|
||||
const orgId: string = request.params.orgId;
|
||||
|
||||
@@ -27,17 +27,25 @@ export class IsTeamInOrg implements CanActivate {
|
||||
throw new ForbiddenException("No team id found in request params.");
|
||||
}
|
||||
|
||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||
const { canAccess, team } = await this.checkIfTeamIsInOrg(orgId, teamId);
|
||||
|
||||
if (team && !team.isOrganization && team.parentId === Number(orgId)) {
|
||||
if (canAccess && team) {
|
||||
request.team = team;
|
||||
return true;
|
||||
}
|
||||
return canAccess;
|
||||
}
|
||||
|
||||
async checkIfTeamIsInOrg(orgId: string, teamId: string): Promise<{ canAccess: boolean; team?: Team }> {
|
||||
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team (${teamId}) not found.`);
|
||||
}
|
||||
|
||||
return false;
|
||||
if (!team.isOrganization && team.parentId === Number(orgId)) {
|
||||
return { canAccess: true, team };
|
||||
}
|
||||
|
||||
return { canAccess: false };
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,15 @@ import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.s
|
||||
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 { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, ConfigModule],
|
||||
imports: [PrismaModule, ConfigModule, forwardRef(() => OrganizationsModule)],
|
||||
providers: [
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
@@ -25,7 +26,13 @@ import { ConfigModule } from "@nestjs/config";
|
||||
Office365VideoService,
|
||||
AppsRepository,
|
||||
],
|
||||
exports: [],
|
||||
exports: [
|
||||
ZoomVideoService,
|
||||
Office365VideoService,
|
||||
GoogleMeetService,
|
||||
ConferencingService,
|
||||
ConferencingRepository,
|
||||
],
|
||||
controllers: [ConferencingController],
|
||||
})
|
||||
export class ConferencingModule {}
|
||||
@@ -15,18 +15,16 @@ import {
|
||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.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 { Office365VideoService } from "@/modules/conferencing/services/office365-video.service";
|
||||
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
UseGuards,
|
||||
Post,
|
||||
Param,
|
||||
@@ -46,7 +44,8 @@ import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: number;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
@@ -63,9 +62,7 @@ export class ConferencingController {
|
||||
constructor(
|
||||
private readonly tokensRepository: TokensRepository,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
|
||||
@Post("/:app/connect")
|
||||
@@ -80,21 +77,11 @@ export class ConferencingController {
|
||||
required: true,
|
||||
})
|
||||
async connect(
|
||||
@GetUser("id") userId: number,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@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: ",
|
||||
[GOOGLE_MEET].join(", ")
|
||||
);
|
||||
}
|
||||
const credential = await this.conferencingService.connectUserNonOauthApp(app, user.id);
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/auth-url")
|
||||
@@ -115,7 +102,6 @@ export class ConferencingController {
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<GetConferencingAppsOauthUrlResponseDto> {
|
||||
let credential;
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
@@ -126,27 +112,12 @@ export class ConferencingController {
|
||||
accessToken,
|
||||
};
|
||||
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
const credential = await this.conferencingService.generateOAuthUrl(app, state);
|
||||
|
||||
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, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/:app/oauth/callback")
|
||||
@@ -176,23 +147,18 @@ export class ConferencingController {
|
||||
if (error) {
|
||||
throw new BadRequestException(error_description);
|
||||
}
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
switch (app) {
|
||||
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, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
if (decodedCallbackState.orgId) {
|
||||
return this.organizationsConferencingService.connectTeamOauthApps({
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
decodedCallbackState,
|
||||
});
|
||||
} else {
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof HttpException || error instanceof Error) {
|
||||
@@ -210,15 +176,13 @@ export class ConferencingController {
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "List your conferencing applications" })
|
||||
async listInstalledConferencingApps(
|
||||
@GetUser("id") userId: number
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<ConferencingAppsOutputResponseDto> {
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(userId);
|
||||
|
||||
const data = conferencingApps.map((conferencingApps) =>
|
||||
plainToInstance(ConferencingAppsOutputDto, conferencingApps)
|
||||
);
|
||||
|
||||
return { status: SUCCESS_STATUS, data };
|
||||
const conferencingApps = await this.conferencingService.getConferencingApps(user.id);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)),
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/:app/default")
|
||||
@@ -245,8 +209,8 @@ export class ConferencingController {
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({ summary: "Get your default conferencing application" })
|
||||
async getDefault(@GetUser("id") userId: number): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId);
|
||||
async getDefault(@GetUser() user: UserWithProfile): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(user.id);
|
||||
return { status: SUCCESS_STATUS, data: defaultconferencingApp };
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,15 @@ export class ConferencingRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApps(teamId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
teamId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findGoogleMeet(userId: number) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, type: GOOGLE_MEET_TYPE },
|
||||
@@ -28,4 +37,10 @@ export class ConferencingRepository {
|
||||
where: { userId, appId: app },
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApp(teamId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { teamId, appId: app },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,20 @@
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
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 { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
import {
|
||||
CONFERENCING_APPS,
|
||||
CAL_VIDEO,
|
||||
GOOGLE_MEET,
|
||||
ZOOM,
|
||||
OFFICE_365_VIDEO,
|
||||
} from "@calcom/platform-constants";
|
||||
import { userMetadata, getUsersCredentials } from "@calcom/platform-libraries";
|
||||
import { getApps, handleDeleteCredential } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@@ -14,13 +24,53 @@ export class ConferencingService {
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly zoomVideoService: ZoomVideoService,
|
||||
private readonly office365VideoService: Office365VideoService
|
||||
) {}
|
||||
|
||||
async getConferencingApps(userId: number) {
|
||||
return this.conferencingRepository.findConferencingApps(userId);
|
||||
}
|
||||
|
||||
async connectUserNonOauthApp(app: string, userId: number) {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
const credential = await this.googleMeetService.connectGoogleMeetToUser(userId);
|
||||
return credential;
|
||||
default:
|
||||
throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectOauthApps(
|
||||
app: string,
|
||||
code: string,
|
||||
userId: number,
|
||||
decodedCallbackState: OAuthCallbackState,
|
||||
teamId?: number
|
||||
) {
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId, teamId);
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.connectOffice365App(
|
||||
decodedCallbackState,
|
||||
code,
|
||||
userId,
|
||||
teamId
|
||||
);
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async getUserDefaultConferencingApp(userId: number) {
|
||||
const user = await this.usersRepository.findById(userId);
|
||||
return userMetadata.parse(user?.metadata)?.defaultConferencingApp;
|
||||
@@ -63,4 +113,20 @@ export class ConferencingService {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async generateOAuthUrl(app: string, state: OAuthCallbackState) {
|
||||
switch (app) {
|
||||
case ZOOM:
|
||||
return await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
|
||||
|
||||
case OFFICE_365_VIDEO:
|
||||
return await this.office365VideoService.generateOffice365AuthUrl(JSON.stringify(state));
|
||||
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid conferencing app, available apps are: ",
|
||||
[ZOOM, OFFICE_365_VIDEO].join(", ")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
|
||||
import { BadRequestException, Logger } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE, GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
import { GOOGLE_CALENDAR_TYPE, GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
@Injectable()
|
||||
export class GoogleMeetService {
|
||||
@@ -12,35 +11,51 @@ export class GoogleMeetService {
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository,
|
||||
private readonly usersRepository: UsersRepository
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async connectGoogleMeetApp(userId: number) {
|
||||
const googleCalendar = await this.credentialsRepository.getByTypeAndUserId(GOOGLE_CALENDAR_TYPE, userId);
|
||||
async connectGoogleMeetToUser(userId: number) {
|
||||
await this.validateGoogleCalendarConnection(userId, "user");
|
||||
|
||||
const googleMeetExists = await this.conferencingRepository.findGoogleMeet(userId);
|
||||
if (googleMeetExists) {
|
||||
throw new BadRequestException("Google Meet is already connected for this user.");
|
||||
}
|
||||
|
||||
return this.credentialsRepository.upsertUserAppCredential(GOOGLE_MEET_TYPE, {}, userId);
|
||||
}
|
||||
|
||||
async connectGoogleMeetToTeam(teamId: number) {
|
||||
await this.validateGoogleCalendarConnection(teamId, "team");
|
||||
|
||||
const googleMeetExists = await this.credentialsRepository.findCredentialByTypeAndTeamId(
|
||||
GOOGLE_MEET_TYPE,
|
||||
teamId
|
||||
);
|
||||
if (googleMeetExists) {
|
||||
throw new BadRequestException("Google Meet is already connected for this team.");
|
||||
}
|
||||
|
||||
return this.credentialsRepository.upsertTeamAppCredential(GOOGLE_MEET_TYPE, {}, teamId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that Google Calendar is connected and valid for either a user or a team.
|
||||
*/
|
||||
private async validateGoogleCalendarConnection(id: number, entity: "user" | "team") {
|
||||
const googleCalendar =
|
||||
entity === "user"
|
||||
? await this.credentialsRepository.findCredentialByTypeAndUserId(GOOGLE_CALENDAR_TYPE, id)
|
||||
: await this.credentialsRepository.findCredentialByTypeAndTeamId(GOOGLE_CALENDAR_TYPE, id);
|
||||
|
||||
if (!googleCalendar) {
|
||||
throw new BadRequestException("Google Meet app requires a Google Calendar connection");
|
||||
throw new BadRequestException("Google Meet requires a Google Calendar connection.");
|
||||
}
|
||||
|
||||
if (googleCalendar.invalid) {
|
||||
throw new BadRequestException(
|
||||
"Google Meet app requires a valid Google Calendar connection, please reconnect Google Calendar."
|
||||
"Google Meet 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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, Logger, NotFoundException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -21,7 +22,11 @@ export class 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) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async getOffice365AppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(OFFICE_365_VIDEO);
|
||||
@@ -56,7 +61,7 @@ export class Office365VideoService {
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number) {
|
||||
async connectOffice365App(state: OAuthCallbackState, code: string, userId: number, teamId?: number) {
|
||||
const { client_id, client_secret } = await this.getOffice365AppKeys();
|
||||
|
||||
const toUrlEncoded = (payload: Record<string, string>) =>
|
||||
@@ -98,23 +103,30 @@ export class Office365VideoService {
|
||||
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 existingCredentialOffice365Video = teamId
|
||||
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(OFFICE_365_VIDEO_TYPE, teamId)
|
||||
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(OFFICE_365_VIDEO_TYPE, userId);
|
||||
|
||||
const credentialIdsToDelete = existingCredentialOffice365Video.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
teamId
|
||||
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
|
||||
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
OFFICE_365_VIDEO
|
||||
);
|
||||
teamId
|
||||
? await this.appsRepository.createTeamAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
OFFICE_365_VIDEO
|
||||
)
|
||||
: await this.appsRepository.createAppCredential(
|
||||
OFFICE_365_VIDEO_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
OFFICE_365_VIDEO
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
@@ -20,7 +21,11 @@ export class ZoomVideoService {
|
||||
private logger = new Logger("ZoomVideoService");
|
||||
private redirectUri = `${this.config.get("api.url")}/conferencing/${ZOOM}/oauth/callback`;
|
||||
|
||||
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialsRepository: CredentialsRepository
|
||||
) {}
|
||||
|
||||
async getZoomAppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug(ZOOM);
|
||||
@@ -53,7 +58,7 @@ export class ZoomVideoService {
|
||||
return { url };
|
||||
}
|
||||
|
||||
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number) {
|
||||
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number, teamId?: number) {
|
||||
const { client_id, client_secret } = await this.getZoomAppKeys();
|
||||
const redirectUri = encodeURI(this.redirectUri);
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
@@ -90,23 +95,31 @@ export class ZoomVideoService {
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({
|
||||
type: ZOOM_TYPE,
|
||||
userId,
|
||||
appId: ZOOM,
|
||||
});
|
||||
|
||||
const existingCredentialZoomVideo = teamId
|
||||
? await this.credentialsRepository.findAllCredentialsByTypeAndTeamId(ZOOM_TYPE, teamId)
|
||||
: await this.credentialsRepository.findAllCredentialsByTypeAndUserId(ZOOM_TYPE, userId);
|
||||
|
||||
const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id);
|
||||
if (credentialIdsToDelete.length > 0) {
|
||||
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
teamId
|
||||
? await this.appsRepository.deleteTeamAppCredentials(credentialIdsToDelete, teamId)
|
||||
: await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
ZOOM
|
||||
);
|
||||
teamId
|
||||
? await this.appsRepository.createTeamAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
teamId,
|
||||
ZOOM
|
||||
)
|
||||
: await this.appsRepository.createAppCredential(
|
||||
ZOOM_TYPE,
|
||||
responseBody as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
ZOOM
|
||||
);
|
||||
|
||||
return { url: state.returnTo ?? "" };
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
|
||||
export class CredentialsRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
async upsertAppCredential(
|
||||
async upsertUserAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
userId: number,
|
||||
@@ -33,14 +33,45 @@ export class CredentialsRepository {
|
||||
});
|
||||
}
|
||||
|
||||
getByTypeAndUserId(type: string, userId: number) {
|
||||
async upsertTeamAppCredential(
|
||||
type: keyof typeof APPS_TYPE_ID_MAPPING,
|
||||
key: Prisma.InputJsonValue,
|
||||
teamId: number,
|
||||
credentialId?: number | null
|
||||
) {
|
||||
return this.dbWrite.prisma.credential.upsert({
|
||||
create: {
|
||||
type,
|
||||
key,
|
||||
teamId,
|
||||
appId: APPS_TYPE_ID_MAPPING[type],
|
||||
},
|
||||
update: {
|
||||
key,
|
||||
invalid: false,
|
||||
},
|
||||
where: {
|
||||
id: credentialId ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
findCredentialByTypeAndUserId(type: string, userId: number) {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
|
||||
}
|
||||
|
||||
getByTypeAndTeamId(type: string, teamId: number) {
|
||||
findAllCredentialsByTypeAndUserId(type: string, userId: number) {
|
||||
return this.dbWrite.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
findCredentialByTypeAndTeamId(type: string, teamId: number) {
|
||||
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
|
||||
}
|
||||
|
||||
findAllCredentialsByTypeAndTeamId(type: string, teamId: number) {
|
||||
return this.dbWrite.prisma.credential.findMany({ where: { type, teamId } });
|
||||
}
|
||||
|
||||
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
|
||||
}
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import {
|
||||
ConferencingAppsOauthUrlOutputDto,
|
||||
GetConferencingAppsOauthUrlResponseDto,
|
||||
} from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url";
|
||||
import {
|
||||
ConferencingAppsOutputResponseDto,
|
||||
ConferencingAppOutputResponseDto,
|
||||
ConferencingAppsOutputDto,
|
||||
DisconnectConferencingAppOutputResponseDto,
|
||||
} from "@/modules/conferencing/outputs/get-conferencing-apps.output";
|
||||
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Query,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
Post,
|
||||
Param,
|
||||
Delete,
|
||||
Headers,
|
||||
Req,
|
||||
ParseIntPipe,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiParam } from "@nestjs/swagger";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
|
||||
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS, OFFICE_365_VIDEO, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
|
||||
export type OAuthCallbackState = {
|
||||
accessToken: string;
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
fromApp?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
};
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Organizations/Teams Conferencing")
|
||||
export class OrganizationsConferencingController {
|
||||
constructor(
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly organizationsConferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET],
|
||||
required: true,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Post("/teams/:teamId/conferencing/:app/connect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Connect your conferencing application to a team" })
|
||||
async connectTeamApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("orgId", ParseIntPipe) orgId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<ConferencingAppOutputResponseDto> {
|
||||
const credential = await this.organizationsConferencingService.connectTeamNonOauthApps({
|
||||
teamId,
|
||||
app,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [ZOOM, OFFICE_365_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/:app/oauth/auth-url")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get OAuth conferencing app's auth url for a team" })
|
||||
async getTeamOAuthUrl(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("teamId") teamId: string,
|
||||
@Param("orgId") orgId: string,
|
||||
@Param("app") app: string,
|
||||
@Query("returnTo") returnTo?: string,
|
||||
@Query("onErrorReturnTo") onErrorReturnTo?: string
|
||||
): Promise<GetConferencingAppsOauthUrlResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state: OAuthCallbackState = {
|
||||
returnTo: returnTo ?? origin,
|
||||
onErrorReturnTo: onErrorReturnTo ?? origin,
|
||||
fromApp: false,
|
||||
accessToken,
|
||||
teamId,
|
||||
orgId,
|
||||
};
|
||||
|
||||
const credential = await this.conferencingService.generateOAuthUrl(app, state);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "List team conferencing applications" })
|
||||
async listTeamConferencingApps(
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<ConferencingAppsOutputResponseDto> {
|
||||
const conferencingApps = await this.organizationsConferencingService.getConferencingApps({
|
||||
teamId,
|
||||
});
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: conferencingApps.map((app) => plainToInstance(ConferencingAppsOutputDto, app)),
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Post("/teams/:teamId/conferencing/:app/default")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Set team default conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
async setTeamDefaultApp(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<SetDefaultConferencingAppOutputResponseDto> {
|
||||
await this.organizationsConferencingService.setDefaultConferencingApp({
|
||||
teamId,
|
||||
app,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Get("/teams/:teamId/conferencing/default")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get team default conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO, CAL_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
async getTeamDefaultApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number
|
||||
): Promise<GetDefaultConferencingAppOutputResponseDto> {
|
||||
const defaultConferencingApp = await this.organizationsConferencingService.getDefaultConferencingApp({
|
||||
teamId,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS, data: defaultConferencingApp };
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
|
||||
@Delete("/teams/:teamId/conferencing/:app/disconnect")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Disconnect team conferencing application" })
|
||||
@ApiParam({
|
||||
name: "app",
|
||||
description: "Conferencing application type",
|
||||
enum: [GOOGLE_MEET, ZOOM, OFFICE_365_VIDEO],
|
||||
required: true,
|
||||
})
|
||||
async disconnectTeamApp(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Param("app") app: string
|
||||
): Promise<DisconnectConferencingAppOutputResponseDto> {
|
||||
await this.organizationsConferencingService.disconnectConferencingApp({
|
||||
teamId,
|
||||
user,
|
||||
app,
|
||||
});
|
||||
|
||||
return { status: SUCCESS_STATUS };
|
||||
}
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { PlatformPlanType } from "@/modules/billing/types";
|
||||
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
|
||||
import { DefaultConferencingAppsOutputDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { GOOGLE_MEET } from "@calcom/platform-constants";
|
||||
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
|
||||
import { teamMetadataSchema } from "@calcom/platform-libraries";
|
||||
import { handleDeleteCredential } from "@calcom/platform-libraries/app-store";
|
||||
|
||||
@Injectable()
|
||||
export class OrganizationsConferencingService {
|
||||
private logger = new Logger("OrganizationsConferencingService");
|
||||
|
||||
constructor(
|
||||
private readonly conferencingRepository: ConferencingRepository,
|
||||
private teamsRepository: TeamsRepository,
|
||||
private usersRepository: UsersRepository,
|
||||
private readonly googleMeetService: GoogleMeetService,
|
||||
private readonly conferencingService: ConferencingService,
|
||||
private readonly platformPlanGuard: PlatformPlanGuard,
|
||||
private readonly isAdminAPIEnabledGuard: IsAdminAPIEnabledGuard,
|
||||
private readonly isOrgGuard: IsOrgGuard,
|
||||
private readonly isTeamInOrg: IsTeamInOrg,
|
||||
private readonly rolesGuard: RolesGuard
|
||||
) {}
|
||||
|
||||
async verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan,
|
||||
}: {
|
||||
user: UserWithProfile;
|
||||
orgId: string;
|
||||
teamId?: string;
|
||||
requiredRole?: string;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<{ orgId: number; teamId: number | null }> {
|
||||
const userWithAdminFlag = {
|
||||
...user,
|
||||
isSystemAdmin: user.role === "ADMIN",
|
||||
};
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("Organization ID is required");
|
||||
}
|
||||
|
||||
await this.ensureOrganizationAccess(orgId);
|
||||
await this.ensureAdminAPIEnabled(orgId);
|
||||
|
||||
// Only validate team if a teamId is provided
|
||||
if (teamId && teamId.trim() !== "") {
|
||||
await this.ensureTeamBelongsToOrg(orgId, teamId);
|
||||
}
|
||||
|
||||
await this.ensureUserRoleAccess(userWithAdminFlag, orgId, teamId, requiredRole);
|
||||
await this.ensurePlatformPlanAccess({ teamId, orgId, user: userWithAdminFlag, minimumPlan });
|
||||
|
||||
const parsedOrgId = parseInt(orgId, 10);
|
||||
// For organization-level operations, teamId might be empty or null
|
||||
const parsedTeamId = teamId && teamId.trim() !== "" ? parseInt(teamId, 10) : null;
|
||||
|
||||
return {
|
||||
orgId: parsedOrgId,
|
||||
teamId: parsedTeamId,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureOrganizationAccess(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isOrgGuard.checkOrgAccess(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Organization validation failed.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAdminAPIEnabled(orgId: string): Promise<void> {
|
||||
const { canAccess } = await this.isAdminAPIEnabledGuard.checkAdminAPIEnabled(orgId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Admin API is not enabled for this organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTeamBelongsToOrg(orgId: string, teamId: string): Promise<void> {
|
||||
const { canAccess } = await this.isTeamInOrg.checkIfTeamIsInOrg(orgId, teamId);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("Team is not part of the organization.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureUserRoleAccess(
|
||||
user: ApiAuthGuardUser,
|
||||
orgId: string,
|
||||
teamId?: string,
|
||||
requiredRole?: string
|
||||
): Promise<void> {
|
||||
if (!requiredRole) return;
|
||||
|
||||
// For team-level roles, we need both orgId and teamId
|
||||
if (requiredRole.startsWith("TEAM_") && (!teamId || teamId.trim() === "")) {
|
||||
throw new ForbiddenException("Team ID is required for team-level role access");
|
||||
}
|
||||
|
||||
// Use empty string for teamId if it's an org-level operation
|
||||
const effectiveTeamId = teamId && teamId.trim() !== "" ? teamId : "";
|
||||
|
||||
const { canAccess } = await this.rolesGuard.checkUserRoleAccess(
|
||||
user,
|
||||
orgId,
|
||||
effectiveTeamId,
|
||||
requiredRole
|
||||
);
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User does not have the required role.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ensurePlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
}: {
|
||||
teamId?: string;
|
||||
orgId?: string;
|
||||
user: ApiAuthGuardUser;
|
||||
minimumPlan?: PlatformPlanType;
|
||||
}): Promise<void> {
|
||||
if (!minimumPlan) return;
|
||||
|
||||
const { canAccess } = await this.platformPlanGuard.checkPlatformPlanAccess({
|
||||
teamId,
|
||||
orgId,
|
||||
user,
|
||||
minimumPlan,
|
||||
});
|
||||
if (!canAccess) {
|
||||
throw new ForbiddenException("User's organization does not meet the required subscription plan.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectTeamNonOauthApps({ teamId, app }: { teamId: number; app: string }): Promise<any> {
|
||||
switch (app) {
|
||||
case GOOGLE_MEET:
|
||||
return this.googleMeetService.connectGoogleMeetToTeam(teamId);
|
||||
default:
|
||||
throw new BadRequestException("Invalid conferencing app. Available apps: GOOGLE_MEET.");
|
||||
}
|
||||
}
|
||||
|
||||
async connectTeamOauthApps({
|
||||
decodedCallbackState,
|
||||
app,
|
||||
code,
|
||||
userId,
|
||||
}: {
|
||||
userId: number;
|
||||
app: string;
|
||||
decodedCallbackState: OAuthCallbackState;
|
||||
code: string;
|
||||
}) {
|
||||
const user = await this.usersRepository.findByIdWithProfile(userId);
|
||||
const { orgId, teamId } = decodedCallbackState;
|
||||
|
||||
if (!orgId) {
|
||||
throw new BadRequestException("orgId required");
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new BadRequestException("user not found");
|
||||
}
|
||||
|
||||
// Determine if this is a team-level or organization-level operation
|
||||
const isTeamLevel = !!teamId;
|
||||
const requiredRole = isTeamLevel ? "TEAM_ADMIN" : "ORG_ADMIN";
|
||||
|
||||
// Verify access with appropriate parameters
|
||||
const { orgId: validatedOrgId, teamId: validatedTeamId } = await this.verifyAccess({
|
||||
user,
|
||||
orgId,
|
||||
teamId,
|
||||
requiredRole,
|
||||
minimumPlan: "ESSENTIALS",
|
||||
});
|
||||
|
||||
const entityId = isTeamLevel && validatedTeamId !== null ? validatedTeamId : validatedOrgId;
|
||||
|
||||
return this.conferencingService.connectOauthApps(app, code, userId, decodedCallbackState, entityId);
|
||||
}
|
||||
|
||||
async getConferencingApps({ teamId }: { teamId: number }) {
|
||||
return this.conferencingRepository.findTeamConferencingApps(teamId);
|
||||
}
|
||||
|
||||
async getDefaultConferencingApp({
|
||||
teamId,
|
||||
}: {
|
||||
teamId: number;
|
||||
}): Promise<DefaultConferencingAppsOutputDto | undefined> {
|
||||
const team = await this.teamsRepository.getById(teamId);
|
||||
return teamMetadataSchema.parse(team?.metadata)?.defaultConferencingApp;
|
||||
}
|
||||
|
||||
async checkAppIsValidAndConnected(teamId: number, app: string) {
|
||||
if (!CONFERENCING_APPS.includes(app)) {
|
||||
throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", "));
|
||||
}
|
||||
const credential = await this.conferencingRepository.findTeamConferencingApp(teamId, app);
|
||||
|
||||
if (!credential) {
|
||||
throw new BadRequestException(`${app} not connected.`);
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
async disconnectConferencingApp({
|
||||
teamId,
|
||||
user,
|
||||
app,
|
||||
}: {
|
||||
teamId: number;
|
||||
user: UserWithProfile;
|
||||
app: string;
|
||||
}) {
|
||||
const credential = await this.checkAppIsValidAndConnected(teamId, app);
|
||||
return handleDeleteCredential({
|
||||
userId: user.id,
|
||||
teamId,
|
||||
userMetadata: user?.metadata,
|
||||
credentialId: credential.id,
|
||||
});
|
||||
}
|
||||
|
||||
async setDefaultConferencingApp({ teamId, app }: { teamId: number; app: string }) {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (app !== CAL_VIDEO) {
|
||||
await this.checkAppIsValidAndConnected(teamId, app);
|
||||
}
|
||||
const team = await this.teamsRepository.setDefaultConferencingApp(teamId, app);
|
||||
const metadata = team.metadata as { defaultConferencingApp?: { appSlug?: string } };
|
||||
if (metadata?.defaultConferencingApp?.appSlug !== app) {
|
||||
throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
@@ -25,13 +26,15 @@ export class InputOrganizationsEventTypesService {
|
||||
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly usersRepository: UsersRepository,
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository
|
||||
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
|
||||
private readonly conferencingService: OrganizationsConferencingService
|
||||
) {}
|
||||
async transformAndValidateCreateTeamEventTypeInput(
|
||||
userId: number,
|
||||
teamId: number,
|
||||
inputEventType: CreateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
|
||||
@@ -62,6 +65,7 @@ export class InputOrganizationsEventTypesService {
|
||||
teamId: number,
|
||||
inputEventType: UpdateTeamEventTypeInput_2024_06_14
|
||||
) {
|
||||
await this.validateInputLocations(teamId, inputEventType.locations);
|
||||
await this.validateHosts(teamId, inputEventType.hosts);
|
||||
if (inputEventType.slug) {
|
||||
await this.validateTeamEventTypeSlug(teamId, inputEventType.slug);
|
||||
@@ -293,6 +297,22 @@ export class InputOrganizationsEventTypesService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async validateInputLocations(
|
||||
teamId: number,
|
||||
inputLocations?: CreateTeamEventTypeInput_2024_06_14["locations"]
|
||||
) {
|
||||
await Promise.all(
|
||||
inputLocations?.map(async (location) => {
|
||||
if (location.type === "integration") {
|
||||
// cal-video is global, so we can skip this check
|
||||
if (location.integration !== "cal-video") {
|
||||
await this.conferencingService.checkAppIsValidAndConnected(teamId, location.integration);
|
||||
}
|
||||
}
|
||||
}) ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityValue(priority: keyof typeof HostPriority): number {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { EmailModule } from "@/modules/email/email.module";
|
||||
import { EmailService } from "@/modules/email/email.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
@@ -11,6 +13,8 @@ import { OrganizationAttributesService } from "@/modules/organizations/attribute
|
||||
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
|
||||
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
|
||||
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
|
||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
|
||||
@@ -52,7 +56,7 @@ import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -65,6 +69,8 @@ import { Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_06_14,
|
||||
TeamsEventTypesModule,
|
||||
TeamsModule,
|
||||
forwardRef(() => ConferencingModule),
|
||||
forwardRef(() => AuthModule),
|
||||
OrganizationsDelegationCredentialModule,
|
||||
OrganizationsOrganizationsModule,
|
||||
OrganizationsTeamsRoutingFormsModule,
|
||||
@@ -101,6 +107,7 @@ import { Module } from "@nestjs/common";
|
||||
UserOOORepository,
|
||||
OrgUsersOOOService,
|
||||
OrgUsersOOORepository,
|
||||
OrganizationsConferencingService,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
@@ -121,6 +128,7 @@ import { Module } from "@nestjs/common";
|
||||
WebhooksRepository,
|
||||
WebhooksService,
|
||||
OrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
],
|
||||
controllers: [
|
||||
OrganizationsTeamsController,
|
||||
@@ -134,6 +142,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsWebhooksController,
|
||||
OrganizationsTeamsSchedulesController,
|
||||
OrganizationsUsersOOOController,
|
||||
OrganizationsConferencingController,
|
||||
],
|
||||
})
|
||||
export class OrganizationsModule {}
|
||||
@@ -142,13 +142,19 @@ export class StripeService {
|
||||
}
|
||||
|
||||
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
|
||||
"stripe_payment",
|
||||
userId
|
||||
);
|
||||
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId);
|
||||
const stripeCredentials = await this.credentialRepository.findCredentialByTypeAndTeamId(
|
||||
"stripe_payment",
|
||||
teamId
|
||||
);
|
||||
|
||||
return await this.validateStripeCredentials(stripeCredentials);
|
||||
}
|
||||
|
||||
+42
@@ -217,6 +217,32 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should not allow creating an event type with integration not installed on team", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: "Coding consultation",
|
||||
slug: "coding-consultation",
|
||||
description: "Our team will review your codebase.",
|
||||
lengthInMinutes: 60,
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "zoom",
|
||||
},
|
||||
],
|
||||
schedulingType: "COLLECTIVE",
|
||||
hosts: [
|
||||
{
|
||||
userId: teamMember1.id,
|
||||
},
|
||||
{
|
||||
userId: teamMember2.id,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer()).post(`/v2/teams/${team.id}/event-types`).send(body).expect(400);
|
||||
});
|
||||
|
||||
it("should create a collective team event-type", async () => {
|
||||
const body: CreateTeamEventTypeInput_2024_06_14 = {
|
||||
title: `teams-event-types-collective-${randomString()}`,
|
||||
@@ -447,6 +473,22 @@ describe("Organizations Event Types Endpoints", () => {
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not allow to update event type with integration not installed on team", async () => {
|
||||
const body: UpdateTeamEventTypeInput_2024_06_14 = {
|
||||
locations: [
|
||||
{
|
||||
type: "integration",
|
||||
integration: "office365-video",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.patch(`/v2/teams/${team.id}/event-types/${collectiveEventType.id}`)
|
||||
.send(body)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should update collective event-type", async () => {
|
||||
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { AuthModule } from "@/modules/auth/auth.module";
|
||||
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
|
||||
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/output.service";
|
||||
@@ -11,7 +14,7 @@ import { TeamsEventTypesService } from "@/modules/teams/event-types/services/tea
|
||||
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { forwardRef, Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,6 +24,8 @@ import { Module } from "@nestjs/common";
|
||||
EventTypesModule_2024_06_14,
|
||||
UsersModule,
|
||||
TeamsModule,
|
||||
forwardRef(() => ConferencingModule),
|
||||
forwardRef(() => AuthModule),
|
||||
],
|
||||
providers: [
|
||||
TeamsEventTypesRepository,
|
||||
@@ -29,6 +34,7 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsTeamsRepository,
|
||||
OutputTeamEventTypesResponsePipe,
|
||||
OutputOrganizationsEventTypesService,
|
||||
OrganizationsConferencingService,
|
||||
],
|
||||
exports: [TeamsEventTypesRepository, TeamsEventTypesService],
|
||||
controllers: [TeamsEventTypesController],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { teamMetadataSchema } from "@calcom/platform-libraries";
|
||||
import { Prisma } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
@@ -83,4 +84,30 @@ export class TeamsRepository {
|
||||
where: { id: teamId },
|
||||
});
|
||||
}
|
||||
|
||||
async setDefaultConferencingApp(teamId: number, appSlug?: string, appLink?: string) {
|
||||
const team = await this.getById(teamId);
|
||||
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException("user not found");
|
||||
}
|
||||
|
||||
return await this.dbWrite.prisma.team.update({
|
||||
data: {
|
||||
metadata:
|
||||
typeof teamMetadata === "object"
|
||||
? {
|
||||
...teamMetadata,
|
||||
defaultConferencingApp: {
|
||||
appSlug: appSlug,
|
||||
appLink: appLink,
|
||||
},
|
||||
}
|
||||
: {},
|
||||
},
|
||||
|
||||
where: { id: teamId },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,8 @@ export function createAppsFixture(page: Page) {
|
||||
await page.click('[data-testid="install-app-button-personal"]');
|
||||
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
for (const id of eventTypeIds) {
|
||||
await page.click(`[data-testid="select-event-type-${id}"]`);
|
||||
}
|
||||
@@ -79,6 +81,8 @@ export function createAppsFixture(page: Page) {
|
||||
await page.getByTestId("install-app-button").click();
|
||||
await page.waitForURL(`apps/installation/event-types?slug=${app.slug}`);
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
for (const id of eventTypeIds) {
|
||||
await page.click(`[data-testid="select-event-type-${id}"]`);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
|
||||
import { getBulkUserEventTypes } from "@calcom/lib/event-types/getBulkEventTypes";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const setDefaultConferencingApp = async (userId: number, appSlug: string) => {
|
||||
const eventTypes = await getBulkEventTypes(userId);
|
||||
const eventTypes = await getBulkUserEventTypes(userId);
|
||||
const eventTypeIds = eventTypes.eventTypes.map((item) => item.id);
|
||||
const foundApp = getAppFromSlug(appSlug);
|
||||
const appType = foundApp?.appData?.location?.type;
|
||||
|
||||
@@ -99,7 +99,7 @@ export const AppList = ({
|
||||
<Button StartIcon="ellipsis" variant="icon" color="secondary" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
{!appIsDefault && variant === "conferencing" && !item.credentialOwner?.teamId && (
|
||||
{!appIsDefault && variant === "conferencing" && (
|
||||
<DropdownMenuItem>
|
||||
<DropdownItem
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const bulkUpdateTeamEventsToDefaultLocation = async ({
|
||||
eventTypeIds,
|
||||
teamId,
|
||||
prisma,
|
||||
}: {
|
||||
eventTypeIds: number[];
|
||||
teamId: number;
|
||||
prisma: PrismaClient;
|
||||
}) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: { id: teamId },
|
||||
select: { metadata: true },
|
||||
});
|
||||
const defaultApp = teamMetadataSchema.parse(team?.metadata)?.defaultConferencingApp;
|
||||
|
||||
if (!defaultApp) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Default conferencing app not set",
|
||||
});
|
||||
}
|
||||
|
||||
const foundApp = getAppFromSlug(defaultApp.appSlug);
|
||||
const appType = foundApp?.appData?.location?.type;
|
||||
if (!appType) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Default conferencing app '${defaultApp.appSlug}' doesnt exist.`,
|
||||
});
|
||||
}
|
||||
|
||||
const credential = await prisma.credential.findFirst({
|
||||
where: {
|
||||
teamId,
|
||||
appId: foundApp.slug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
return await prisma.eventType.updateMany({
|
||||
where: {
|
||||
id: {
|
||||
in: eventTypeIds,
|
||||
},
|
||||
teamId,
|
||||
},
|
||||
data: {
|
||||
locations: [
|
||||
{ type: appType, link: defaultApp.appLink, credentialId: credential?.id },
|
||||
] as LocationObject[],
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -2,19 +2,11 @@ import { getAppFromLocationValue } from "@calcom/app-store/utils";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { eventTypeLocations as eventTypeLocationsSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const getBulkEventTypes = async (userId: number) => {
|
||||
const eventTypes = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId,
|
||||
team: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
locations: true,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Process event types to add logo information
|
||||
* @param eventTypes - The event types to process
|
||||
*/
|
||||
const processEventTypes = (eventTypes: { id: number; title: string; locations: unknown }[]) => {
|
||||
const eventTypesWithLogo = eventTypes.map((eventType) => {
|
||||
const locationParsed = eventTypeLocationsSchema.safeParse(eventType.locations);
|
||||
|
||||
@@ -35,4 +27,34 @@ const getBulkEventTypes = async (userId: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
export default getBulkEventTypes;
|
||||
export const getBulkUserEventTypes = async (userId: number) => {
|
||||
const eventTypes = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: userId,
|
||||
teamId: null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
locations: true,
|
||||
},
|
||||
});
|
||||
|
||||
return processEventTypes(eventTypes);
|
||||
};
|
||||
|
||||
export const getBulkTeamEventTypes = async (teamId: number) => {
|
||||
const eventTypes = await prisma.eventType.findMany({
|
||||
where: {
|
||||
userId: null,
|
||||
teamId: teamId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
locations: true,
|
||||
},
|
||||
});
|
||||
|
||||
return processEventTypes(eventTypes);
|
||||
};
|
||||
@@ -3,6 +3,8 @@ import { z } from "zod";
|
||||
const integrationsApiAvailable = {
|
||||
"cal-video": z.literal("integrations:daily"),
|
||||
"google-meet": z.literal("integrations:google:meet"),
|
||||
zoom: z.literal("integrations:zoom"),
|
||||
"office365-video": z.literal("integrations:office365_video"),
|
||||
};
|
||||
|
||||
// note(Lauris): these are read only aka they exist in database and are to be returned by a READ operation
|
||||
|
||||
@@ -137,6 +137,7 @@ export async function getConnectedApps({
|
||||
const teamAppCredentials = userTeams.flatMap((teamApp) => {
|
||||
return teamApp.credentials ? buildNonDelegationCredentials(teamApp.credentials.flat()) : [];
|
||||
});
|
||||
|
||||
if (!includeTeamInstalledApps || teamId) {
|
||||
credentials = teamAppCredentials;
|
||||
} else {
|
||||
|
||||
+13
-5
@@ -46,6 +46,7 @@ type ConferencingAppsViewPlatformWrapperProps = {
|
||||
disableToasts?: boolean;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
type RemoveAppParams = { callback: () => void; app?: App["slug"] };
|
||||
@@ -71,6 +72,7 @@ export const ConferencingAppsViewPlatformWrapper = ({
|
||||
disableToasts = false,
|
||||
returnTo,
|
||||
onErrorReturnTo,
|
||||
teamId,
|
||||
}: ConferencingAppsViewPlatformWrapperProps) => {
|
||||
const { t } = useLocale();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -101,9 +103,9 @@ export const ConferencingAppsViewPlatformWrapper = ({
|
||||
updateModal({ isOpen: true, credentialId, app });
|
||||
};
|
||||
|
||||
const installedIntegrationsQuery = useAtomsGetInstalledConferencingApps();
|
||||
const { data: defaultConferencingApp } = useGetDefaultConferencingApp();
|
||||
const { data: eventTypesQuery, isFetching: isEventTypesFetching } = useAtomGetEventTypes();
|
||||
const installedIntegrationsQuery = useAtomsGetInstalledConferencingApps(teamId);
|
||||
const { data: defaultConferencingApp } = useGetDefaultConferencingApp(teamId);
|
||||
const { data: eventTypesQuery, isFetching: isEventTypesFetching } = useAtomGetEventTypes(teamId);
|
||||
|
||||
const deleteCredentialMutation = useDeleteCredential({
|
||||
onSuccess: () => {
|
||||
@@ -120,11 +122,16 @@ export const ConferencingAppsViewPlatformWrapper = ({
|
||||
showToast(t("error_removing_app"), "error");
|
||||
handleModelClose();
|
||||
},
|
||||
teamId,
|
||||
});
|
||||
|
||||
const updateDefaultAppMutation = useUpdateUserDefaultConferencingApp({});
|
||||
const updateDefaultAppMutation = useUpdateUserDefaultConferencingApp({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const bulkUpdateEventTypesToDefaultLocation = useAtomBulkUpdateEventTypesToDefaultLocation({});
|
||||
const bulkUpdateEventTypesToDefaultLocation = useAtomBulkUpdateEventTypesToDefaultLocation({
|
||||
teamId,
|
||||
});
|
||||
|
||||
const handleRemoveApp = ({ app }: RemoveAppParams) => {
|
||||
!!app && deleteCredentialMutation.mutate(app);
|
||||
@@ -176,6 +183,7 @@ export const ConferencingAppsViewPlatformWrapper = ({
|
||||
},
|
||||
returnTo,
|
||||
onErrorReturnTo,
|
||||
teamId,
|
||||
});
|
||||
|
||||
const AddConferencingButtonPlatform = ({ installedApps }: { installedApps?: Array<{ slug: string }> }) => {
|
||||
|
||||
+9
-1
@@ -4,6 +4,7 @@ import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { ApiSuccessResponse } from "@calcom/platform-types";
|
||||
import type { EventType } from "@calcom/prisma/client";
|
||||
|
||||
import { useAtomsContext } from "../../../hooks/useAtomsContext";
|
||||
import http from "../../../lib/http";
|
||||
|
||||
export const QUERY_KEY = "bulk-update-event-types-to-default-location";
|
||||
@@ -12,6 +13,7 @@ export type UseAtomBulkUpdateEventTypesToDefaultLocationProps = {
|
||||
onSuccess?: () => void;
|
||||
onError?: (err: Error) => void;
|
||||
onSettled?: () => void;
|
||||
teamId?: number;
|
||||
};
|
||||
export const useAtomBulkUpdateEventTypesToDefaultLocation = ({
|
||||
onSuccess = () => {
|
||||
@@ -23,14 +25,20 @@ export const useAtomBulkUpdateEventTypesToDefaultLocation = ({
|
||||
onSettled = () => {
|
||||
return;
|
||||
},
|
||||
teamId,
|
||||
}: UseAtomBulkUpdateEventTypesToDefaultLocationProps) => {
|
||||
const { organizationId } = useAtomsContext();
|
||||
return useMutation({
|
||||
onSuccess,
|
||||
onError,
|
||||
onSettled,
|
||||
mutationFn: (eventTypeIds: number[]) => {
|
||||
if (!eventTypeIds || eventTypeIds.length < 1) throw new Error("Event type ids are required");
|
||||
const pathname = `/atoms/${V2_ENDPOINTS.eventTypes}/bulk-update-to-default-location`;
|
||||
let pathname = `/atoms/${V2_ENDPOINTS.eventTypes}/bulk-update-to-default-location`;
|
||||
|
||||
if (teamId) {
|
||||
pathname = `/atoms/organizations/${organizationId}/teams/${teamId}/${V2_ENDPOINTS.eventTypes}/bulk-update-to-default-location`;
|
||||
}
|
||||
|
||||
return http?.patch(pathname, { eventTypeIds }).then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { V2_ENDPOINTS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
import { useAtomsContext } from "../../../hooks/useAtomsContext";
|
||||
@@ -10,12 +10,17 @@ export const QUERY_KEY = "use-get-event-types";
|
||||
|
||||
type ResponseEventType = { eventTypes: Array<{ id: number; title: string }> };
|
||||
|
||||
export const useAtomGetEventTypes = () => {
|
||||
const pathname = `/atoms/${V2_ENDPOINTS.eventTypes}`;
|
||||
const { isInit, accessToken } = useAtomsContext();
|
||||
export const useAtomGetEventTypes = (teamId?: number) => {
|
||||
const { isInit, accessToken, organizationId } = useAtomsContext();
|
||||
|
||||
let pathname = "/atoms/event-types";
|
||||
|
||||
if (teamId) {
|
||||
pathname = `/atoms/organizations/${organizationId}/teams/${teamId}/event-types`;
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY],
|
||||
queryKey: [QUERY_KEY, teamId, organizationId],
|
||||
queryFn: () => {
|
||||
return http?.get<ApiResponse<ResponseEventType>>(pathname).then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
|
||||
+21
-11
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import type { ConnectedApps } from "@calcom/lib/getConnectedApps";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { ConnectedApps } from "@calcom/platform-libraries";
|
||||
import type { ApiResponse, ApiSuccessResponse } from "@calcom/platform-types";
|
||||
|
||||
import { useAtomsContext } from "../../../hooks/useAtomsContext";
|
||||
@@ -9,19 +9,29 @@ import http from "../../../lib/http";
|
||||
|
||||
export const QUERY_KEY = "get-installed-conferencing-apps";
|
||||
|
||||
export const useAtomsGetInstalledConferencingApps = () => {
|
||||
const pathname = `/atoms/conferencing`;
|
||||
const { isInit, accessToken } = useAtomsContext();
|
||||
export const useAtomsGetInstalledConferencingApps = (teamId?: number) => {
|
||||
const { isInit, accessToken, organizationId } = useAtomsContext();
|
||||
|
||||
let pathname = "/atoms/conferencing";
|
||||
|
||||
if (teamId) {
|
||||
pathname = `/atoms/organizations/${organizationId}/teams/${teamId}/conferencing`;
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY],
|
||||
queryKey: [QUERY_KEY, teamId, organizationId],
|
||||
queryFn: () => {
|
||||
return http?.get<ApiResponse<ConnectedApps>>(pathname).then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
return (res.data as ApiSuccessResponse<ConnectedApps>).data;
|
||||
}
|
||||
throw new Error(res.data.error.message);
|
||||
});
|
||||
return http
|
||||
?.get<ApiResponse<ConnectedApps>>(pathname)
|
||||
.then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
return (res.data as ApiSuccessResponse<ConnectedApps>).data;
|
||||
}
|
||||
throw new Error(res.data.error.message);
|
||||
})
|
||||
.catch(() => {
|
||||
throw new Error("Failed to get installed conferencing apps");
|
||||
});
|
||||
},
|
||||
enabled: isInit && !!accessToken,
|
||||
});
|
||||
|
||||
@@ -11,55 +11,76 @@ import {
|
||||
import type { ApiErrorResponse, ApiResponse } from "@calcom/platform-types";
|
||||
import type { App } from "@calcom/types/App";
|
||||
|
||||
import { useAtomsContext } from "../../../hooks/useAtomsContext";
|
||||
import http from "../../../lib/http";
|
||||
|
||||
export type UseGetOauthAuthUrlProps = {
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const useGetZoomOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => {
|
||||
export const useGetZoomOauthAuthUrl = ({ returnTo, onErrorReturnTo, teamId }: UseGetOauthAuthUrlProps) => {
|
||||
const { organizationId } = useAtomsContext();
|
||||
let pathname = `conferencing/${ZOOM}/oauth/auth-url`;
|
||||
|
||||
if (teamId) {
|
||||
pathname = `organizations/${organizationId}/teams/${teamId}/conferencing/${ZOOM}/oauth/auth-url`;
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (returnTo) queryParams.append("returnTo", returnTo);
|
||||
if (onErrorReturnTo) queryParams.append("onErrorReturnTo", onErrorReturnTo);
|
||||
|
||||
const fullPath = queryParams.toString() ? `${pathname}?${queryParams.toString()}` : pathname;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["get-zoom-auth-url"],
|
||||
queryKey: ["get-zoom-auth-url", teamId, organizationId],
|
||||
staleTime: Infinity,
|
||||
enabled: false,
|
||||
queryFn: () => {
|
||||
return http
|
||||
?.get<ApiResponse<{ url: string }>>(
|
||||
`conferencing/${ZOOM}/oauth/auth-url${returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ""}${
|
||||
onErrorReturnTo ? `&onErrorReturnTo=${encodeURIComponent(onErrorReturnTo)}` : ""
|
||||
}`
|
||||
)
|
||||
.then(({ data: responseBody }) => {
|
||||
if (responseBody.status === SUCCESS_STATUS) {
|
||||
return responseBody.data.url;
|
||||
}
|
||||
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
|
||||
return "";
|
||||
});
|
||||
return http?.get<ApiResponse<{ url: string }>>(fullPath).then(({ data: responseBody }) => {
|
||||
if (responseBody.status === SUCCESS_STATUS) {
|
||||
return responseBody.data.url;
|
||||
}
|
||||
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
|
||||
return "";
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useOffice365GetOauthAuthUrl = ({ returnTo, onErrorReturnTo }: UseGetOauthAuthUrlProps) => {
|
||||
export const useOffice365GetOauthAuthUrl = ({
|
||||
returnTo,
|
||||
onErrorReturnTo,
|
||||
teamId,
|
||||
}: UseGetOauthAuthUrlProps) => {
|
||||
const { organizationId } = useAtomsContext();
|
||||
let pathname = `conferencing/${OFFICE_365_VIDEO}/oauth/auth-url`;
|
||||
|
||||
if (teamId) {
|
||||
pathname = `organizations/${organizationId}/teams/${teamId}/conferencing/${OFFICE_365_VIDEO}/oauth/auth-url`;
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
const queryParams = new URLSearchParams();
|
||||
if (returnTo) queryParams.append("returnTo", returnTo);
|
||||
if (onErrorReturnTo) queryParams.append("onErrorReturnTo", onErrorReturnTo);
|
||||
|
||||
const fullPath = queryParams.toString() ? `${pathname}?${queryParams.toString()}` : pathname;
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["get-office365-auth-url"],
|
||||
queryKey: ["get-office365-auth-url", teamId, organizationId],
|
||||
staleTime: Infinity,
|
||||
enabled: false,
|
||||
queryFn: () => {
|
||||
return http
|
||||
?.get<ApiResponse<{ url: string }>>(
|
||||
`conferencing/${OFFICE_365_VIDEO}/oauth/auth-url${
|
||||
returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ""
|
||||
}${onErrorReturnTo ? `&onErrorReturnTo=${encodeURIComponent(onErrorReturnTo)}` : ""}`
|
||||
)
|
||||
.then(({ data: responseBody }) => {
|
||||
if (responseBody.status === SUCCESS_STATUS) {
|
||||
return responseBody.data.url;
|
||||
}
|
||||
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
|
||||
return "";
|
||||
});
|
||||
return http?.get<ApiResponse<{ url: string }>>(fullPath).then(({ data: responseBody }) => {
|
||||
if (responseBody.status === SUCCESS_STATUS) {
|
||||
return responseBody.data.url;
|
||||
}
|
||||
if (responseBody.status === ERROR_STATUS) throw new Error(responseBody.error.message);
|
||||
return "";
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -69,10 +90,11 @@ export type UseConnectGoogleMeetProps = {
|
||||
onError?: (err: ApiErrorResponse) => void;
|
||||
returnTo?: string;
|
||||
onErrorReturnTo?: string;
|
||||
teamId?: number;
|
||||
};
|
||||
|
||||
export const useConnectNonOauthApp = (
|
||||
{ onSuccess, onError }: UseConnectGoogleMeetProps = {
|
||||
{ onSuccess, onError, teamId, returnTo, onErrorReturnTo }: UseConnectGoogleMeetProps = {
|
||||
onSuccess: () => {
|
||||
return;
|
||||
},
|
||||
@@ -81,10 +103,21 @@ export const useConnectNonOauthApp = (
|
||||
},
|
||||
}
|
||||
) => {
|
||||
const { organizationId } = useAtomsContext();
|
||||
return useMutation({
|
||||
mutationFn: (app: string) => {
|
||||
const pathname = `/conferencing/${app}/connect`;
|
||||
return http?.post(pathname).then((res) => {
|
||||
let pathname = `/conferencing/${app}/connect`;
|
||||
|
||||
if (teamId) {
|
||||
pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/connect`;
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams();
|
||||
if (returnTo) queryParams.append("returnTo", returnTo);
|
||||
if (onErrorReturnTo) queryParams.append("onErrorReturnTo", onErrorReturnTo);
|
||||
|
||||
const fullPath = queryParams.toString() ? `${pathname}?${queryParams.toString()}` : pathname;
|
||||
return http?.post(fullPath).then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
return { status: res.data };
|
||||
} else {
|
||||
@@ -97,9 +130,9 @@ export const useConnectNonOauthApp = (
|
||||
});
|
||||
};
|
||||
|
||||
export const useConnect = ({ returnTo, onErrorReturnTo, ...props }: UseConnectGoogleMeetProps) => {
|
||||
const { refetch: refetchZoomAuthUrl } = useGetZoomOauthAuthUrl({ returnTo, onErrorReturnTo });
|
||||
const { refetch: refetchOffice365AuthUrl } = useOffice365GetOauthAuthUrl({ returnTo, onErrorReturnTo });
|
||||
export const useConnect = (props: UseConnectGoogleMeetProps) => {
|
||||
const { refetch: refetchZoomAuthUrl } = useGetZoomOauthAuthUrl(props);
|
||||
const { refetch: refetchOffice365AuthUrl } = useOffice365GetOauthAuthUrl(props);
|
||||
const connectNonOauthApp = useConnectNonOauthApp(props);
|
||||
|
||||
const connect = async (app: App["slug"]) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { App } from "@calcom/types/App";
|
||||
|
||||
import { useAtomsContext } from "../../../hooks/useAtomsContext";
|
||||
import http from "../../../lib/http";
|
||||
|
||||
export const QUERY_KEY = "use-delete-credential";
|
||||
@@ -10,15 +11,22 @@ export type UseDeleteEventTypeProps = {
|
||||
onSuccess?: () => void;
|
||||
onError?: (err: Error) => void;
|
||||
onSettled?: () => void;
|
||||
teamId?: number;
|
||||
};
|
||||
export const useDeleteCredential = ({ onSuccess, onError, onSettled }: UseDeleteEventTypeProps) => {
|
||||
export const useDeleteCredential = ({ onSuccess, onError, onSettled, teamId }: UseDeleteEventTypeProps) => {
|
||||
const { organizationId } = useAtomsContext();
|
||||
return useMutation({
|
||||
onSuccess,
|
||||
onError,
|
||||
onSettled,
|
||||
mutationFn: (app: App["slug"]) => {
|
||||
if (!app) throw new Error("app is required");
|
||||
const pathname = `/conferencing/${app}/disconnect`;
|
||||
|
||||
let pathname = `/conferencing/${app}/disconnect`;
|
||||
|
||||
if (teamId) {
|
||||
pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/disconnect`;
|
||||
}
|
||||
return http?.delete(pathname).then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
return;
|
||||
|
||||
+9
-4
@@ -15,12 +15,17 @@ type ResponseDataType =
|
||||
appLink?: string;
|
||||
}
|
||||
| undefined;
|
||||
export const useGetDefaultConferencingApp = () => {
|
||||
const pathname = `/conferencing/default`;
|
||||
const { isInit, accessToken } = useAtomsContext();
|
||||
export const useGetDefaultConferencingApp = (teamId?: number) => {
|
||||
const { isInit, accessToken, organizationId } = useAtomsContext();
|
||||
|
||||
let pathname = `/conferencing/default`;
|
||||
|
||||
if (teamId) {
|
||||
pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/default`;
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: [QUERY_KEY],
|
||||
queryKey: [QUERY_KEY, teamId, organizationId],
|
||||
queryFn: () => {
|
||||
return http?.get<ApiResponse<ResponseDataType>>(pathname).then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
|
||||
+10
-1
@@ -3,6 +3,7 @@ import { useMutation } from "@tanstack/react-query";
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { App } from "@calcom/types/App";
|
||||
|
||||
import { useAtomsContext } from "../../../hooks/useAtomsContext";
|
||||
import http from "../../../lib/http";
|
||||
|
||||
export const QUERY_KEY = "use-update-user-default-conferencing-app";
|
||||
@@ -10,19 +11,27 @@ export type UseUpdateUserDefaultConferencingAppProps = {
|
||||
onSuccess?: () => void;
|
||||
onError?: (err: Error) => void;
|
||||
onSettled?: () => void;
|
||||
teamId?: number;
|
||||
};
|
||||
export const useUpdateUserDefaultConferencingApp = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
onSettled,
|
||||
teamId,
|
||||
}: UseUpdateUserDefaultConferencingAppProps) => {
|
||||
const { organizationId } = useAtomsContext();
|
||||
return useMutation({
|
||||
onSuccess,
|
||||
onError,
|
||||
onSettled,
|
||||
mutationFn: (app: App["slug"]) => {
|
||||
if (!app) throw new Error("app is required");
|
||||
const pathname = `/conferencing/${app}/default`;
|
||||
|
||||
let pathname = `/conferencing/${app}/default`;
|
||||
|
||||
if (teamId) {
|
||||
pathname = `/organizations/${organizationId}/teams/${teamId}/conferencing/${app}/default`;
|
||||
}
|
||||
return http?.post(pathname).then((res) => {
|
||||
if (res.data.status === SUCCESS_STATUS) {
|
||||
return;
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function ConferencingApps(props: { calUsername: string; calEmail:
|
||||
className={`flex min-h-screen flex-col ${inter.className} main text-default flex min-h-full w-full flex-col items-center overflow-visible`}>
|
||||
<Navbar username={props.calUsername} />
|
||||
<div className="my-8">
|
||||
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} />
|
||||
<ConferencingAppsSettings returnTo={callbackUri} onErrorReturnTo={callbackUri} teamId={35} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
|
||||
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
|
||||
|
||||
export { getBulkUserEventTypes, getBulkTeamEventTypes } from "@calcom/lib/event-types/getBulkEventTypes";
|
||||
|
||||
export { createHandler as createEventType } from "@calcom/trpc/server/routers/viewer/eventTypes/create.handler";
|
||||
export { updateHandler as updateEventType } from "@calcom/trpc/server/routers/viewer/eventTypes/update.handler";
|
||||
@@ -54,7 +55,11 @@ export type {
|
||||
InternalLocation,
|
||||
} from "@calcom/lib/event-types/transformers";
|
||||
|
||||
export { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
export {
|
||||
EventTypeMetaDataSchema,
|
||||
eventTypeBookingFields,
|
||||
eventTypeLocations,
|
||||
} from "@calcom/prisma/zod-utils";
|
||||
|
||||
export { validateCustomEventName } from "@calcom/lib/event";
|
||||
|
||||
@@ -63,11 +68,9 @@ export { getEventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByVi
|
||||
export type { EventType } from "@calcom/lib/event-types/getEventTypeById";
|
||||
export type { EventTypesByViewer } from "@calcom/lib/event-types/getEventTypesByViewer";
|
||||
export type { UpdateEventTypeReturn } from "@calcom/trpc/server/routers/viewer/eventTypes/update.handler";
|
||||
export { eventTypeBookingFields, eventTypeLocations } from "@calcom/prisma/zod-utils";
|
||||
export { updateNewTeamMemberEventTypes } from "@calcom/lib/server/queries";
|
||||
|
||||
export { getBulkEventTypes };
|
||||
|
||||
export type PublicEventType = Awaited<ReturnType<typeof getPublicEvent>>;
|
||||
export { bulkUpdateEventsToDefaultLocation } from "@calcom/lib/bulkUpdateEventsToDefaultLocation";
|
||||
export { bulkUpdateTeamEventsToDefaultLocation } from "@calcom/lib/bulkUpdateTeamEventsToDefaultLocation";
|
||||
export { getPublicEvent };
|
||||
@@ -61,7 +61,7 @@ export { getAllUserBookings };
|
||||
export { getBookingInfo };
|
||||
export { handleCancelBooking };
|
||||
|
||||
export { userMetadata, bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
export { userMetadata, bookingMetadataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export { parseBookingLimit } from "@calcom/lib/intervalLimits/isBookingLimits";
|
||||
|
||||
|
||||
@@ -375,6 +375,7 @@ export enum BillingPeriod {
|
||||
|
||||
export const teamMetadataSchema = z
|
||||
.object({
|
||||
defaultConferencingApp: schemaDefaultConferencingApp.optional(),
|
||||
requestedSlug: z.string().or(z.null()),
|
||||
paymentId: z.string(),
|
||||
subscriptionId: z.string().nullable(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import getBulkEventTypes from "@calcom/lib/event-types/getBulkEventTypes";
|
||||
import { getBulkUserEventTypes } from "@calcom/lib/event-types/getBulkEventTypes";
|
||||
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
|
||||
@@ -9,5 +9,5 @@ type BulkEventFetchOptions = {
|
||||
};
|
||||
|
||||
export const bulkEventFetchHandler = async ({ ctx }: BulkEventFetchOptions) => {
|
||||
return getBulkEventTypes(ctx.user.id);
|
||||
return getBulkUserEventTypes(ctx.user.id);
|
||||
};
|
||||
@@ -2297,7 +2297,7 @@ __metadata:
|
||||
"@axiomhq/winston": ^1.2.0
|
||||
"@calcom/platform-constants": "*"
|
||||
"@calcom/platform-enums": "*"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.164"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.166"
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2"
|
||||
"@calcom/platform-types": "*"
|
||||
"@calcom/platform-utils": "*"
|
||||
@@ -3336,13 +3336,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.164":
|
||||
version: 0.0.164
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.164"
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.166":
|
||||
version: 0.0.166
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.166"
|
||||
dependencies:
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
checksum: f612a3e2002950ed00d3479a121b92ee3245f9cb00b663824cac45bb5ab8f74182271377cd8866563a5d11983bc8af08072bfdb58bf459e1c7cd2ec025a50466
|
||||
checksum: 4c00e7ea01b099503fcab4da5b5808ccdb905699880fac36981e96836b037be8ef54c3bca82fa3a23dfa2f1c7eca3f6db5d5def41059e324ec504182efe43164
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in new issue
Block a user