feat: Platform OAuthClient Webhooks (#16134)

* wip

* fixup! Merge branch 'main' into platform-oauth-client-webhooks

* wip

* fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! fixup! fixup! fixup! fixup! fixup! fixup! Merge branch 'main' into platform-oauth-client-webhooks

* fixup! Merge branch 'platform-oauth-client-webhooks' of github.com:calcom/cal.com into platform-oauth-client-webhooks

* fixup! fixup! Merge branch 'platform-oauth-client-webhooks' of github.com:calcom/cal.com into platform-oauth-client-webhooks

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
This commit is contained in:
Morgan
2024-08-09 17:02:39 +02:00
committed by GitHub
co-authored by Peer Richelsen
parent 4def31e98f
commit dda4b17a7c
47 changed files with 1199 additions and 68 deletions
@@ -1,6 +1,6 @@
import { Injectable } from "@nestjs/common";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.26";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries";
@Injectable()
export class EmailService {
@@ -0,0 +1,281 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { UserWithProfile } from "@/modules/users/users.repository";
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import {
OAuthClientWebhooksOutputResponseDto,
OAuthClientWebhookOutputResponseDto,
} from "@/modules/webhooks/outputs/oauth-client-webhook.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
import { withNextAuth } from "test/utils/withNextAuth";
import { PlatformOAuthClient, Team, Webhook } from "@calcom/prisma/client";
describe("EventTypes WebhooksController (e2e)", () => {
let app: INestApplication;
const userEmail = "event-types-webhook-controller-e2e@api.com";
const otherUserEmail = "other-event-types-webhook-controller-e2e@api.com";
let user: UserWithProfile;
let otherUser: UserWithProfile;
let oAuthClient: PlatformOAuthClient;
let otherOAuthClient: PlatformOAuthClient;
let org: Team;
let otherOrg: Team;
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let webhookRepositoryFixture: WebhookRepositoryFixture;
let otherOAuthClientWebhook: Webhook;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let orgRepositoryFixture: OrganizationRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let webhook: OAuthClientWebhookOutputResponseDto["data"];
beforeAll(async () => {
const moduleRef = await withNextAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef);
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
orgRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
otherUser = await userRepositoryFixture.create({
email: otherUserEmail,
username: otherUserEmail,
});
org = await orgRepositoryFixture.create({
name: "apiOrg",
isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
isOrganizationVerified: true,
isOrganizationConfigured: true,
},
isPlatform: true,
});
otherOrg = await orgRepositoryFixture.create({
name: "otherOrg",
isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
isOrganizationVerified: true,
isOrganizationConfigured: true,
},
isPlatform: true,
});
await platformBillingRepositoryFixture.create(org.id);
await platformBillingRepositoryFixture.create(otherOrg.id);
await membershipRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
accepted: true,
});
await membershipRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: otherUser.id } },
team: { connect: { id: otherOrg.id } },
accepted: true,
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
movedFromUser: {
connect: {
id: user.id,
},
},
user: {
connect: { id: user.id },
},
});
await profileRepositoryFixture.create({
uid: `usr-${otherUser.id}`,
username: otherUserEmail,
organization: {
connect: {
id: otherOrg.id,
},
},
movedFromUser: {
connect: {
id: otherUser.id,
},
},
user: {
connect: { id: otherUser.id },
},
});
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
oAuthClient = await oAuthClientRepositoryFixture.create(org.id, data, secret);
otherOAuthClient = await oAuthClientRepositoryFixture.create(otherOrg.id, data, secret);
otherOAuthClientWebhook = await webhookRepositoryFixture.create({
id: "123abc-123abc-123abc-123abc",
active: true,
payloadTemplate: "string",
subscriberUrl: "https://example.com",
eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
platformOAuthClient: { connect: { id: otherOAuthClient.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await orgRepositoryFixture.delete(org.id);
await userRepositoryFixture.deleteByEmail(otherUser.email);
await orgRepositoryFixture.delete(otherOrg.id);
await app.close();
});
it("/webhooks (POST)", () => {
return request(app.getHttpServer())
.post(`/v2/oauth-clients/${oAuthClient.id}/webhooks`)
.send({
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
} satisfies CreateWebhookInputDto)
.expect(201)
.then(async (res) => {
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
active: true,
oAuthClientId: oAuthClient.id,
payloadTemplate: "string",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
},
} satisfies OAuthClientWebhookOutputResponseDto);
webhook = res.body.data;
});
});
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (PATCH)", () => {
return request(app.getHttpServer())
.patch(`/v2/oauth-clients/${oAuthClient.id}/webhooks/${webhook.id}`)
.send({
active: false,
} satisfies UpdateWebhookInputDto)
.expect(200)
.then((res) => {
expect(res.body.data.active).toBe(false);
});
});
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/oauth-clients/${oAuthClient.id}/webhooks/${webhook.id}`)
.expect(200)
.then((res) => {
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: false,
payloadTemplate: "string",
oAuthClientId: oAuthClient.id,
},
} satisfies OAuthClientWebhookOutputResponseDto);
});
});
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => {
return request(app.getHttpServer()).get(`/v2/oauth-clients/${oAuthClient.id}/webhooks/90284`).expect(404);
});
it("/webhooks (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/oauth-clients/${oAuthClient.id}/webhooks`)
.expect(200)
.then((res) => {
const responseBody = res.body as OAuthClientWebhooksOutputResponseDto;
responseBody.data.forEach((webhook) => {
expect(webhook.oAuthClientId).toBe(oAuthClient.id);
});
});
});
it("/webhooks (GET) should fail to get webhooks of oauth client that doesn't belong to you", () => {
return request(app.getHttpServer()).get(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks`).expect(403);
});
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE)", () => {
return request(app.getHttpServer())
.delete(`/v2/oauth-clients/${oAuthClient.id}/webhooks/${webhook.id}`)
.expect(200)
.then((res) => {
expect(res.body).toMatchObject({
status: "success",
data: {
id: expect.any(String),
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: false,
payloadTemplate: "string",
oAuthClientId: oAuthClient.id,
},
} satisfies OAuthClientWebhookOutputResponseDto);
});
});
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) should fail to delete webhooks of an oauth client that doesn't belong to you", () => {
return request(app.getHttpServer())
.delete(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks/${otherOAuthClientWebhook.id}`)
.expect(403);
});
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => {
return request(app.getHttpServer())
.delete(`/v2/oauth-clients/${oAuthClient.id}/webhooks/1234453`)
.expect(404);
});
});
@@ -0,0 +1,140 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
import { IsOAuthClientWebhookGuard } from "@/modules/webhooks/guards/is-oauth-client-webhook-guard";
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import {
OAuthClientWebhookOutputResponseDto,
OAuthClientWebhookOutputDto,
OAuthClientWebhooksOutputResponseDto,
} from "@/modules/webhooks/outputs/oauth-client-webhook.output";
import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output";
import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
import { OAuthClientWebhooksService } from "@/modules/webhooks/services/oauth-clients-webhooks.service";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import { Controller, Post, Body, UseGuards, Get, Param, Query, Delete, Patch } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Webhook, MembershipRole } from "@prisma/client";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
import { OAuthClientGuard } from "../../guards/oauth-client-guard";
@Controller({
path: "/v2/oauth-clients/:clientId/webhooks",
version: API_VERSIONS_VALUES,
})
@UseGuards(NextAuthGuard, OrganizationRolesGuard, OAuthClientGuard)
@ApiTags("OAuthClients Webhooks")
export class OAuthClientWebhooksController {
constructor(
private readonly webhooksService: WebhooksService,
private readonly oAuthClientWebhooksService: OAuthClientWebhooksService
) {}
@Post("/")
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@ApiOperation({ summary: "Create a webhook for an oAuthClient" })
async createOAuthClientWebhook(
@Body() body: CreateWebhookInputDto,
@Param("clientId") oAuthClientId: string
): Promise<OAuthClientWebhookOutputResponseDto> {
const webhook = await this.oAuthClientWebhooksService.createOAuthClientWebhook(
oAuthClientId,
new WebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Patch("/:webhookId")
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@ApiOperation({ summary: "Update a webhook of an oAuthClient" })
@UseGuards(IsOAuthClientWebhookGuard)
async updateOAuthClientWebhook(
@Body() body: UpdateWebhookInputDto,
@Param("webhookId") webhookId: string
): Promise<OAuthClientWebhookOutputResponseDto> {
const webhook = await this.webhooksService.updateWebhook(
webhookId,
new PartialWebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Get("/:webhookId")
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@ApiOperation({ summary: "Get a webhook of an oAuthClient" })
@UseGuards(IsOAuthClientWebhookGuard)
async getOAuthClientWebhook(@GetWebhook() webhook: Webhook): Promise<OAuthClientWebhookOutputResponseDto> {
return {
status: SUCCESS_STATUS,
data: plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Get("/")
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@ApiOperation({ summary: "Get all webhooks of an oAuthClient" })
async getOAuthClientWebhooks(
@Param("clientId") oAuthClientId: string,
@Query() pagination: SkipTakePagination
): Promise<OAuthClientWebhooksOutputResponseDto> {
const webhooks = await this.oAuthClientWebhooksService.getOAuthClientWebhooksPaginated(
oAuthClientId,
pagination.skip ?? 0,
pagination.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: webhooks.map((webhook) =>
plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
})
),
};
}
@Delete("/:webhookId")
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@ApiOperation({ summary: "Delete a webhook of an oAuthClient" })
@UseGuards(IsOAuthClientWebhookGuard)
async deleteOAuthClientWebhook(
@GetWebhook() webhook: Webhook
): Promise<OAuthClientWebhookOutputResponseDto> {
await this.webhooksService.deleteWebhook(webhook.id);
return {
status: SUCCESS_STATUS,
data: plainToClass(OAuthClientWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Delete("/")
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@ApiOperation({ summary: "Delete all webhooks of an oAuthClient" })
async deleteAllOAuthClientWebhooks(
@Param("clientId") oAuthClientId: string
): Promise<DeleteManyWebhooksOutputResponseDto> {
const data = await this.oAuthClientWebhooksService.deleteAllOAuthClientWebhooks(oAuthClientId);
return { status: SUCCESS_STATUS, data: `${data.count} webhooks deleted` };
}
}
@@ -37,8 +37,7 @@ import {
ApiOperation as DocsOperation,
ApiCreatedResponse as DocsCreatedResponse,
} from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { User } from "@prisma/client";
import { User, MembershipRole } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreateOAuthClientInput } from "@calcom/platform-types";
@@ -86,6 +85,7 @@ export class OAuthClientsController {
}
const { id, secret } = await this.oauthClientRepository.createOAuthClient(organizationId, body);
return {
status: SUCCESS_STATUS,
data: {
@@ -116,6 +116,7 @@ export class OAuthClientsController {
if (!client) {
throw new NotFoundException(`OAuth client with ID ${clientId} not found`);
}
return { status: SUCCESS_STATUS, data: client };
}
@@ -149,6 +150,7 @@ export class OAuthClientsController {
): Promise<GetOAuthClientResponseDto> {
this.logger.log(`For client ${clientId} updating OAuth Client with data: ${JSON.stringify(body)}`);
const client = await this.oauthClientRepository.updateOAuthClient(clientId, body);
return { status: SUCCESS_STATUS, data: client };
}
@@ -8,7 +8,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.26";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
@Injectable()
export class OAuthClientUsersService {
@@ -9,7 +9,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.26";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import {
CreateTeamEventTypeInput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
@@ -3,9 +3,9 @@ import { OrganizationsEventTypesRepository } from "@/modules/organizations/repos
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, Host } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
import { SchedulingType } from "@calcom/prisma/enums";
type EventTypeRelations = { users: User[]; schedule: Schedule | null; hosts: Host[] };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -5,7 +5,7 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/repositori
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries-0.0.26";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsService {
@@ -7,7 +7,7 @@ import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { Injectable, ConflictException } from "@nestjs/common";
import { plainToInstance } from "class-transformer";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries-0.0.26";
import { createNewUsersConnectToOrgIfExists } from "@calcom/platform-libraries";
import { Team } from "@calcom/prisma/client";
@Injectable()
@@ -5,8 +5,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.26";
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.26";
import { getAvailableSlots } from "@calcom/platform-libraries";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { DateTime } from "luxon";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.26";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries";
import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
@@ -4,7 +4,7 @@ import { Controller, Get } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.26";
import type { CityTimezones } from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
@@ -1,8 +1,8 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.26";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.26";
import { cityTimezonesHandler } from "@calcom/platform-libraries";
import type { CityTimezones } from "@calcom/platform-libraries";
@Injectable()
export class TimezonesService {
@@ -0,0 +1,69 @@
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
import {
CanActivate,
ExecutionContext,
Injectable,
NotFoundException,
ForbiddenException,
BadRequestException,
} from "@nestjs/common";
import { Request } from "express";
import { PlatformOAuthClient, Webhook } from "@calcom/prisma/client";
@Injectable()
export class IsOAuthClientWebhookGuard implements CanActivate {
constructor(
private readonly webhooksService: WebhooksService,
private readonly oAuthClientRepository: OAuthClientRepository
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context
.switchToHttp()
.getRequest<Request & { webhook: Webhook; oAuthClient: PlatformOAuthClient }>();
const user = request.user as GetUserReturnType;
const webhookId = request.params.webhookId;
const oAuthClientId = request.params.clientId;
const organizationId = user.movedToProfile?.organizationId || user.organizationId;
if (!user) {
throw new ForbiddenException("User not authenticated");
}
if (!webhookId) {
throw new BadRequestException("webhookId parameter not specified in the request");
}
if (!webhookId) {
throw new BadRequestException("oAuthClientId parameter not specified in the request");
}
if (!user || !webhookId || !oAuthClientId) {
return false;
}
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
if (!oAuthClient) {
throw new NotFoundException(`OAuthClient (${oAuthClientId}) not found`);
}
const webhook = await this.webhooksService.getWebhookById(webhookId);
if (oAuthClient?.organizationId !== organizationId) {
return user.isSystemAdmin;
}
if (webhook.platformOAuthClientId !== oAuthClientId) {
throw new ForbiddenException("Webhook does not belong to this oAuthClient");
}
request.webhook = webhook;
request.oAuthClient = oAuthClient;
return true;
}
}
@@ -0,0 +1,26 @@
import { Expose, Type } from "class-transformer";
import { IsInt, ValidateNested } from "class-validator";
import { ApiResponseWithoutData } from "@calcom/platform-types";
import { WebhookOutputDto } from "./webhook.output";
export class OAuthClientWebhookOutputDto extends WebhookOutputDto {
@IsInt()
@Expose()
readonly oAuthClientId!: string;
}
export class OAuthClientWebhookOutputResponseDto extends ApiResponseWithoutData {
@Expose()
@ValidateNested()
@Type(() => WebhookOutputDto)
data!: OAuthClientWebhookOutputDto;
}
export class OAuthClientWebhooksOutputResponseDto extends ApiResponseWithoutData {
@Expose()
@ValidateNested()
@Type(() => WebhookOutputDto)
data!: OAuthClientWebhookOutputDto[];
}
@@ -1,7 +1,7 @@
import { ApiProperty } from "@nestjs/swagger";
import { WebhookTriggerEvents } from "@prisma/client";
import { Expose, Type } from "class-transformer";
import { IsBoolean, IsEnum, IsInt, IsString, ValidateNested } from "class-validator";
import { IsBoolean, IsEnum, IsInt, IsString, ValidateNested, IsArray } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
@@ -25,7 +25,8 @@ export class WebhookOutputDto {
})
readonly payloadTemplate!: string;
@IsEnum(WebhookTriggerEvents)
@IsArray()
@IsEnum(WebhookTriggerEvents, { each: true })
@Expose()
readonly triggers!: WebhookTriggerEvents[];
@@ -36,6 +37,10 @@ export class WebhookOutputDto {
@IsBoolean()
@Expose()
readonly active!: boolean;
@IsString()
@Expose()
readonly secret?: string;
}
export class DeleteManyWebhooksOutputResponseDto {
@@ -4,13 +4,8 @@ import { Webhook } from "@prisma/client";
@Injectable()
export class WebhookOutputPipe implements PipeTransform {
transform(value: Webhook) {
if (value?.eventTriggers) {
const { eventTriggers, ...rest } = value;
const triggers = eventTriggers;
const parsedData = { ...rest, triggers };
return parsedData;
}
return value;
const { eventTriggers, platformOAuthClientId, ...rest } = value;
return { ...rest, triggers: eventTriggers, oAuthClientId: platformOAuthClientId };
}
}
@@ -0,0 +1,32 @@
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
import { ConflictException, Injectable } from "@nestjs/common";
@Injectable()
export class OAuthClientWebhooksService {
constructor(private readonly webhooksRepository: WebhooksRepository) {}
async createOAuthClientWebhook(platformOAuthClientId: string, body: PipedInputWebhookType) {
const existingWebhook = await this.webhooksRepository.getOAuthClientWebhookByUrl(
platformOAuthClientId,
body.subscriberUrl
);
if (existingWebhook) {
throw new ConflictException("Webhook with this subscriber url already exists for this oAuth client");
}
return this.webhooksRepository.createOAuthClientWebhook(platformOAuthClientId, {
...body,
payloadTemplate: body.payloadTemplate ?? null,
secret: body.secret ?? null,
});
}
async getOAuthClientWebhooksPaginated(platformOAuthClientId: string, skip: number, take: number) {
return this.webhooksRepository.getOAuthClientWebhooksPaginated(platformOAuthClientId, skip, take);
}
async deleteAllOAuthClientWebhooks(platformOAuthClientId: string): Promise<{ count: number }> {
return this.webhooksRepository.deleteAllOAuthClientWebhooks(platformOAuthClientId);
}
}
@@ -1,19 +1,44 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeWebhooksController } from "@/modules/event-types/controllers/event-types-webhooks.controller";
import { OAuthClientWebhooksController } from "@/modules/oauth-clients/controllers/oauth-client-webhooks/oauth-client-webhooks.controller";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { Module } from "@nestjs/common";
import { MembershipsModule } from "../memberships/memberships.module";
import { OrganizationsModule } from "../organizations/organizations.module";
import { PrismaModule } from "../prisma/prisma.module";
import { UsersModule } from "../users/users.module";
import { WebhooksController } from "./controllers/webhooks.controller";
import { EventTypeWebhooksService } from "./services/event-type-webhooks.service";
import { OAuthClientWebhooksService } from "./services/oauth-clients-webhooks.service";
import { UserWebhooksService } from "./services/user-webhooks.service";
import { WebhooksService } from "./services/webhooks.service";
import { WebhooksRepository } from "./webhooks.repository";
@Module({
imports: [PrismaModule, UsersModule, EventTypesModule_2024_06_14],
controllers: [WebhooksController, EventTypeWebhooksController],
providers: [WebhooksService, WebhooksRepository, UserWebhooksService, EventTypeWebhooksService],
exports: [WebhooksService, WebhooksRepository, UserWebhooksService, EventTypeWebhooksService],
imports: [
PrismaModule,
UsersModule,
EventTypesModule_2024_06_14,
OAuthClientModule,
OrganizationsModule,
MembershipsModule,
OAuthClientModule,
],
controllers: [WebhooksController, EventTypeWebhooksController, OAuthClientWebhooksController],
providers: [
WebhooksService,
WebhooksRepository,
UserWebhooksService,
EventTypeWebhooksService,
OAuthClientWebhooksService,
],
exports: [
WebhooksService,
WebhooksRepository,
UserWebhooksService,
EventTypeWebhooksService,
OAuthClientWebhooksService,
],
})
export class WebhooksModule {}
@@ -29,6 +29,13 @@ export class WebhooksRepository {
});
}
async createOAuthClientWebhook(platformOAuthClientId: string, data: WebhookInputData) {
const id = uuidv4();
return this.dbWrite.prisma.webhook.create({
data: { ...data, id, platformOAuthClientId },
});
}
async updateWebhook(webhookId: string, data: Partial<WebhookInputData>) {
return this.dbWrite.prisma.webhook.update({
where: { id: webhookId },
@@ -58,12 +65,26 @@ export class WebhooksRepository {
});
}
async getOAuthClientWebhooksPaginated(platformOAuthClientId: string, skip: number, take: number) {
return this.dbRead.prisma.webhook.findMany({
where: { platformOAuthClientId },
skip,
take,
});
}
async getUserWebhookByUrl(userId: number, subscriberUrl: string) {
return this.dbRead.prisma.webhook.findFirst({
where: { userId, subscriberUrl },
});
}
async getOAuthClientWebhookByUrl(platformOAuthClientId: string, subscriberUrl: string) {
return this.dbRead.prisma.webhook.findFirst({
where: { platformOAuthClientId, subscriberUrl },
});
}
async getEventTypeWebhookByUrl(eventTypeId: number, subscriberUrl: string) {
return this.dbRead.prisma.webhook.findFirst({
where: { eventTypeId, subscriberUrl },
@@ -81,4 +102,10 @@ export class WebhooksRepository {
where: { eventTypeId },
});
}
async deleteAllOAuthClientWebhooks(oAuthClientId: string) {
return this.dbWrite.prisma.webhook.deleteMany({
where: { platformOAuthClientId: oAuthClientId },
});
}
}