Skip to content

Commit

Permalink
feat: Org Webhooks API V2 (#16274)
Browse files Browse the repository at this point in the history
* --init

* add is webhook in org guard

* --

* doc

* e2e

* --feedback

* --

* fix typo

* delete unnecessary file

* fix v2 e2e

* fix v2 e2e

* fix e2e

* fix v2 e2e

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
  • Loading branch information
2 people authored and zomars committed Aug 29, 2024
1 parent 327ed49 commit affda27
Show file tree
Hide file tree
Showing 9 changed files with 922 additions and 5 deletions.
Original file line number Diff line number Diff line change
@@ -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<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
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;
}
}
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -89,7 +87,11 @@ export class OrganizationsMembershipsController {
@UseGuards(IsMembershipInOrg)
@Get("/:membershipId")
@HttpCode(HttpStatus.OK)
async getUserSchedule(@GetMembership() membership: Membership): Promise<GetOrgMembership> {
async getOrgMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("membershipId", ParseIntPipe) membershipId: number
): Promise<GetOrgMembership> {
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OrgWebhooksOutputResponseDto> {
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<OrgWebhookOutputResponseDto> {
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<OrgWebhookOutputResponseDto> {
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<OrgWebhookOutputResponseDto> {
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<OrgWebhookOutputResponseDto> {
const webhook = await this.organizationsWebhooksService.updateWebhook(
webhookId,
new PartialWebhookInputPipe().transform(body)
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgWebhookOutputDto, new WebhookOutputPipe().transform(webhook), {
strategy: "excludeAll",
}),
};
}
}
Loading

0 comments on commit affda27

Please sign in to comment.