diff --git a/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts b/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts new file mode 100644 index 00000000000000..0dbdc9fe651023 --- /dev/null +++ b/apps/api/v2/src/modules/auth/guards/organizations/is-webhook-in-org.guard.ts @@ -0,0 +1,67 @@ +import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; +import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository"; +import { RedisService } from "@/modules/redis/redis.service"; +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; +import { Request } from "express"; + +import { Team } from "@calcom/prisma/client"; + +type CachedData = { + org?: Team; + canAccess?: boolean; +}; + +@Injectable() +export class IsWebhookInOrg implements CanActivate { + constructor( + private organizationsRepository: OrganizationsRepository, + private organizationsWebhooksRepository: OrganizationsWebhooksRepository, + private readonly redisService: RedisService + ) {} + + async canActivate(context: ExecutionContext): Promise { + let canAccess = false; + const request = context.switchToHttp().getRequest(); + const webhookId: string = request.params.webhookId; + const organizationId: string = request.params.orgId; + + if (!organizationId) { + throw new ForbiddenException("No organization id found in request params."); + } + if (!webhookId) { + throw new ForbiddenException("No webhook id found in request params."); + } + + const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`; + const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY); + + if (cachedData) { + const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData; + if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) { + request.organization = cachedOrg; + return cachedCanAccess; + } + } + + const org = await this.organizationsRepository.findById(Number(organizationId)); + + if (org?.isOrganization) { + const isWebhookInOrg = await this.organizationsWebhooksRepository.findWebhook( + Number(organizationId), + webhookId + ); + if (isWebhookInOrg) canAccess = true; + } + + if (org) { + await this.redisService.redis.set( + REDIS_CACHE_KEY, + JSON.stringify({ org: org, canAccess } satisfies CachedData), + "EX", + 300 + ); + } + + return canAccess; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts index ec285632653e63..65868bc732447c 100644 --- a/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts +++ b/apps/api/v2/src/modules/organizations/controllers/memberships/organizations-membership.controller.ts @@ -1,6 +1,5 @@ import { API_VERSIONS_VALUES } from "@/lib/api-versions"; import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; -import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator"; import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; @@ -36,7 +35,6 @@ import { plainToClass } from "class-transformer"; import { SUCCESS_STATUS } from "@calcom/platform-constants"; import { SkipTakePagination } from "@calcom/platform-types"; -import { Membership } from "@calcom/prisma/client"; @Controller({ path: "/v2/organizations/:orgId/memberships", @@ -89,7 +87,11 @@ export class OrganizationsMembershipsController { @UseGuards(IsMembershipInOrg) @Get("/:membershipId") @HttpCode(HttpStatus.OK) - async getUserSchedule(@GetMembership() membership: Membership): Promise { + async getOrgMembership( + @Param("orgId", ParseIntPipe) orgId: number, + @Param("membershipId", ParseIntPipe) membershipId: number + ): Promise { + const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId); return { status: SUCCESS_STATUS, data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }), diff --git a/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.controller.ts b/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.controller.ts new file mode 100644 index 00000000000000..7f26d7546e4aac --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.controller.ts @@ -0,0 +1,147 @@ +import { API_VERSIONS_VALUES } from "@/lib/api-versions"; +import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator"; +import { Roles } from "@/modules/auth/decorators/roles/roles.decorator"; +import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard"; +import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard"; +import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard"; +import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard"; +import { IsWebhookInOrg } from "@/modules/auth/guards/organizations/is-webhook-in-org.guard"; +import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard"; +import { OrganizationsWebhooksService } from "@/modules/organizations/services/organizations-webhooks.service"; +import { CreateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { + TeamWebhookOutputDto as OrgWebhookOutputDto, + TeamWebhookOutputResponseDto as OrgWebhookOutputResponseDto, + TeamWebhooksOutputResponseDto as OrgWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/team-webhook.output"; +import { PartialWebhookInputPipe, WebhookInputPipe } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhookOutputPipe } from "@/modules/webhooks/pipes/WebhookOutputPipe"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { + Controller, + UseGuards, + Get, + Param, + ParseIntPipe, + Query, + Delete, + Patch, + Post, + Body, + HttpCode, + HttpStatus, +} from "@nestjs/common"; +import { ApiTags as DocsTags } from "@nestjs/swagger"; +import { plainToClass } from "class-transformer"; + +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { SkipTakePagination } from "@calcom/platform-types"; + +@Controller({ + path: "/v2/organizations/:orgId/webhooks", + version: API_VERSIONS_VALUES, +}) +@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard) +@DocsTags("Organizations Webhooks") +export class OrganizationsWebhooksController { + constructor( + private organizationsWebhooksService: OrganizationsWebhooksService, + private webhooksService: WebhooksService + ) {} + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Get("/") + @HttpCode(HttpStatus.OK) + async getAllOrganizationWebhooks( + @Param("orgId", ParseIntPipe) orgId: number, + @Query() queryParams: SkipTakePagination + ): Promise { + const { skip, take } = queryParams; + const webhooks = await this.organizationsWebhooksService.getWebhooksPaginated( + orgId, + skip ?? 0, + take ?? 250 + ); + return { + status: SUCCESS_STATUS, + data: webhooks.map((webhook) => + plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }) + ), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @Post("/") + @HttpCode(HttpStatus.CREATED) + async createOrganizationWebhook( + @Param("orgId", ParseIntPipe) orgId: number, + @Body() body: CreateWebhookInputDto + ): Promise { + const webhook = await this.organizationsWebhooksService.createWebhook( + orgId, + new WebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsWebhookInOrg) + @Get("/:webhookId") + @HttpCode(HttpStatus.OK) + async getOrganizationWebhook(@Param("webhookId") webhookId: string): Promise { + const webhook = await this.organizationsWebhooksService.getWebhook(webhookId); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsWebhookInOrg) + @Delete("/:webhookId") + @HttpCode(HttpStatus.OK) + async deleteWebhook(@Param("webhookId") webhookId: string): Promise { + const webhook = await this.webhooksService.deleteWebhook(webhookId); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } + + @Roles("ORG_ADMIN") + @PlatformPlan("ESSENTIALS") + @UseGuards(IsWebhookInOrg) + @Patch("/:webhookId") + @HttpCode(HttpStatus.OK) + async updateOrgWebhook( + @Param("webhookId") webhookId: string, + @Body() body: UpdateWebhookInputDto + ): Promise { + const webhook = await this.organizationsWebhooksService.updateWebhook( + webhookId, + new PartialWebhookInputPipe().transform(body) + ); + return { + status: SUCCESS_STATUS, + data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), { + strategy: "excludeAll", + }), + }; + } +} diff --git a/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.e2e-spec.ts b/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.e2e-spec.ts new file mode 100644 index 00000000000000..8b23a250a6f406 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/controllers/webhooks/organizations-webhooks.e2e-spec.ts @@ -0,0 +1,211 @@ +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 { + TeamWebhookOutputResponseDto, + TeamWebhooksOutputResponseDto, +} from "@/modules/webhooks/outputs/team-webhook.output"; +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import * as request from "supertest"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.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 { Team, Webhook } from "@calcom/prisma/client"; + +describe("WebhooksController (e2e)", () => { + let app: INestApplication; + const userEmail = "webhook-controller-e2e@api.com"; + let org: Team; + + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let webhookRepositoryFixture: WebhookRepositoryFixture; + let userRepositoryFixture: UserRepositoryFixture; + let membershipsRepositoryFixture: MembershipRepositoryFixture; + let webhook: TeamWebhookOutputResponseDto["data"]; + let otherWebhook: Webhook; + let user: UserWithProfile; + + beforeAll(async () => { + const moduleRef = await withApiAuth( + userEmail, + Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }) + ).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + webhookRepositoryFixture = new WebhookRepositoryFixture(moduleRef); + membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: "Test Organization", + isOrganization: true, + }); + + await membershipsRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + }); + + 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); + webhookRepositoryFixture.delete(otherWebhook.id); + await app.close(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("/organizations/:orgId/webhooks (POST)", () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.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) => { + process.stdout.write(JSON.stringify(res.body)); + 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", + teamId: org.id, + }, + } satisfies TeamWebhookOutputResponseDto); + webhook = res.body.data; + }); + }); + + it("/organizations/:orgId/webhooks (POST) should fail to create a webhook that already has same orgId / subcriberUrl combo", () => { + return request(app.getHttpServer()) + .post(`/v2/organizations/${org.id}/webhooks`) + .send({ + subscriberUrl: "https://example.com", + triggers: ["BOOKING_CREATED", "BOOKING_RESCHEDULED", "BOOKING_CANCELLED"], + active: true, + payloadTemplate: "string", + } satisfies CreateWebhookInputDto) + .expect(409); + }); + + it("/organizations/:orgId/webhooks/:webhookId (PATCH)", () => { + return request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/webhooks/${webhook.id}`) + .send({ + active: false, + } satisfies UpdateWebhookInputDto) + .expect(200) + .then((res) => { + expect(res.body.data.active).toBe(false); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.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", + teamId: org.id, + }, + } satisfies TeamWebhookOutputResponseDto); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (GET) should say forbidden to get a webhook that does not exist", () => { + return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/webhooks/90284`).expect(403); + }); + + it("/organizations/:orgId/webhooks/:webhookId (GET) should fail to get a webhook that does not belong to org", () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`) + .expect(403); + }); + + it("/organizations/:orgId/webhooks (GET)", () => { + return request(app.getHttpServer()) + .get(`/v2/organizations/${org.id}/webhooks`) + .expect(200) + .then((res) => { + const responseBody = res.body as TeamWebhooksOutputResponseDto; + responseBody.data.forEach((webhook) => { + expect(webhook.teamId).toBe(org.id); + }); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (DELETE)", () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.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", + teamId: org.id, + }, + } satisfies TeamWebhookOutputResponseDto); + }); + }); + + it("/organizations/:orgId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not exist", () => { + return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/webhooks/12993`).expect(403); + }); + + it("/organizations/:orgId/webhooks/:webhookId (DELETE) shoud fail to delete a webhook that does not belong to org", () => { + return request(app.getHttpServer()) + .delete(`/v2/organizations/${org.id}/webhooks/${otherWebhook.id}`) + .expect(403); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/organizations.module.ts b/apps/api/v2/src/modules/organizations/organizations.module.ts index a2eed9969a0d75..54155ba02e6df0 100644 --- a/apps/api/v2/src/modules/organizations/organizations.module.ts +++ b/apps/api/v2/src/modules/organizations/organizations.module.ts @@ -9,6 +9,7 @@ import { OrganizationsSchedulesController } from "@/modules/organizations/contro import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller"; import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller"; import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller"; +import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller"; import { OrganizationsRepository } from "@/modules/organizations/organizations.repository"; import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository"; import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository"; @@ -16,6 +17,7 @@ import { OrganizationSchedulesRepository } from "@/modules/organizations/reposit import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository"; import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository"; import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository"; +import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository"; import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service"; import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service"; import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service"; @@ -24,11 +26,14 @@ import { OrganizationsSchedulesService } from "@/modules/organizations/services/ import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service"; import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service"; import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service"; +import { OrganizationsWebhooksService } from "@/modules/organizations/services/organizations-webhooks.service"; import { OrganizationsService } from "@/modules/organizations/services/organizations.service"; import { PrismaModule } from "@/modules/prisma/prisma.module"; import { RedisModule } from "@/modules/redis/redis.module"; import { StripeModule } from "@/modules/stripe/stripe.module"; import { UsersModule } from "@/modules/users/users.module"; +import { WebhooksService } from "@/modules/webhooks/services/webhooks.service"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; import { Module } from "@nestjs/common"; @Module({ @@ -60,6 +65,10 @@ import { Module } from "@nestjs/common"; OrganizationsEventTypesRepository, OrganizationsTeamsMembershipsRepository, OrganizationsTeamsMembershipsService, + OrganizationsWebhooksRepository, + OrganizationsWebhooksService, + WebhooksRepository, + WebhooksService, ], exports: [ OrganizationsService, @@ -71,6 +80,10 @@ import { Module } from "@nestjs/common"; OrganizationsMembershipService, OrganizationsTeamsMembershipsRepository, OrganizationsTeamsMembershipsService, + OrganizationsWebhooksRepository, + OrganizationsWebhooksService, + WebhooksRepository, + WebhooksService, ], controllers: [ OrganizationsTeamsController, @@ -79,6 +92,7 @@ import { Module } from "@nestjs/common"; OrganizationsMembershipsController, OrganizationsEventTypesController, OrganizationsTeamsMembershipsController, + OrganizationsWebhooksController, ], }) export class OrganizationsModule {} diff --git a/apps/api/v2/src/modules/organizations/repositories/organizations-webhooks.repository.ts b/apps/api/v2/src/modules/organizations/repositories/organizations-webhooks.repository.ts new file mode 100644 index 00000000000000..1caec8fd69c936 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/repositories/organizations-webhooks.repository.ts @@ -0,0 +1,71 @@ +import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { Injectable } from "@nestjs/common"; +import { v4 as uuidv4 } from "uuid"; + +import { Webhook } from "@calcom/prisma/client"; + +type WebhookInputData = Pick< + Webhook, + "payloadTemplate" | "eventTriggers" | "subscriberUrl" | "secret" | "active" +>; +@Injectable() +export class OrganizationsWebhooksRepository { + constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {} + + async findWebhookByUrl(organizationId: number, subscriberUrl: string) { + return this.dbRead.prisma.webhook.findFirst({ + where: { teamId: organizationId, subscriberUrl }, + }); + } + + async findWebhook(organizationId: number, webhookId: string) { + return this.dbRead.prisma.webhook.findUnique({ + where: { + id: webhookId, + teamId: organizationId, + }, + }); + } + + async findWebhooks(organizationId: number) { + return this.dbRead.prisma.webhook.findMany({ + where: { + teamId: organizationId, + }, + }); + } + + async deleteWebhook(organizationId: number, webhookId: string) { + return this.dbRead.prisma.webhook.delete({ + where: { + id: webhookId, + teamId: organizationId, + }, + }); + } + + async createWebhook(organizationId: number, data: WebhookInputData) { + const id = uuidv4(); + return this.dbWrite.prisma.webhook.create({ + data: { ...data, id, teamId: organizationId }, + }); + } + + async updateWebhook(organizationId: number, webhookId: string, data: Partial) { + return this.dbRead.prisma.webhook.update({ + data: { ...data }, + where: { id: webhookId, teamId: organizationId }, + }); + } + + async findWebhooksPaginated(organizationId: number, skip: number, take: number) { + return this.dbRead.prisma.webhook.findMany({ + where: { + teamId: organizationId, + }, + skip, + take, + }); + } +} diff --git a/apps/api/v2/src/modules/organizations/services/organizations-webhooks.service.ts b/apps/api/v2/src/modules/organizations/services/organizations-webhooks.service.ts new file mode 100644 index 00000000000000..574ada6ae38372 --- /dev/null +++ b/apps/api/v2/src/modules/organizations/services/organizations-webhooks.service.ts @@ -0,0 +1,45 @@ +import { OrganizationsWebhooksRepository } from "@/modules/organizations/repositories/organizations-webhooks.repository"; +import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; +import { ConflictException, Injectable, NotFoundException } from "@nestjs/common"; + +@Injectable() +export class OrganizationsWebhooksService { + constructor( + private readonly organizationsWebhooksRepository: OrganizationsWebhooksRepository, + private readonly webhooksRepository: WebhooksRepository + ) {} + + async createWebhook(orgId: number, body: PipedInputWebhookType) { + const existingWebhook = await this.organizationsWebhooksRepository.findWebhookByUrl( + orgId, + body.subscriberUrl + ); + if (existingWebhook) { + throw new ConflictException("Webhook with this subscriber url already exists for this user"); + } + + return this.organizationsWebhooksRepository.createWebhook(orgId, { + ...body, + payloadTemplate: body.payloadTemplate ?? null, + secret: body.secret ?? null, + }); + } + + async getWebhooksPaginated(orgId: number, skip: number, take: number) { + return this.organizationsWebhooksRepository.findWebhooksPaginated(orgId, skip, take); + } + + async getWebhook(webhookId: string) { + const webhook = await this.webhooksRepository.getWebhookById(webhookId); + if (!webhook) { + throw new NotFoundException(`Webhook (${webhookId}) not found`); + } + return webhook; + } + + async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) { + return this.webhooksRepository.updateWebhook(webhookId, body); + } +} diff --git a/apps/api/v2/src/modules/webhooks/outputs/team-webhook.output.ts b/apps/api/v2/src/modules/webhooks/outputs/team-webhook.output.ts new file mode 100644 index 00000000000000..7e67d99ab6aae7 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/outputs/team-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 TeamWebhookOutputDto extends WebhookOutputDto { + @IsInt() + @Expose() + readonly teamId!: number; +} + +export class TeamWebhookOutputResponseDto { + @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!: TeamWebhookOutputDto; +} + +export class TeamWebhooksOutputResponseDto { + @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!: TeamWebhookOutputDto[]; +} diff --git a/apps/api/v2/swagger/documentation.json b/apps/api/v2/swagger/documentation.json index fb056d87936c3a..94e9df15dbf503 100644 --- a/apps/api/v2/swagger/documentation.json +++ b/apps/api/v2/swagger/documentation.json @@ -1668,8 +1668,25 @@ }, "/v2/organizations/{orgId}/memberships/{membershipId}": { "get": { - "operationId": "OrganizationsMembershipsController_getUserSchedule", - "parameters": [], + "operationId": "OrganizationsMembershipsController_getOrgMembership", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "membershipId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + } + ], "responses": { "200": { "description": "", @@ -2268,6 +2285,214 @@ ] } }, + "/v2/organizations/{orgId}/webhooks": { + "get": { + "operationId": "OrganizationsWebhooksController_getAllWebhooks", + "parameters": [ + { + "name": "orgId", + "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/OrgWebhooksResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Webhooks" + ] + }, + "post": { + "operationId": "OrganizationsWebhooksController_createWebhook", + "parameters": [ + { + "name": "orgId", + "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/OrgWebhooksResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Webhooks" + ] + } + }, + "/v2/organizations/{orgId}/webhooks/{webhookId}": { + "get": { + "operationId": "OrganizationsWebhooksController_getOrgWebhook", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgWebhooksResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Webhooks" + ] + }, + "delete": { + "operationId": "OrganizationsWebhooksController_deleteWebhook", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "name": "webhookId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrgWebhooksResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Webhooks" + ] + }, + "patch": { + "operationId": "OrganizationsWebhooksController_updateWebhook", + "parameters": [ + { + "name": "orgId", + "required": true, + "in": "path", + "schema": { + "type": "number" + } + }, + { + "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/OrgWebhooksResponseDto" + } + } + } + } + }, + "tags": [ + "Organizations Webhooks" + ] + } + }, "/v2/schedules": { "post": { "operationId": "SchedulesController_2024_04_15_createSchedule", @@ -9163,6 +9388,84 @@ "active" ] }, + "OrgWebhookOutputDto": { + "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}}\"}" + }, + "teamId": { + "type": "number" + }, + "id": { + "type": "number" + }, + "triggers": { + "type": "array", + "items": { + "type": "object" + } + }, + "subscriberUrl": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "secret": { + "type": "string" + } + }, + "required": [ + "payloadTemplate", + "userId", + "id", + "triggers", + "subscriberUrl", + "active" + ] + }, + "GetAllOrgWebhooksDto": { + "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}}\"}" + }, + "teamId": { + "type": "number" + }, + "id": { + "type": "number" + }, + "triggers": { + "type": "array", + "items": { + "type": "object" + } + }, + "subscriberUrl": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "secret": { + "type": "string" + } + }, + "required": [ + "payloadTemplate", + "userId", + "id", + "triggers", + "subscriberUrl", + "active" + ] + }, "UserWebhookOutputResponseDto": { "type": "object", "properties": { @@ -9183,6 +9486,26 @@ "data" ] }, + "OrgWebhooksResponseDto": { + "type": "object", + "properties": { + "status": { + "type": "string", + "example": "success", + "enum": [ + "success", + "error" + ] + }, + "data": { + "$ref": "#/components/schemas/GetAllOrgWebhooksDto" + } + }, + "required": [ + "status", + "data" + ] + }, "UpdateWebhookInputDto": { "type": "object", "properties": {