feat(api): add team event-types webhooks controller (#26449)

* feat(api): add team event-types webhooks controller

- Add TeamsEventTypesWebhooksController for managing webhooks on team event types
- Add IsTeamEventTypeWebhookGuard for authorization
- Add TeamEventTypeWebhooksService for business logic
- Register new controller in TeamsEventTypesModule
- Enable unsafeParameterDecoratorsEnabled in biome.json for NestJS support

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* test(api): add e2e tests for team event-types webhooks controller

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix(api): restrict team event-type webhooks to admins/owners only

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix(api): use regular imports instead of type imports for DI and DTOs

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix(api): use regular imports for DI in guard file

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* fix: circular depndency in modules

* delete teams in test

* fix roles guard

* fix biome

* fix guard

* resolve type import

* resolve review feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Morgan
2026-01-06 11:23:18 -03:00
committed by GitHub
co-authored by morgan@cal.com Devin AI
parent 208a9eaa80
commit 81b5d62f07
9 changed files with 5142 additions and 970 deletions
+5 -2
View File
@@ -1,12 +1,15 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"typescript.preferences.importModuleSpecifier": "non-relative",
"spellright.language": ["en"],
"spellright.documentTypes": ["markdown", "typescript", "typescriptreact"],
"tailwindCSS.classFunctions": ["cva"]
"tailwindCSS.classFunctions": ["cva"],
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
@@ -0,0 +1,328 @@
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 { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import {
EventTypeWebhookOutputResponseDto,
EventTypeWebhooksOutputResponseDto,
} from "@/modules/webhooks/outputs/event-type-webhook.output";
import { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import type { EventType, Team, User, Webhook } from "@calcom/prisma/client";
describe("Teams EventTypes WebhooksController (e2e)", () => {
let app: INestApplication;
const userEmail = `teams-event-types-webhooks-user-${randomString()}@api.com`;
let userAdmin: User;
let otherUser: User;
let team: Team;
let otherTeam: Team;
let teamEventType: EventType;
let teamEventType2: EventType;
let otherTeamEventType: EventType;
let eventTypeRepositoryFixture: EventTypesRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let webhookRepositoryFixture: WebhookRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let webhook: EventTypeWebhookOutputResponseDto["data"];
let otherWebhook: Webhook;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef);
eventTypeRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
userAdmin = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
otherUser = await userRepositoryFixture.create({
email: `teams-event-types-webhooks-other-user-${randomString()}@api.com`,
username: `teams-event-types-webhooks-other-user-${randomString()}@api.com`,
});
team = await teamsRepositoryFixture.create({
name: `teams-event-types-webhooks-team-${randomString()}`,
isOrganization: false,
});
otherTeam = await teamsRepositoryFixture.create({
name: `teams-event-types-webhooks-other-team-${randomString()}`,
isOrganization: false,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: userAdmin.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: otherUser.id } },
team: { connect: { id: otherTeam.id } },
accepted: true,
});
teamEventType = await eventTypeRepositoryFixture.createTeamEventType({
team: { connect: { id: team.id } },
title: "Team Event Type 1",
slug: `teams-event-types-webhooks-event-type-${randomString()}`,
length: 60,
schedulingType: "COLLECTIVE",
});
teamEventType2 = await eventTypeRepositoryFixture.createTeamEventType({
team: { connect: { id: team.id } },
title: "Team Event Type 2",
slug: `teams-event-types-webhooks-event-type-${randomString()}`,
length: 60,
schedulingType: "COLLECTIVE",
});
otherTeamEventType = await eventTypeRepositoryFixture.createTeamEventType({
team: { connect: { id: otherTeam.id } },
title: "Other Team Event Type",
slug: `teams-event-types-webhooks-other-event-type-${randomString()}`,
length: 60,
schedulingType: "COLLECTIVE",
});
otherWebhook = await webhookRepositoryFixture.create({
id: `teams-webhooks-${randomString()}`,
subscriberUrl: "https://example.com/other",
eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
eventType: { connect: { id: otherTeamEventType.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userAdmin.email);
await userRepositoryFixture.deleteByEmail(otherUser.email);
await webhookRepositoryFixture.delete(otherWebhook.id);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(otherTeam.id);
await app.close();
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks (POST)", () => {
return request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types/${teamEventType.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",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
eventTypeId: teamEventType.id,
},
} satisfies EventTypeWebhookOutputResponseDto);
webhook = res.body.data;
});
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks (POST) - create webhook for second event type", () => {
return request(app.getHttpServer())
.post(`/v2/teams/${team.id}/event-types/${teamEventType2.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",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
eventTypeId: teamEventType2.id,
},
} satisfies EventTypeWebhookOutputResponseDto);
});
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks (POST) should fail to create a webhook for an event-type that does not belong to user's team", () => {
return request(app.getHttpServer())
.post(`/v2/teams/${otherTeam.id}/event-types/${otherTeamEventType.id}/webhooks`)
.send({
subscriberUrl: "https://example.com",
triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"],
active: true,
payloadTemplate: "string",
} satisfies CreateWebhookInputDto)
.expect(403);
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks/:webhookId (PATCH)", () => {
return request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/event-types/${teamEventType.id}/webhooks/${webhook.id}`)
.send({
active: false,
} satisfies UpdateWebhookInputDto)
.expect(200)
.then((res) => {
expect(res.body.data.active).toBe(false);
});
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks/:webhookId (PATCH) should fail to patch a webhook for an event-type that does not belong to user's team", () => {
return request(app.getHttpServer())
.patch(`/v2/teams/${otherTeam.id}/event-types/${otherTeamEventType.id}/webhooks/${otherWebhook.id}`)
.send({
active: false,
} satisfies UpdateWebhookInputDto)
.expect(403);
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks/:webhookId (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/event-types/${teamEventType.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",
eventTypeId: teamEventType.id,
},
} satisfies EventTypeWebhookOutputResponseDto);
});
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/event-types/${teamEventType.id}/webhooks/90284`)
.expect(404);
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook of an eventType that does not belong to user's team", () => {
return request(app.getHttpServer())
.get(`/v2/teams/${otherTeam.id}/event-types/${otherTeamEventType.id}/webhooks/${otherWebhook.id}`)
.expect(403);
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to the eventType", () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/event-types/${teamEventType.id}/webhooks/${otherWebhook.id}`)
.expect(403);
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks (GET)", () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/event-types/${teamEventType.id}/webhooks`)
.expect(200)
.then((res) => {
const responseBody = res.body as EventTypeWebhooksOutputResponseDto;
responseBody.data.forEach((webhook) => {
expect(webhook.eventTypeId).toBe(teamEventType.id);
});
});
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks (GET) - list webhooks for second event type", () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/event-types/${teamEventType2.id}/webhooks`)
.expect(200)
.then((res) => {
const responseBody = res.body as EventTypeWebhooksOutputResponseDto;
responseBody.data.forEach((webhook) => {
expect(webhook.eventTypeId).toBe(teamEventType2.id);
});
});
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks/:webhookId (DELETE)", () => {
return request(app.getHttpServer())
.delete(`/v2/teams/${team.id}/event-types/${teamEventType.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",
eventTypeId: teamEventType.id,
},
} satisfies EventTypeWebhookOutputResponseDto);
});
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks (DELETE)", () => {
return request(app.getHttpServer())
.delete(`/v2/teams/${team.id}/event-types/${teamEventType2.id}/webhooks`)
.expect(200)
.then((res) => {
expect(res.body).toMatchObject({
status: "success",
data: "1 webhooks deleted",
} satisfies DeleteManyWebhooksOutputResponseDto);
});
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks/:webhookId (DELETE) should fail to delete a webhook that does not exist", () => {
return request(app.getHttpServer())
.delete(`/v2/teams/${team.id}/event-types/${teamEventType.id}/webhooks/1234453`)
.expect(404);
});
it("/teams/:teamId/event-types/:eventTypeId/webhooks/:webhookId (DELETE) should fail to delete a webhook that does not belong to user's team", () => {
return request(app.getHttpServer())
.delete(`/v2/teams/${otherTeam.id}/event-types/${otherTeamEventType.id}/webhooks/${otherWebhook.id}`)
.expect(403);
});
});
@@ -0,0 +1,146 @@
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
import type { Webhook } from "@calcom/prisma/client";
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_HEADER } from "@/lib/docs/headers";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator";
import { IsTeamEventTypeWebhookGuard } from "@/modules/webhooks/guards/is-team-event-type-webhook-guard";
import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
import {
EventTypeWebhookOutputDto,
type EventTypeWebhookOutputResponseDto,
type EventTypeWebhooksOutputResponseDto,
} from "@/modules/webhooks/outputs/event-type-webhook.output";
import type { DeleteManyWebhooksOutputResponseDto } from "@/modules/webhooks/outputs/webhook.output";
import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe";
import { TeamEventTypeWebhooksService } from "@/modules/webhooks/services/team-event-type-webhooks.service";
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
@Controller({
path: "/v2/teams/:teamId/event-types/:eventTypeId/webhooks",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, RolesGuard, IsTeamEventTypeWebhookGuard)
@DocsTags("Teams / Event Types / Webhooks")
@ApiHeader(API_KEY_HEADER)
export class TeamsEventTypesWebhooksController {
constructor(
private readonly webhooksService: WebhooksService,
private readonly teamEventTypeWebhooksService: TeamEventTypeWebhooksService
) {}
@Post("/")
@ApiOperation({ summary: "Create a webhook for a team event type" })
@Roles("TEAM_ADMIN")
async createTeamEventTypeWebhook(
@Body() body: CreateWebhookInputDto,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<EventTypeWebhookOutputResponseDto> {
const webhook = await this.teamEventTypeWebhooksService.createTeamEventTypeWebhook(
eventTypeId,
new WebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Patch("/:webhookId")
@ApiOperation({ summary: "Update a webhook for a team event type" })
@Roles("TEAM_ADMIN")
async updateTeamEventTypeWebhook(
@Body() body: UpdateWebhookInputDto,
@Param("webhookId") webhookId: string
): Promise<EventTypeWebhookOutputResponseDto> {
const webhook = await this.webhooksService.updateWebhook(
webhookId,
new PartialWebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Get("/:webhookId")
@ApiOperation({ summary: "Get a webhook for a team event type" })
@Roles("TEAM_MEMBER")
async getTeamEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise<EventTypeWebhookOutputResponseDto> {
return {
status: SUCCESS_STATUS,
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Get("/")
@Roles("TEAM_MEMBER")
@ApiOperation({ summary: "Get all webhooks for a team event type" })
async getTeamEventTypeWebhooks(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Query() pagination: SkipTakePagination
): Promise<EventTypeWebhooksOutputResponseDto> {
const webhooks = await this.teamEventTypeWebhooksService.getTeamEventTypeWebhooksPaginated(
eventTypeId,
pagination.skip ?? 0,
pagination.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: webhooks.map((webhook) =>
plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
})
),
};
}
@Delete("/:webhookId")
@Roles("TEAM_ADMIN")
@ApiOperation({ summary: "Delete a webhook for a team event type" })
async deleteTeamEventTypeWebhook(
@GetWebhook() webhook: Webhook
): Promise<EventTypeWebhookOutputResponseDto> {
await this.webhooksService.deleteWebhook(webhook.id);
return {
status: SUCCESS_STATUS,
data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
@Delete("/")
@Roles("TEAM_ADMIN")
@ApiOperation({ summary: "Delete all webhooks for a team event type" })
async deleteAllTeamEventTypeWebhooks(
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
): Promise<DeleteManyWebhooksOutputResponseDto> {
const data = await this.teamEventTypeWebhooksService.deleteAllTeamEventTypeWebhooks(eventTypeId);
return { status: SUCCESS_STATUS, data: `${data.count} webhooks deleted` };
}
}
@@ -1,3 +1,4 @@
import { Module } from "@nestjs/common";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
@@ -13,7 +14,6 @@ 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";
@Module({
imports: [
@@ -0,0 +1,90 @@
import type { EventType, Webhook } from "@calcom/prisma/client";
import {
BadRequestException,
type CanActivate,
type ExecutionContext,
ForbiddenException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import type { Request } from "express";
import type { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
type WebhookRequest = Request & { webhook: Webhook; eventType: EventType };
@Injectable()
export class IsTeamEventTypeWebhookGuard implements CanActivate {
constructor(
private readonly webhooksRepository: WebhooksRepository,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly membershipsRepository: MembershipsRepository
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<WebhookRequest>();
const user = request.user as ApiAuthGuardUser;
const { webhookId, eventTypeId, teamId } = request.params;
this.validateInitialRequest(user, teamId, eventTypeId);
await this.validateTeamMembership(user.id, Number(teamId));
request.eventType = await this.validateAndGetEventType(Number(teamId), Number(eventTypeId));
if (webhookId) {
request.webhook = await this.validateAndGetWebhook(webhookId, Number(eventTypeId));
}
return true;
}
private validateInitialRequest(user: ApiAuthGuardUser, teamId: string, eventTypeId: string): void {
if (!user) {
throw new ForbiddenException("IsTeamEventTypeWebhookGuard - No user associated with the request.");
}
if (!teamId) {
throw new BadRequestException("IsTeamEventTypeWebhookGuard - Team ID is required.");
}
if (!eventTypeId) {
throw new BadRequestException("IsTeamEventTypeWebhookGuard - Event Type ID is required.");
}
}
private async validateTeamMembership(userId: number, teamId: number): Promise<void> {
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(userId, teamId);
if (!membership) {
throw new ForbiddenException(
`IsTeamEventTypeWebhookGuard - User (${userId}) is not an admin/owner of team (${teamId})`
);
}
}
private async validateAndGetEventType(teamId: number, eventTypeId: number): Promise<EventType> {
const eventType = await this.teamsEventTypesRepository.getTeamEventType(teamId, eventTypeId);
if (!eventType) {
throw new NotFoundException(
`IsTeamEventTypeWebhookGuard - Event type (${eventTypeId}) not found for team (${teamId})`
);
}
return eventType;
}
private async validateAndGetWebhook(webhookId: string, eventTypeId: number): Promise<Webhook> {
const webhook = await this.webhooksRepository.getWebhookById(webhookId);
if (!webhook) {
throw new NotFoundException(`IsTeamEventTypeWebhookGuard - Webhook (${webhookId}) not found`);
}
if (!webhook.eventTypeId) {
throw new BadRequestException(`IsTeamEventTypeWebhookGuard - Webhook (${webhookId}) no event type`);
}
if (webhook.eventTypeId !== eventTypeId) {
throw new ForbiddenException(`IsTeamEventTypeWebhookGuard - Webhook mismatch with event type`);
}
return webhook;
}
}
@@ -0,0 +1,38 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
import type { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
@Injectable()
export class TeamEventTypeWebhooksService {
constructor(private readonly webhooksRepository: WebhooksRepository) {}
async createTeamEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) {
if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) {
throw new BadRequestException(
"DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks"
);
}
const existingWebhook = await this.webhooksRepository.getEventTypeWebhookByUrl(
eventTypeId,
body.subscriberUrl
);
if (existingWebhook) {
throw new ConflictException("Webhook with this subscriber url already exists for this event type");
}
return this.webhooksRepository.createEventTypeWebhook(eventTypeId, {
...body,
payloadTemplate: body.payloadTemplate ?? null,
secret: body.secret ?? null,
});
}
getTeamEventTypeWebhooksPaginated(eventTypeId: number, skip: number, take: number) {
return this.webhooksRepository.getEventTypeWebhooksPaginated(eventTypeId, skip, take);
}
async deleteAllTeamEventTypeWebhooks(eventTypeId: number): Promise<{ count: number }> {
return this.webhooksRepository.deleteAllEventTypeWebhooks(eventTypeId);
}
}
@@ -1,8 +1,9 @@
import { Module } from "@nestjs/common";
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 { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { MembershipsModule } from "../memberships/memberships.module";
import { OrganizationsModule } from "../organizations/organizations.module";
@@ -11,13 +12,17 @@ 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 { TeamEventTypeWebhooksService } from "./services/team-event-type-webhooks.service";
import { UserWebhooksService } from "./services/user-webhooks.service";
import { WebhooksService } from "./services/webhooks.service";
import { WebhooksRepository } from "./webhooks.repository";
import { TeamsEventTypesWebhooksController } from "@/modules/teams/event-types/controllers/teams-event-types-webhooks.controller";
import { RedisModule } from "@/modules/redis/redis.module";
@Module({
imports: [
PrismaModule,
RedisModule,
UsersModule,
EventTypesModule_2024_06_14,
OAuthClientModule,
@@ -25,13 +30,20 @@ import { WebhooksRepository } from "./webhooks.repository";
MembershipsModule,
OAuthClientModule,
],
controllers: [WebhooksController, EventTypeWebhooksController, OAuthClientWebhooksController],
controllers: [
WebhooksController,
EventTypeWebhooksController,
OAuthClientWebhooksController,
TeamsEventTypesWebhooksController,
],
providers: [
TeamsEventTypesRepository,
WebhooksService,
WebhooksRepository,
UserWebhooksService,
EventTypeWebhooksService,
OAuthClientWebhooksService,
TeamEventTypeWebhooksService,
],
exports: [
WebhooksService,
@@ -39,6 +51,7 @@ import { WebhooksRepository } from "./webhooks.repository";
UserWebhooksService,
EventTypeWebhooksService,
OAuthClientWebhooksService,
TeamEventTypeWebhooksService,
],
})
export class WebhooksModule {}
+19 -1
View File
@@ -48,6 +48,9 @@
]
},
"javascript": {
"parser": {
"unsafeParameterDecoratorsEnabled": true
},
"formatter": {
"arrowParentheses": "always",
"bracketSpacing": true,
@@ -70,6 +73,21 @@
}
},
"overrides": [
{
"includes": ["docs/api-reference/v2/openapi.json"],
"linter": { "enabled": false },
"formatter": { "enabled": false }
},
{
"includes": ["apps/api/v2/**/*.ts", "apps/api/v2/**/*.controller.ts"],
"linter": {
"rules": {
"style": {
"useImportType": "off"
}
}
}
},
{
"includes": ["**/*.tsx"],
"javascript": {
@@ -140,7 +158,7 @@
}
},
{
"includes": ["**/*.test.ts", "**/*.test.tsx"],
"includes": ["**/*.test.ts", "**/*.test.tsx", "**/*.e2e-spec.ts"],
"linter": {
"rules": {
"complexity": {
File diff suppressed because it is too large Load Diff