From c059d79a516695ee7454068aaa1b1f56e11d3a52 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Fri, 2 Aug 2024 15:18:35 +0300 Subject: [PATCH] feat: api v2 webhooks for users and event-types (#15996) * feat: webhooks for users * fixup! feat: webhooks for users * feat: webhooks for user event types * feat: webhooks for user event types * fixup! Merge branch 'main' into feat-webhooks-api-v2 * doc * chore: split webhook service * chore: webhook repo only depends on prisma * chore: split webhook outputs * fixup! chore: split webhook outputs * chore: describe payload template * chore: pipe webhook input and output * chore: use partialType for update dtos * chore: improve dto --- apps/api/v2/src/modules/endpoints.module.ts | 10 +- ...vent-types-webhooks.controller.e2e-spec.ts | 295 ++++++ .../event-types-webhooks.controller.ts | 135 +++ .../webhooks.controller.e2e-spec.ts | 189 ++++ .../controllers/webhooks.controller.ts | 117 +++ .../decorators/get-webhook-decorator.ts | 33 + .../is-user-event-type-webhook-guard.ts | 60 ++ .../webhooks/guards/is-user-webhook-guard.ts | 31 + .../modules/webhooks/inputs/webhook.input.ts | 49 + .../outputs/event-type-webhook.output.ts | 37 + .../webhooks/outputs/user-webhook.output.ts | 37 + .../webhooks/outputs/webhook.output.ts | 51 ++ .../webhooks/pipes/WebhookInputPipe.ts | 27 + .../webhooks/pipes/WebhookOutputPipe.ts | 17 + .../services/event-type-webhooks.service.ts | 31 + .../services/user-webhooks.service.ts | 25 + .../webhooks/services/webhooks.service.ts | 24 + .../src/modules/webhooks/webhooks.module.ts | 19 + .../modules/webhooks/webhooks.repository.ts | 84 ++ apps/api/v2/swagger/documentation.json | 849 +++++++++++++----- .../repository/webhooks.repository.fixture.ts | 22 + 21 files changed, 1934 insertions(+), 208 deletions(-) create mode 100644 apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.ts create mode 100644 apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.e2e-spec.ts create mode 100644 apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.ts create mode 100644 apps/api/v2/src/modules/webhooks/decorators/get-webhook-decorator.ts create mode 100644 apps/api/v2/src/modules/webhooks/guards/is-user-event-type-webhook-guard.ts create mode 100644 apps/api/v2/src/modules/webhooks/guards/is-user-webhook-guard.ts create mode 100644 apps/api/v2/src/modules/webhooks/inputs/webhook.input.ts create mode 100644 apps/api/v2/src/modules/webhooks/outputs/event-type-webhook.output.ts create mode 100644 apps/api/v2/src/modules/webhooks/outputs/user-webhook.output.ts create mode 100644 apps/api/v2/src/modules/webhooks/outputs/webhook.output.ts create mode 100644 apps/api/v2/src/modules/webhooks/pipes/WebhookInputPipe.ts create mode 100644 apps/api/v2/src/modules/webhooks/pipes/WebhookOutputPipe.ts create mode 100644 apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts create mode 100644 apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts create mode 100644 apps/api/v2/src/modules/webhooks/services/webhooks.service.ts create mode 100644 apps/api/v2/src/modules/webhooks/webhooks.module.ts create mode 100644 apps/api/v2/src/modules/webhooks/webhooks.repository.ts create mode 100644 apps/api/v2/test/fixtures/repository/webhooks.repository.fixture.ts diff --git a/apps/api/v2/src/modules/endpoints.module.ts b/apps/api/v2/src/modules/endpoints.module.ts index 3134bd2bd5..d4ef222182 100644 --- a/apps/api/v2/src/modules/endpoints.module.ts +++ b/apps/api/v2/src/modules/endpoints.module.ts @@ -6,9 +6,17 @@ import type { MiddlewareConsumer, NestModule } from "@nestjs/common"; import { Module } from "@nestjs/common"; import { UsersModule } from "./users/users.module"; +import { WebhooksModule } from "./webhooks/webhooks.module"; @Module({ - imports: [OAuthClientModule, BillingModule, PlatformEndpointsModule, TimezoneModule, UsersModule], + imports: [ + OAuthClientModule, + BillingModule, + PlatformEndpointsModule, + TimezoneModule, + UsersModule, + WebhooksModule, + ], }) export class EndpointsModule implements NestModule { // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.e2e-spec.ts b/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.e2e-spec.ts new file mode 100644 index 0000000000..a2e26feb33 --- /dev/null +++ b/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.e2e-spec.ts @@ -0,0 +1,295 @@ +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 { + 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 { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { EventType, Webhook } from "@calcom/prisma/client"; + +describe("EventTypes WebhooksController (e2e)", () => { + let app: INestApplication; + const userEmail = "event-types-webhook-controller-e2e@api.com"; + let user: UserWithProfile; + let otherUser: UserWithProfile; + let eventType: EventType; + let eventType2: EventType; + let otherEventType: EventType; + + let eventTypeRepositoryFixture: EventTypesRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let webhookRepositoryFixture: WebhookRepositoryFixture; + + let webhook: EventTypeWebhookOutputResponseDto["data"]; + let webhook2: Webhook; + 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); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + otherUser = await userRepositoryFixture.create({ + email: "other-user-webhook-controller@api.com", + username: "other-user-webhook-controller@api.com", + }); + + eventType = await eventTypeRepositoryFixture.create( + { + title: "Event Type 1", + slug: "webhook-event-type-1", + length: 60, + }, + user.id + ); + + eventType2 = await eventTypeRepositoryFixture.create( + { + title: "Event Type 2", + slug: "webhook-event-type-2", + length: 60, + }, + user.id + ); + + otherEventType = await eventTypeRepositoryFixture.create( + { + title: "Other Event Type ", + slug: "other-webhook-event-type", + length: 60, + }, + otherUser.id + ); + + otherWebhook = await webhookRepositoryFixture.create({ + id: "2mdfnn24", + subscriberUrl: "https://example.com", + eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + afterAll(async () => { + userRepositoryFixture.deleteByEmail(user.email); + userRepositoryFixture.deleteByEmail(otherUser.email); + webhookRepositoryFixture.delete(otherWebhook.id); + await app.close(); + }); + + it("/webhooks (POST)", () => { + return request(app.getHttpServer()) + .post(`/v2/event-types/${eventType.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: eventType.id, + }, + } satisfies EventTypeWebhookOutputResponseDto); + webhook = res.body.data; + }); + }); + + it("/webhooks (POST)", () => { + return request(app.getHttpServer()) + .post(`/v2/event-types/${eventType2.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: eventType2.id, + }, + } satisfies EventTypeWebhookOutputResponseDto); + webhook2 = res.body.data; + }); + }); + + it("/webhooks (POST) should fail to create a webhook for an event-type that does not belong to user", () => { + return request(app.getHttpServer()) + .post(`/v2/event-types/${otherEventType.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(403); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH)", () => { + return request(app.getHttpServer()) + .patch(`/v2/event-types/${eventType.id}/webhooks/${webhook.id}`) + .send({ + active: false, + } satisfies UpdateWebhookInputDto) + .expect(200) + .then((res) => { + expect(res.body.data.active).toBe(false); + }); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (PATCH) should fail to patch a webhook for an event-type that does not belong to user", () => { + return request(app.getHttpServer()) + .patch(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`) + .send({ + active: false, + } satisfies UpdateWebhookInputDto) + .expect(403); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/event-types/${eventType.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: eventType.id, + }, + } satisfies EventTypeWebhookOutputResponseDto); + }); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => { + return request(app.getHttpServer()).get(`/v2/event-types/${eventType.id}/webhooks/90284`).expect(404); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (GET) should fail to get a webhook of an eventType that does not belong to user", () => { + return request(app.getHttpServer()) + .get(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`) + .expect(403); + }); + + it("/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/event-types/${eventType.id}/webhooks/${otherWebhook.id}`) + .expect(400); + }); + + it("/webhooks (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/event-types/${eventType.id}/webhooks`) + .expect(200) + .then((res) => { + const responseBody = res.body as EventTypeWebhooksOutputResponseDto; + responseBody.data.forEach((webhook) => { + expect(webhook.eventTypeId).toBe(eventType.id); + }); + }); + }); + + it("/event-types/:eventTypeId/webhooks (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/event-types/${eventType2.id}/webhooks`) + .expect(200) + .then((res) => { + const responseBody = res.body as EventTypeWebhooksOutputResponseDto; + responseBody.data.forEach((webhook) => { + expect(webhook.eventTypeId).toBe(eventType2.id); + }); + }); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE)", () => { + return request(app.getHttpServer()) + .delete(`/v2/event-types/${eventType.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: eventType.id, + }, + } satisfies EventTypeWebhookOutputResponseDto); + }); + }); + + it("/event-types/:eventTypeId/webhooks (DELETE)", () => { + return request(app.getHttpServer()) + .delete(`/v2/event-types/${eventType2.id}/webhooks`) + .expect(200) + .then((res) => { + expect(res.body).toMatchObject({ + status: "success", + data: "1 webhooks deleted", + } satisfies DeleteManyWebhooksOutputResponseDto); + }); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => { + return request(app.getHttpServer()) + .delete(`/v2/event-types/${eventType.id}/webhooks/1234453`) + .expect(404); + }); + + it("/event-types/:eventTypeId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to user", () => { + return request(app.getHttpServer()) + .delete(`/v2/event-types/${otherEventType.id}/webhooks/${otherWebhook.id}`) + .expect(403); + }); +}); diff --git a/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.ts b/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.ts new file mode 100644 index 0000000000..6859e3319e --- /dev/null +++ b/apps/api/v2/src/modules/event-types/controllers/event-types-webhooks.controller.ts @@ -0,0 +1,135 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator"; +import { IsUserEventTypeWebhookGuard } from "@/modules/webhooks/guards/is-user-event-type-webhook-guard"; +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + EventTypeWebhookOutputResponseDto, + EventTypeWebhookOutputDto, + EventTypeWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/event-type-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 { EventTypeWebhooksService } from "@/modules/webhooks/services/event-type-webhooks.service"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { + Controller, + Post, + Body, + UseGuards, + Get, + Param, + Query, + Delete, + Patch, + ParseIntPipe, +} from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; +import { Webhook } from "@prisma/client"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/event-types/:eventTypeId/webhooks", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsUserEventTypeWebhookGuard) +@ApiTags("Users' EventTypes Webhooks") +export class EventTypeWebhooksController { + constructor( + private readonly webhooksService: WebhooksService, + private readonly eventTypeWebhooksService: EventTypeWebhooksService + ) {} + + @Post("/") + @ApiOperation({ summary: "Create a webhook for an event-type" }) + async createEventTypeWebhook( + @Body() body: CreateWebhookInputDto, + @Param("eventTypeId", ParseIntPipe) eventTypeId: number + ): Promise { + const webhook = await this.eventTypeWebhooksService.createEventTypeWebhook( + 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 of an event-type" }) + async updateEventTypeWebhook( + @Body() body: UpdateWebhookInputDto, + @Param("webhookId") webhookId: string + ): Promise { + 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 of an event-type" }) + async getEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise { + return { + status: SUCCESS_STATUS, + data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Get("/") + @ApiOperation({ summary: "Get all webhooks of an event-type" }) + async getEventTypeWebhooks( + @Param("eventTypeId", ParseIntPipe) eventTypeId: number, + @Query() pagination: SkipTakePagination + ): Promise { + const webhooks = await this.eventTypeWebhooksService.getEventTypeWebhooksPaginated( + 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") + @ApiOperation({ summary: "Delete a webhook of an event-type" }) + async deleteEventTypeWebhook(@GetWebhook() webhook: Webhook): Promise { + await this.webhooksService.deleteWebhook(webhook.id); + return { + status: SUCCESS_STATUS, + data: plainToClass(EventTypeWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Delete("/") + @ApiOperation({ summary: "Delete all webhooks of an event-type" }) + async deleteAllEventTypeWebhooks( + @Param("eventTypeId", ParseIntPipe) eventTypeId: number + ): Promise { + const data = await this.eventTypeWebhooksService.deleteAllEventTypeWebhooks(eventTypeId); + return { status: SUCCESS_STATUS, data: `${data.count} webhooks deleted` }; + } +} diff --git a/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.e2e-spec.ts b/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.e2e-spec.ts new file mode 100644 index 0000000000..d79fab8998 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.e2e-spec.ts @@ -0,0 +1,189 @@ +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 { + UserWebhookOutputResponseDto, + UserWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/user-webhook.output"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { WebhookRepositoryFixture } from "test/fixtures/repository/webhooks.repository.fixture"; +import { withApiAuth } from "test/utils/withApiAuth"; + +import { Webhook } from "@calcom/prisma/client"; + +describe("WebhooksController (e2e)", () => { + let app: INestApplication; + const userEmail = "webhook-controller-e2e@api.com"; + let user: UserWithProfile; + let otherUser: UserWithProfile; + + let userRepositoryFixture: UserRepositoryFixture; + let webhookRepositoryFixture: WebhookRepositoryFixture; + + let webhook: UserWebhookOutputResponseDto["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); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + otherUser = await userRepositoryFixture.create({ + email: "other-user-webhook-controller@api.com", + username: "other-user-webhook-controller@api.com", + }); + + otherWebhook = await webhookRepositoryFixture.create({ + id: "2mdfnn2", + subscriberUrl: "https://example.com", + eventTriggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + }); + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + afterAll(async () => { + userRepositoryFixture.deleteByEmail(user.email); + userRepositoryFixture.deleteByEmail(otherUser.email); + webhookRepositoryFixture.delete(otherWebhook.id); + await app.close(); + }); + + it("/webhooks (POST)", () => { + return request(app.getHttpServer()) + .post("/v2/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", + userId: user.id, + }, + } satisfies UserWebhookOutputResponseDto); + webhook = res.body.data; + }); + }); + + it("/webhooks (POST) should fail to create a webhook that already has same userId / subcriberUrl combo", () => { + return request(app.getHttpServer()) + .post("/v2/webhooks") + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(409); + }); + + it("/webhooks/:webhookId (PATCH)", () => { + return request(app.getHttpServer()) + .patch(`/v2/webhooks/${webhook.id}`) + .send({ + active: false, + } satisfies UpdateWebhookInputDto) + .expect(200) + .then((res) => { + expect(res.body.data.active).toBe(false); + }); + }); + + it("/webhooks/:webhookId (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/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", + userId: user.id, + }, + } satisfies UserWebhookOutputResponseDto); + }); + }); + + it("/webhooks/:webhookId (GET) should fail to get a webhook that does not exist", () => { + return request(app.getHttpServer()).get(`/v2/webhooks/90284`).expect(404); + }); + + it("/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to user", () => { + return request(app.getHttpServer()).get(`/v2/webhooks/${otherWebhook.id}`).expect(403); + }); + + it("/webhooks (GET)", () => { + return request(app.getHttpServer()) + .get("/v2/webhooks") + .expect(200) + .then((res) => { + const responseBody = res.body as UserWebhooksOutputResponseDto; + responseBody.data.forEach((webhook) => { + expect(webhook.userId).toBe(user.id); + }); + }); + }); + + it("/webhooks/:webhookId (DELETE)", () => { + return request(app.getHttpServer()) + .delete(`/v2/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", + userId: user.id, + }, + } satisfies UserWebhookOutputResponseDto); + }); + }); + + it("/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => { + return request(app.getHttpServer()).delete(`/v2/webhooks/12993`).expect(404); + }); + + it("/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to user", () => { + return request(app.getHttpServer()).delete(`/v2/webhooks/${otherWebhook.id}`).expect(403); + }); +}); diff --git a/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.ts b/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.ts new file mode 100644 index 0000000000..0239e40ca2 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/controllers/webhooks.controller.ts @@ -0,0 +1,117 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { UserWithProfile } from "@/modules/users/users.repository"; +import { GetWebhook } from "@/modules/webhooks/decorators/get-webhook-decorator"; +import { IsUserWebhookGuard } from "@/modules/webhooks/guards/is-user-webhook-guard"; +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + UserWebhookOutputDto, + UserWebhookOutputResponseDto, + UserWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/user-webhook.output"; +import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe"; +import { UserWebhooksService } from "@/modules/webhooks/services/user-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 } from "@prisma/client"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/webhooks", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard) +@ApiTags("Users' Webhooks") +export class WebhooksController { + constructor( + private readonly webhooksService: WebhooksService, + private readonly userWebhooksService: UserWebhooksService + ) {} + + @Post("/") + @ApiOperation({ summary: "Create a webhook" }) + async createWebhook( + @Body() body: CreateWebhookInputDto, + @GetUser() user: UserWithProfile + ): Promise { + const webhook = await this.userWebhooksService.createUserWebhook( + user.id, + new WebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Patch("/:webhookId") + @ApiOperation({ summary: "Update a webhook" }) + @UseGuards(IsUserWebhookGuard) + async updateWebhook( + @Param("webhookId") webhookId: string, + @Body() body: UpdateWebhookInputDto + ): Promise { + const webhook = await this.webhooksService.updateWebhook( + webhookId, + new PartialWebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Get("/:webhookId") + @ApiOperation({ summary: "Get a webhook" }) + @UseGuards(IsUserWebhookGuard) + async getWebhook(@GetWebhook() webhook: Webhook): Promise { + return { + status: SUCCESS_STATUS, + data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook)), + }; + } + + @Get("/") + @ApiOperation({ summary: "Get all user webhooks paginated" }) + async getWebhooks( + @GetUser() user: UserWithProfile, + @Query() query: SkipTakePagination + ): Promise { + const webhooks = await this.userWebhooksService.getUserWebhooksPaginated( + user.id, + query.skip ?? 0, + query.take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: webhooks.map((webhook) => + plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }) + ), + }; + } + + @Delete("/:webhookId") + @ApiOperation({ summary: "Delete a webhook" }) + @UseGuards(IsUserWebhookGuard) + async deleteWebhook(@Param("webhookId") webhookId: string): Promise { + const webhook = await this.webhooksService.deleteWebhook(webhookId); + return { + status: SUCCESS_STATUS, + data: plainToClass(UserWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } +} diff --git a/apps/api/v2/src/modules/webhooks/decorators/get-webhook-decorator.ts b/apps/api/v2/src/modules/webhooks/decorators/get-webhook-decorator.ts new file mode 100644 index 0000000000..df98ac107c --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/decorators/get-webhook-decorator.ts @@ -0,0 +1,33 @@ +import { ExecutionContext } from "@nestjs/common"; +import { createParamDecorator } from "@nestjs/common"; + +import { Webhook } from "@calcom/prisma/client"; + +export type GetWebhookReturnType = Webhook; + +export const GetWebhook = createParamDecorator< + keyof GetWebhookReturnType | (keyof GetWebhookReturnType)[], + ExecutionContext +>((data, ctx) => { + const request = ctx.switchToHttp().getRequest(); + const webhook = request.webhook as GetWebhookReturnType; + + if (!webhook) { + throw new Error("GetWebhook decorator : Webhook not found"); + } + + if (Array.isArray(data)) { + return data.reduce((prev, curr) => { + return { + ...prev, + [curr]: webhook[curr], + }; + }, {}); + } + + if (data) { + return webhook[data]; + } + + return webhook; +}); diff --git a/apps/api/v2/src/modules/webhooks/guards/is-user-event-type-webhook-guard.ts b/apps/api/v2/src/modules/webhooks/guards/is-user-event-type-webhook-guard.ts new file mode 100644 index 0000000000..a69c6d5fe8 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/guards/is-user-event-type-webhook-guard.ts @@ -0,0 +1,60 @@ +import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository"; +import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { + BadRequestException, + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { EventType, Webhook } from "@prisma/client"; +import { Request } from "express"; + +@Injectable() +export class IsUserEventTypeWebhookGuard implements CanActivate { + constructor( + private readonly webhooksService: WebhooksService, + private readonly eventtypesRepository: EventTypesRepository_2024_06_14 + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context + .switchToHttp() + .getRequest(); + const user = request.user as GetUserReturnType; + const webhookId = request.params.webhookId; + const eventTypeId = request.params.eventTypeId; + + if (!user) { + return false; + } + + if (eventTypeId) { + const eventType = await this.eventtypesRepository.getEventTypeById(parseInt(eventTypeId)); + if (!eventType) { + throw new NotFoundException(`Event type (${eventTypeId}) not found`); + } + if (eventType.userId !== user.id) { + throw new ForbiddenException(`User (${user.id}) is not the owner of event type (${eventTypeId})`); + } + request.eventType = eventType; + } + + if (webhookId) { + const webhook = await this.webhooksService.getWebhookById(webhookId); + if (!webhook.eventTypeId) { + throw new BadRequestException(`Webhook (${webhookId}) is not associated with an event type`); + } + if (webhook.eventTypeId !== parseInt(eventTypeId)) { + throw new ForbiddenException( + `Webhook (${webhookId}) is not associated with event type (${eventTypeId})` + ); + } + request.webhook = webhook; + } + + return true; + } +} diff --git a/apps/api/v2/src/modules/webhooks/guards/is-user-webhook-guard.ts b/apps/api/v2/src/modules/webhooks/guards/is-user-webhook-guard.ts new file mode 100644 index 0000000000..857732aa9e --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/guards/is-user-webhook-guard.ts @@ -0,0 +1,31 @@ +import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common"; +import { Request } from "express"; + +import { Webhook } from "@calcom/prisma/client"; + +@Injectable() +export class IsUserWebhookGuard implements CanActivate { + constructor(private readonly webhooksService: WebhooksService) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user as GetUserReturnType; + const webhookId = request.params.webhookId; + + if (!user || !webhookId) { + return false; + } + + const webhook = await this.webhooksService.getWebhookById(webhookId); + + if (webhook.userId !== user.id) { + return user.isSystemAdmin; + } + + request.webhook = webhook; + + return true; + } +} diff --git a/apps/api/v2/src/modules/webhooks/inputs/webhook.input.ts b/apps/api/v2/src/modules/webhooks/inputs/webhook.input.ts new file mode 100644 index 0000000000..1056d3fdff --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/inputs/webhook.input.ts @@ -0,0 +1,49 @@ +import { ApiProperty, PartialType } from "@nestjs/swagger"; +import { WebhookTriggerEvents } from "@prisma/client"; +import { IsArray, IsBoolean, IsEnum, IsOptional, IsString } from "class-validator"; + +export class CreateWebhookInputDto { + @IsString() + @IsOptional() + @ApiProperty({ + description: + "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + example: JSON.stringify({ + content: "A new event has been scheduled", + type: "{{type}}", + name: "{{title}}", + organizer: "{{organizer.name}}", + booker: "{{attendees.0.name}}", + }), + }) + payloadTemplate?: string; + + @IsBoolean() + active!: boolean; + + @IsString() + subscriberUrl!: string; + + @IsArray() + @ApiProperty({ + example: [ + "BOOKING_CREATED", + "BOOKING_RESCHEDULED", + "BOOKING_CANCELLED", + "BOOKING_CONFIRMED", + "BOOKING_REJECTED", + "BOOKING_COMPLETED", + "BOOKING_NO_SHOW", + "BOOKING_REOPENED", + ], + enum: WebhookTriggerEvents, + }) + @IsEnum(WebhookTriggerEvents, { each: true }) + triggers!: WebhookTriggerEvents[]; + + @IsString() + @IsOptional() + secret?: string; +} + +export class UpdateWebhookInputDto extends PartialType(CreateWebhookInputDto) {} diff --git a/apps/api/v2/src/modules/webhooks/outputs/event-type-webhook.output.ts b/apps/api/v2/src/modules/webhooks/outputs/event-type-webhook.output.ts new file mode 100644 index 0000000000..5c69783e5d --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/outputs/event-type-webhook.output.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +import { WebhookOutputDto } from "./webhook.output"; + +export class EventTypeWebhookOutputDto extends WebhookOutputDto { + @IsInt() + @Expose() + readonly eventTypeId!: number; +} + +export class EventTypeWebhookOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: EventTypeWebhookOutputDto; +} + +export class EventTypeWebhooksOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: EventTypeWebhookOutputDto[]; +} diff --git a/apps/api/v2/src/modules/webhooks/outputs/user-webhook.output.ts b/apps/api/v2/src/modules/webhooks/outputs/user-webhook.output.ts new file mode 100644 index 0000000000..66b3038d23 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/outputs/user-webhook.output.ts @@ -0,0 +1,37 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { Expose, Type } from "class-transformer"; +import { IsInt, IsEnum, ValidateNested } from "class-validator"; + +import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +import { WebhookOutputDto } from "./webhook.output"; + +export class UserWebhookOutputDto extends WebhookOutputDto { + @IsInt() + @Expose() + readonly userId!: number; +} + +export class UserWebhookOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: UserWebhookOutputDto; +} + +export class UserWebhooksOutputResponseDto { + @Expose() + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: UserWebhookOutputDto[]; +} diff --git a/apps/api/v2/src/modules/webhooks/outputs/webhook.output.ts b/apps/api/v2/src/modules/webhooks/outputs/webhook.output.ts new file mode 100644 index 0000000000..1fafa31583 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/outputs/webhook.output.ts @@ -0,0 +1,51 @@ +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 { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants"; + +export class WebhookOutputDto { + @IsInt() + @Expose() + readonly id!: number; + + @IsString() + @Expose() + @ApiProperty({ + description: + "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + example: JSON.stringify({ + content: "A new event has been scheduled", + type: "{{type}}", + name: "{{title}}", + organizer: "{{organizer.name}}", + booker: "{{attendees.0.name}}", + }), + }) + readonly payloadTemplate!: string; + + @IsEnum(WebhookTriggerEvents) + @Expose() + readonly triggers!: WebhookTriggerEvents[]; + + @IsString() + @Expose() + readonly subscriberUrl!: string; + + @IsBoolean() + @Expose() + readonly active!: boolean; +} + +export class DeleteManyWebhooksOutputResponseDto { + @ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] }) + @IsEnum([SUCCESS_STATUS, ERROR_STATUS]) + @Expose() + status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS; + + @Expose() + @ValidateNested() + @Type(() => WebhookOutputDto) + data!: string; +} diff --git a/apps/api/v2/src/modules/webhooks/pipes/WebhookInputPipe.ts b/apps/api/v2/src/modules/webhooks/pipes/WebhookInputPipe.ts new file mode 100644 index 0000000000..efaef7395d --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/pipes/WebhookInputPipe.ts @@ -0,0 +1,27 @@ +import { CreateWebhookInputDto, UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { PipeTransform, Injectable } from "@nestjs/common"; + +@Injectable() +export class WebhookInputPipe implements PipeTransform { + transform(value: CreateWebhookInputDto) { + const { triggers, ...rest } = value; + const eventTriggers = triggers; + const parsedData = { ...rest, eventTriggers }; + return parsedData; + } +} + +@Injectable() +export class PartialWebhookInputPipe implements PipeTransform { + transform(value: UpdateWebhookInputDto) { + if (value.triggers) { + const { triggers, ...rest } = value; + const eventTriggers = triggers; + const parsedData = { ...rest, eventTriggers }; + return parsedData; + } + return { ...value, eventTriggers: undefined }; + } +} + +export type PipedInputWebhookType = ReturnType; diff --git a/apps/api/v2/src/modules/webhooks/pipes/WebhookOutputPipe.ts b/apps/api/v2/src/modules/webhooks/pipes/WebhookOutputPipe.ts new file mode 100644 index 0000000000..73f2d82747 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/pipes/WebhookOutputPipe.ts @@ -0,0 +1,17 @@ +import { PipeTransform, Injectable } from "@nestjs/common"; +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; + } +} + +export type PipedOutputWebhookType = ReturnType; diff --git a/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts new file mode 100644 index 0000000000..89014abf55 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts @@ -0,0 +1,31 @@ +import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { ConflictException, Injectable } from "@nestjs/common"; + +@Injectable() +export class EventTypeWebhooksService { + constructor(private readonly webhooksRepository: WebhooksRepository) {} + + async createEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) { + 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, + }); + } + + getEventTypeWebhooksPaginated(eventTypeId: number, skip: number, take: number) { + return this.webhooksRepository.getEventTypeWebhooksPaginated(eventTypeId, skip, take); + } + + async deleteAllEventTypeWebhooks(eventTypeId: number): Promise<{ count: number }> { + return this.webhooksRepository.deleteAllEventTypeWebhooks(eventTypeId); + } +} diff --git a/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts new file mode 100644 index 0000000000..cd091ce7b7 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts @@ -0,0 +1,25 @@ +import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { ConflictException, Injectable } from "@nestjs/common"; + +@Injectable() +export class UserWebhooksService { + constructor(private readonly webhooksRepository: WebhooksRepository) {} + + async createUserWebhook(userId: number, body: PipedInputWebhookType) { + const existingWebhook = await this.webhooksRepository.getUserWebhookByUrl(userId, body.subscriberUrl); + if (existingWebhook) { + throw new ConflictException("Webhook with this subscriber url already exists for this user"); + } + + return this.webhooksRepository.createUserWebhook(userId, { + ...body, + payloadTemplate: body.payloadTemplate ?? null, + secret: body.secret ?? null, + }); + } + + async getUserWebhooksPaginated(userId: number, skip: number, take: number) { + return this.webhooksRepository.getUserWebhooksPaginated(userId, skip, take); + } +} diff --git a/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts new file mode 100644 index 0000000000..087fe31221 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts @@ -0,0 +1,24 @@ +import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { Injectable, NotFoundException } from "@nestjs/common"; + +@Injectable() +export class WebhooksService { + constructor(private readonly webhooksRepository: WebhooksRepository) {} + + async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) { + return this.webhooksRepository.updateWebhook(webhookId, body); + } + + async getWebhookById(webhookId: string) { + const webhook = await this.webhooksRepository.getWebhookById(webhookId); + if (!webhook) { + throw new NotFoundException(`Webhook (${webhookId}) not found`); + } + return webhook; + } + + async deleteWebhook(webhookId: string) { + return this.webhooksRepository.deleteWebhook(webhookId); + } +} diff --git a/apps/api/v2/src/modules/webhooks/webhooks.module.ts b/apps/api/v2/src/modules/webhooks/webhooks.module.ts new file mode 100644 index 0000000000..1774646e20 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/webhooks.module.ts @@ -0,0 +1,19 @@ +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 { Module } from "@nestjs/common"; + +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 { 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], +}) +export class WebhooksModule {} diff --git a/apps/api/v2/src/modules/webhooks/webhooks.repository.ts b/apps/api/v2/src/modules/webhooks/webhooks.repository.ts new file mode 100644 index 0000000000..14d2e7162f --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/webhooks.repository.ts @@ -0,0 +1,84 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { Injectable } from "@nestjs/common"; +import { v4 as uuidv4 } from "uuid"; + +import { Webhook } from "@calcom/prisma/client"; + +import { PrismaWriteService } from "../prisma/prisma-write.service"; + +type WebhookInputData = Pick< + Webhook, + "payloadTemplate" | "eventTriggers" | "subscriberUrl" | "secret" | "active" +>; + +@Injectable() +export class WebhooksRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async createUserWebhook(userId: number, data: WebhookInputData) { + const id = uuidv4(); + return this.dbWrite.prisma.webhook.create({ + data: { ...data, id, userId }, + }); + } + + async createEventTypeWebhook(eventTypeId: number, data: WebhookInputData) { + const id = uuidv4(); + return this.dbWrite.prisma.webhook.create({ + data: { ...data, id, eventTypeId }, + }); + } + + async updateWebhook(webhookId: string, data: Partial) { + return this.dbWrite.prisma.webhook.update({ + where: { id: webhookId }, + data, + }); + } + + async getWebhookById(webhookId: string) { + return this.dbRead.prisma.webhook.findFirst({ + where: { id: webhookId }, + }); + } + + async getUserWebhooksPaginated(userId: number, skip: number, take: number) { + return this.dbRead.prisma.webhook.findMany({ + where: { userId }, + skip, + take, + }); + } + + async getEventTypeWebhooksPaginated(eventTypeId: number, skip: number, take: number) { + return this.dbRead.prisma.webhook.findMany({ + where: { eventTypeId }, + skip, + take, + }); + } + + async getUserWebhookByUrl(userId: number, subscriberUrl: string) { + return this.dbRead.prisma.webhook.findFirst({ + where: { userId, subscriberUrl }, + }); + } + + async getEventTypeWebhookByUrl(eventTypeId: number, subscriberUrl: string) { + return this.dbRead.prisma.webhook.findFirst({ + where: { eventTypeId, subscriberUrl }, + }); + } + + async deleteWebhook(webhookId: string) { + return this.dbWrite.prisma.webhook.delete({ + where: { id: webhookId }, + }); + } + + async deleteAllEventTypeWebhooks(eventTypeId: number) { + return this.dbWrite.prisma.webhook.deleteMany({ + where: { eventTypeId }, + }); + } +} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index 1cc0411be5..76240e0fb1 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1219,16 +1219,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateScheduleInput_2024_06_11" - } - } - } - }, "responses": { "201": { "description": "", @@ -1331,16 +1321,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduleInput_2024_06_11" - } - } - } - }, "responses": { "200": { "description": "", @@ -2380,16 +2360,6 @@ } } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpdateScheduleInput_2024_04_15" - } - } - } - }, "responses": { "200": { "description": "", @@ -3261,6 +3231,369 @@ "Timezones" ] } + }, + "/v2/webhooks": { + "post": { + "operationId": "WebhooksController_createWebhook", + "summary": "Create a webhook", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookInputDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' Webhooks" + ] + }, + "get": { + "operationId": "WebhooksController_getWebhooks", + "summary": "Get all user webhooks paginated", + "parameters": [ + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhooksOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' Webhooks" + ] + } + }, + "/v2/webhooks/{webhookId}": { + "patch": { + "operationId": "WebhooksController_updateWebhook", + "summary": "Update a webhook", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookInputDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' Webhooks" + ] + }, + "get": { + "operationId": "WebhooksController_getWebhook", + "summary": "Get a webhook", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' Webhooks" + ] + }, + "delete": { + "operationId": "WebhooksController_deleteWebhook", + "summary": "Delete a webhook", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' Webhooks" + ] + } + }, + "/v2/event-types/{eventTypeId}/webhooks": { + "post": { + "operationId": "EventTypeWebhooksController_createEventTypeWebhook", + "summary": "Create a webhook for an event-type", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateWebhookInputDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' EventTypes Webhooks" + ] + }, + "get": { + "operationId": "EventTypeWebhooksController_getEventTypeWebhooks", + "summary": "Get all webhooks of an event-type", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "take", + "required": false, + "in": "query", + "description": "The number of items to return", + "example": 10, + "schema": { + "type": "number" + } + }, + { + "name": "skip", + "required": false, + "in": "query", + "description": "The number of items to skip", + "example": 0, + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhooksOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' EventTypes Webhooks" + ] + }, + "delete": { + "operationId": "EventTypeWebhooksController_deleteAllEventTypeWebhooks", + "summary": "Delete all webhooks of an event-type", + "parameters": [ + { + "name": "eventTypeId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteManyWebhooksOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' EventTypes Webhooks" + ] + } + }, + "/v2/event-types/{eventTypeId}/webhooks/{webhookId}": { + "patch": { + "operationId": "EventTypeWebhooksController_updateEventTypeWebhook", + "summary": "Update a webhook of an event-type", + "parameters": [ + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWebhookInputDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' EventTypes Webhooks" + ] + }, + "get": { + "operationId": "EventTypeWebhooksController_getEventTypeWebhook", + "summary": "Get a webhook of an event-type", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' EventTypes Webhooks" + ] + }, + "delete": { + "operationId": "EventTypeWebhooksController_deleteEventTypeWebhook", + "summary": "Delete a webhook of an event-type", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EventTypeWebhookOutputResponseDto" + } + } + } + } + }, + "tags": [ + "Users' EventTypes Webhooks" + ] + } } }, "info": { @@ -5508,57 +5841,6 @@ "data" ] }, - "CreateScheduleInput_2024_06_11": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "One-on-one coaching" - }, - "timeZone": { - "type": "string", - "example": "Europe/Rome" - }, - "availability": { - "example": [ - { - "days": [ - "Monday", - "Tuesday" - ], - "startTime": "09:00", - "endTime": "10:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" - } - }, - "isDefault": { - "type": "boolean", - "example": true - }, - "overrides": { - "example": [ - { - "date": "2024-05-20", - "startTime": "12:00", - "endTime": "14:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" - } - } - }, - "required": [ - "name", - "timeZone", - "isDefault" - ] - }, "CreateScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -5607,52 +5889,6 @@ "data" ] }, - "UpdateScheduleInput_2024_06_11": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "One-on-one coaching" - }, - "timeZone": { - "type": "string", - "example": "Europe/Rome" - }, - "availability": { - "example": [ - { - "days": [ - "Monday", - "Tuesday" - ], - "startTime": "09:00", - "endTime": "10:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleAvailabilityInput_2024_06_11" - } - }, - "isDefault": { - "type": "boolean", - "example": true - }, - "overrides": { - "example": [ - { - "date": "2024-05-20", - "startTime": "12:00", - "endTime": "14:00" - } - ], - "type": "array", - "items": { - "$ref": "#/components/schemas/ScheduleOverrideInput_2024_06_11" - } - } - } - }, "UpdateScheduleOutput_2024_06_11": { "type": "object", "properties": { @@ -6747,26 +6983,6 @@ "userId" ] }, - "GetDefaultScheduleOutput_2024_06_11": { - "type": "object", - "properties": { - "status": { - "type": "string", - "example": "success", - "enum": [ - "success", - "error" - ] - }, - "data": { - "$ref": "#/components/schemas/ScheduleOutput_2024_06_11" - } - }, - "required": [ - "status", - "data" - ] - }, "CreateAvailabilityInput_2024_04_15": { "type": "object", "properties": { @@ -7064,66 +7280,6 @@ "data" ] }, - "UpdateScheduleInput_2024_04_15": { - "type": "object", - "properties": { - "timeZone": { - "type": "string" - }, - "name": { - "type": "string" - }, - "isDefault": { - "type": "boolean" - }, - "schedule": { - "example": [ - [], - [ - { - "start": "2022-01-01T00:00:00.000Z", - "end": "2022-01-02T00:00:00.000Z" - } - ], - [], - [], - [], - [], - [] - ], - "items": { - "type": "array" - }, - "type": "array" - }, - "dateOverrides": { - "example": [ - [], - [ - { - "start": "2022-01-01T00:00:00.000Z", - "end": "2022-01-02T00:00:00.000Z" - } - ], - [], - [], - [], - [], - [] - ], - "items": { - "type": "array" - }, - "type": "array" - } - }, - "required": [ - "timeZone", - "name", - "isDefault", - "schedule" - ] - }, "EventTypeModel_2024_04_15": { "type": "object", "properties": { @@ -8425,6 +8581,285 @@ "ReserveSlotInput": { "type": "object", "properties": {} + }, + "CreateWebhookInputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "triggers": { + "type": "string", + "example": [ + "BOOKING_CREATED", + "BOOKING_RESCHEDULED", + "BOOKING_CANCELLED", + "BOOKING_CONFIRMED", + "BOOKING_REJECTED", + "BOOKING_COMPLETED", + "BOOKING_NO_SHOW", + "BOOKING_REOPENED" + ], + "enum": [ + "BOOKING_CREATED", + "BOOKING_PAYMENT_INITIATED", + "BOOKING_PAID", + "BOOKING_RESCHEDULED", + "BOOKING_REQUESTED", + "BOOKING_CANCELLED", + "BOOKING_REJECTED", + "BOOKING_NO_SHOW_UPDATED", + "FORM_SUBMITTED", + "MEETING_ENDED", + "MEETING_STARTED", + "RECORDING_READY", + "INSTANT_MEETING", + "RECORDING_TRANSCRIPTION_GENERATED" + ] + }, + "active": { + "type": "boolean" + }, + "subscriberUrl": { + "type": "string" + }, + "secret": { + "type": "string" + } + }, + "required": [ + "triggers", + "active", + "subscriberUrl" + ] + }, + "UserWebhookOutputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "userId": { + "type": "number" + }, + "id": { + "type": "number" + }, + "triggers": { + "type": "array", + "items": { + "type": "object" + } + }, + "subscriberUrl": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": [ + "payloadTemplate", + "userId", + "id", + "triggers", + "subscriberUrl", + "active" + ] + }, + "UserWebhookOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/UserWebhookOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "UpdateWebhookInputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "triggers": { + "type": "string", + "example": [ + "BOOKING_CREATED", + "BOOKING_RESCHEDULED", + "BOOKING_CANCELLED", + "BOOKING_CONFIRMED", + "BOOKING_REJECTED", + "BOOKING_COMPLETED", + "BOOKING_NO_SHOW", + "BOOKING_REOPENED" + ], + "enum": [ + "BOOKING_CREATED", + "BOOKING_PAYMENT_INITIATED", + "BOOKING_PAID", + "BOOKING_RESCHEDULED", + "BOOKING_REQUESTED", + "BOOKING_CANCELLED", + "BOOKING_REJECTED", + "BOOKING_NO_SHOW_UPDATED", + "FORM_SUBMITTED", + "MEETING_ENDED", + "MEETING_STARTED", + "RECORDING_READY", + "INSTANT_MEETING", + "RECORDING_TRANSCRIPTION_GENERATED" + ] + }, + "active": { + "type": "boolean" + }, + "subscriberUrl": { + "type": "string" + }, + "secret": { + "type": "string" + } + } + }, + "UserWebhooksOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserWebhookOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "EventTypeWebhookOutputDto": { + "type": "object", + "properties": { + "payloadTemplate": { + "type": "string", + "description": "The template of the payload that will be sent to the subscriberUrl, check cal.com/docs/core-features/webhooks for more information", + "example": "{\"content\":\"A new event has been scheduled\",\"type\":\"{{type}}\",\"name\":\"{{title}}\",\"organizer\":\"{{organizer.name}}\",\"booker\":\"{{attendees.0.name}}\"}" + }, + "eventTypeId": { + "type": "number" + }, + "id": { + "type": "number" + }, + "triggers": { + "type": "array", + "items": { + "type": "object" + } + }, + "subscriberUrl": { + "type": "string" + }, + "active": { + "type": "boolean" + } + }, + "required": [ + "payloadTemplate", + "eventTypeId", + "id", + "triggers", + "subscriberUrl", + "active" + ] + }, + "EventTypeWebhookOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/EventTypeWebhookOutputDto" + } + }, + "required": [ + "status", + "data" + ] + }, + "EventTypeWebhooksOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventTypeWebhookOutputDto" + } + } + }, + "required": [ + "status", + "data" + ] + }, + "DeleteManyWebhooksOutputResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "type": "string" + } + }, + "required": [ + "status", + "data" + ] } } } diff --git a/apps/api/v2/test/fixtures/repository/webhooks.repository.fixture.ts b/apps/api/v2/test/fixtures/repository/webhooks.repository.fixture.ts new file mode 100644 index 0000000000..0ab53e6e42 --- /dev/null +++ b/apps/api/v2/test/fixtures/repository/webhooks.repository.fixture.ts @@ -0,0 +1,22 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { TestingModule } from "@nestjs/testing"; +import { Prisma } from "@prisma/client"; + +export class WebhookRepositoryFixture { + private primaReadClient: PrismaReadService["prisma"]; + private prismaWriteClient: PrismaWriteService["prisma"]; + + constructor(private readonly module: TestingModule) { + this.primaReadClient = module.get(PrismaReadService).prisma; + this.prismaWriteClient = module.get(PrismaWriteService).prisma; + } + + async create(data: Prisma.WebhookCreateInput) { + return this.prismaWriteClient.webhook.create({ data }); + } + + async delete(webhookId: string) { + return this.prismaWriteClient.webhook.delete({ where: { id: webhookId } }); + } +}