feat: verified resources endpoint [v2] (#21006)

* feat: verified resources endpoint [v2]

* add tests and fixes

* chore: add endpoint throttler decorator and handling

* chore: code review comments

* chore: code review comments

* chore: code review comments

* bump platform libraries

* chore: add tests and fix issues
This commit is contained in:
Morgan
2025-05-02 13:37:19 +03:00
committed by GitHub
parent c400c36306
commit 0f0c2b8b92
27 changed files with 4618 additions and 4 deletions
@@ -6,6 +6,7 @@ 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 { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
@@ -29,6 +30,7 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
IsTeamInOrg,
IsRoutingFormInTeam,
PlatformPlanGuard,
RolesGuard,
IsAdminAPIEnabledGuard
)
@ApiHeader(API_KEY_HEADER)
@@ -0,0 +1,236 @@
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 { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
import {
TeamVerifiedEmailOutput,
TeamVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
TeamVerifiedPhoneOutput,
TeamVerifiedPhonesOutput,
} from "@/modules/verified-resources/outputs/verified-phone.output";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { TOTP as TOTPtoMock } from "@otplib/core";
import { User } from "@prisma/client";
import { totp } from "otplib";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { VerifiedResourcesRepositoryFixtures } from "test/fixtures/repository/verified-resources.repository.fixture";
import { randomString } from "test/utils/randomString";
import { AttendeeVerifyEmail } from "@calcom/platform-libraries/emails";
import { Team } from "@calcom/prisma/client";
jest.spyOn(totp, "generate").mockImplementation(function () {
return "1234";
});
jest.spyOn(TOTPtoMock.prototype, "check").mockImplementation(function () {
return true;
});
jest
.spyOn(AttendeeVerifyEmail.prototype as any, "getNodeMailerPayload")
.mockImplementation(async function () {
return {
to: `testnotrealemail@notreal.com`,
from: `testnotrealemail@notreal.com`,
subject: "No Subject",
html: "<html><body>Mocked Email Content</body></html>",
text: "body",
};
});
describe("Organizations Teams Verified Resources", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let verifiedResourcesRepositoryFixtures: VerifiedResourcesRepositoryFixtures;
let teamsRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let orgTeam: Team;
const emailToVerify = `org-team-e2e-verified-resources-${randomString()}@example.com`;
const phoneToVerify = "+37255556666";
const authEmail = `organizations-verified-resources-responses-user-${randomString()}@api.com`;
let user: User;
let apiKeyString: string;
let verifiedEmailId: number;
let verifiedPhoneId: number;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
verifiedResourcesRepositoryFixtures = new VerifiedResourcesRepositoryFixtures(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: `organizations-verified-resources-responses-organization-${randomString()}`,
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: authEmail,
username: authEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = keyString;
orgTeam = await teamsRepositoryFixture.create({
name: `organizations-verified-resources-responses-team-${randomString()}`,
isOrganization: false,
parent: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: orgTeam.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: authEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
verifiedPhoneId = (
await verifiedResourcesRepositoryFixtures.createPhone({
phoneNumber: phoneToVerify,
user: { connect: { id: user.id } },
team: {
connect: {
id: orgTeam.id,
},
},
})
).id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should trigger email verification code", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/request`
)
.send({ email: emailToVerify } satisfies RequestEmailVerificationInput)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200);
});
it("should verify email", async () => {
return request(app.getHttpServer())
.post(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/verification-code/verify`
)
.send({ email: emailToVerify, code: "1234" } satisfies VerifyEmailInput)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedEmailOutput;
verifiedEmailId = response.data.id;
expect(response.data.email).toEqual(emailToVerify);
expect(response.data.teamId).toEqual(orgTeam.id);
});
});
it("should fetch verified email by id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails/${verifiedEmailId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedEmailOutput;
expect(response.data.email).toEqual(emailToVerify);
expect(response.data.teamId).toEqual(orgTeam.id);
});
});
it("should fetch verified number by id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones/${verifiedPhoneId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedPhoneOutput;
expect(response.data.phoneNumber).toEqual(phoneToVerify);
expect(response.data.teamId).toEqual(orgTeam.id);
});
});
it("should fetch verified emails", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/emails`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedEmailsOutput;
expect(response.data.find((verifiedEmail) => verifiedEmail.email === emailToVerify)).toBeDefined();
expect(response.data.find((verifiedEmail) => verifiedEmail.teamId === orgTeam.id)).toBeDefined();
});
});
it("should fetch verified phones", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/verified-resources/phones`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((res) => {
const response = res.body as TeamVerifiedPhonesOutput;
expect(
response.data.find((verifiedPhone) => verifiedPhone.phoneNumber === phoneToVerify)
).toBeDefined();
expect(response.data.find((verifiedPhone) => verifiedPhone.teamId === orgTeam.id)).toBeDefined();
});
});
afterAll(async () => {
await verifiedResourcesRepositoryFixtures.deleteEmailById(verifiedEmailId);
await verifiedResourcesRepositoryFixtures.deletePhoneById(verifiedPhoneId);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
@@ -0,0 +1,256 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.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 { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { RequestEmailVerificationInput } from "@/modules/verified-resources/inputs/request-email-verification.input";
import { RequestPhoneVerificationInput } from "@/modules/verified-resources/inputs/request-phone-verification.input";
import { VerifyEmailInput } from "@/modules/verified-resources/inputs/verify-email.input";
import { VerifyPhoneInput } from "@/modules/verified-resources/inputs/verify-phone.input";
import { RequestEmailVerificationOutput } from "@/modules/verified-resources/outputs/request-email-verification-output";
import { RequestPhoneVerificationOutput } from "@/modules/verified-resources/outputs/request-phone-verification-output";
import {
TeamVerifiedEmailOutput,
TeamVerifiedEmailOutputData,
TeamVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
TeamVerifiedPhoneOutput,
TeamVerifiedPhoneOutputData,
TeamVerifiedPhonesOutput,
} from "@/modules/verified-resources/outputs/verified-phone.output";
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
ParseIntPipe,
Post,
Query,
UseGuards,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/verified-resources",
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiTags("Organization Team Verified Resources")
export class OrgTeamsVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the email.`,
})
@Roles("TEAM_ADMIN")
@Throttle({
limit: 3,
ttl: 60000,
blockDuration: 60000,
name: "org_teams_verified_resources_emails_requests",
})
@PlatformPlan("ESSENTIALS")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/request")
@HttpCode(HttpStatus.OK)
async requestEmailVerificationCode(
@Body() body: RequestEmailVerificationInput,
@GetUser("username") username: string,
@GetUser("locale") locale: string
): Promise<RequestEmailVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestEmailVerificationCode(
{ username, locale },
body.email
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Request Phone Number Verification Code",
description: `Sends a verification code to the phone number.`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@Throttle({
limit: 3,
ttl: 60000,
blockDuration: 60000,
name: "org_teams_verified_resources_phones_requests",
})
@Post("/phones/verification-code/request")
@HttpCode(HttpStatus.OK)
async requestPhoneVerificationCode(
@Body() body: RequestPhoneVerificationInput
): Promise<RequestPhoneVerificationOutput> {
const verificationCodeRequest = await this.verifiedResourcesService.requestPhoneVerificationCode(
body.phone
);
return {
status: verificationCodeRequest ? SUCCESS_STATUS : ERROR_STATUS,
};
}
@ApiOperation({
summary: "Verify an email for an org team.",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/emails/verification-code/verify")
@HttpCode(HttpStatus.OK)
async verifyEmail(
@Body() body: VerifyEmailInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(
userId,
body.email,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Verify a phone number for an org team.",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Post("/phones/verification-code/verify")
@Throttle({
limit: 3,
ttl: 60000,
blockDuration: 60000,
name: "org_teams_verified_resources_phones_verify",
})
@HttpCode(HttpStatus.OK)
async verifyPhoneNumber(
@Body() body: VerifyPhoneInput,
@GetUser("id") userId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(
userId,
body.phone,
body.code,
teamId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhone),
};
}
@ApiOperation({
summary: "Get list of verified emails of an org team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/emails")
@HttpCode(HttpStatus.OK)
async getVerifiedEmails(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedEmailsOutput> {
const verifiedEmails = await this.verifiedResourcesService.getTeamVerifiedEmails(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedEmails.map((verifiedEmail) => plainToClass(TeamVerifiedEmailOutputData, verifiedEmail)),
};
}
@ApiOperation({
summary: "Get list of verified phone numbers of an org team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@PlatformPlan("ESSENTIALS")
@Get("/phones")
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneNumbers(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() pagination: SkipTakePagination
): Promise<TeamVerifiedPhonesOutput> {
const verifiedPhoneNumbers = await this.verifiedResourcesService.getTeamVerifiedPhoneNumbers(
teamId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber)
),
};
}
@ApiOperation({
summary: "Get verified email of an org team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/emails/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedEmailById(
@Param("id") id: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<TeamVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.getTeamVerifiedEmailById(teamId, id);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Get verified phone number of an org team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@Get("/phones/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneById(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("id") id: number
): Promise<TeamVerifiedPhoneOutput> {
const verifiedPhoneNumber = await this.verifiedResourcesService.getTeamVerifiedPhoneNumberById(
teamId,
id
);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamVerifiedPhoneOutputData, verifiedPhoneNumber),
};
}
}