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:
co-authored by
morgan@cal.com
Devin AI
parent
208a9eaa80
commit
81b5d62f07
Vendored
+5
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+328
@@ -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);
|
||||
});
|
||||
});
|
||||
+146
@@ -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
@@ -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": {
|
||||
|
||||
+4500
-964
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user