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
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.178",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.179",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -0,0 +1,4 @@
import { RateLimitType } from "@/lib/throttler-guard";
import { Reflector } from "@nestjs/core";
export const Throttle = Reflector.createDecorator<RateLimitType>();
+16 -2
View File
@@ -1,5 +1,6 @@
import { getEnv } from "@/env";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
ttl: z.number(),
blockDuration: z.number(),
});
type RateLimitType = z.infer<typeof rateLimitSchema>;
export type RateLimitType = z.infer<typeof rateLimitSchema>;
const rateLimitsSchema = z.array(rateLimitSchema);
const sixtySecondsMs = 60 * 1000;
@@ -52,7 +53,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { context } = requestProps;
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
const request = context.switchToHttp().getRequest<Request>();
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
const response = context.switchToHttp().getResponse<Response>();
@@ -63,6 +64,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
);
if (throttleOptions) {
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
}
if (tracker.startsWith("api_key_")) {
return this.handleApiKeyRequest(tracker, response);
} else {
@@ -70,6 +75,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
}
}
private async handleApiEndpointThrottle(tracker: string, options: RateLimitType, response: Response) {
const { isBlocked } = await this.incrementRateLimit(`${tracker}_${options.name}`, options, response);
if (isBlocked) {
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
}
return true;
}
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
@@ -11,6 +11,7 @@ import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -33,6 +34,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
OrganizationsUsersBookingsModule,
OrganizationsBookingsModule,
OrganizationsRoutingFormsModule,
VerifiedResourcesModule,
RouterModule,
],
})
@@ -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),
};
}
}
@@ -0,0 +1,229 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Throttle } from "@/lib/endpoint-throttler-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 { RolesGuard } from "@/modules/auth/guards/roles/roles.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/teams/:teamId/verified-resources",
})
@UseGuards(ApiAuthGuard, RolesGuard)
@ApiTags("Teams Verified Resources")
export class TeamsVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the Email.`,
})
@Roles("TEAM_ADMIN")
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/request")
@Throttle({ limit: 5, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_emails_requests" })
@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")
@Post("/phones/verification-code/request")
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "teams_verified_resources_phones_requests" })
@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 a team.",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@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")
@Post("/phones/verification-code/verify")
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "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 a team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@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 a team.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@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 a team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@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 a team by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Roles("TEAM_ADMIN")
@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),
};
}
}
@@ -0,0 +1,196 @@
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.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 {
UserVerifiedEmailOutput,
UserVerifiedEmailOutputData,
UserVerifiedEmailsOutput,
} from "@/modules/verified-resources/outputs/verified-email.output";
import {
UserVerifiedPhoneOutput,
UserVerifiedPhoneOutputData,
UserVerifiedPhonesOutput,
} 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, 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/verified-resources",
})
@UseGuards(ApiAuthGuard)
@ApiTags("Verified Resources")
export class UserVerifiedResourcesController {
constructor(private readonly verifiedResourcesService: VerifiedResourcesService) {}
@ApiOperation({
summary: "Request Email Verification Code",
description: `Sends a verification code to the email.`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/request")
@HttpCode(HttpStatus.OK)
@Throttle({ limit: 5, ttl: 60000, blockDuration: 60000, name: "users_verified_resources_emails_requests" })
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)
@Post("/phones/verification-code/request")
@HttpCode(HttpStatus.OK)
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "users_verified_resources_phones_requests" })
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.",
description: `Use code to verify an email`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/emails/verification-code/verify")
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "users_verified_resources_phones_verify" })
@HttpCode(HttpStatus.OK)
async verifyEmail(
@Body() body: VerifyEmailInput,
@GetUser("id") userId: number
): Promise<UserVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.verifyEmail(userId, body.email, body.code);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Verify a phone number.",
description: `Use code to verify a phone number`,
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/phones/verification-code/verify")
@HttpCode(HttpStatus.OK)
async verifyPhoneNumber(
@Body() body: VerifyPhoneInput,
@GetUser("id") userId: number
): Promise<UserVerifiedPhoneOutput> {
const verifiedPhone = await this.verifiedResourcesService.verifyPhone(userId, body.phone, body.code);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserVerifiedPhoneOutputData, verifiedPhone),
};
}
@ApiOperation({
summary: "Get list of verified emails.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/emails")
@HttpCode(HttpStatus.OK)
async getVerifiedEmails(
@GetUser("id") userId: number,
@Query() pagination: SkipTakePagination
): Promise<UserVerifiedEmailsOutput> {
const verifiedEmails = await this.verifiedResourcesService.getUserVerifiedEmails(
userId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedEmails.map((verifiedEmail) => plainToClass(UserVerifiedEmailOutputData, verifiedEmail)),
};
}
@ApiOperation({
summary: "Get list of verified phone numbers.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/phones")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneNumbers(
@GetUser("id") userId: number,
@Query() pagination: SkipTakePagination
): Promise<UserVerifiedPhonesOutput> {
const verifiedPhoneNumbers = await this.verifiedResourcesService.getUserVerifiedPhoneNumbers(
userId,
pagination?.skip ?? 0,
pagination?.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: verifiedPhoneNumbers.map((verifiedPhoneNumber) =>
plainToClass(UserVerifiedPhoneOutputData, verifiedPhoneNumber)
),
};
}
@ApiOperation({
summary: "Get verified email by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/emails/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedEmailById(
@GetUser("id") userId: number,
@Param("id") id: number
): Promise<UserVerifiedEmailOutput> {
const verifiedEmail = await this.verifiedResourcesService.getUserVerifiedEmailById(userId, id);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserVerifiedEmailOutputData, verifiedEmail),
};
}
@ApiOperation({
summary: "Get verified phone number by id.",
})
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/phones/:id")
@HttpCode(HttpStatus.OK)
async getVerifiedPhoneById(
@GetUser("id") userId: number,
@Param("id") id: number
): Promise<UserVerifiedPhoneOutput> {
const verifiedPhoneNumber = await this.verifiedResourcesService.getUserVerifiedPhoneNumberById(
userId,
id
);
return {
status: SUCCESS_STATUS,
data: plainToClass(UserVerifiedPhoneOutputData, verifiedPhoneNumber),
};
}
}
@@ -0,0 +1,14 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsEmail } from "class-validator";
export class RequestEmailVerificationInput {
@ApiProperty({
type: String,
description: "Email to verify.",
example: "acme@example.com",
})
@IsEmail()
@Expose()
email!: string;
}
@@ -0,0 +1,14 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsPhoneNumber } from "class-validator";
export class RequestPhoneVerificationInput {
@ApiProperty({
type: String,
description: "Phone number to verify.",
example: "+372 5555 6666",
})
@IsPhoneNumber()
@Expose()
phone!: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsEmail, IsString } from "class-validator";
export class VerifyEmailInput {
@ApiProperty({
type: String,
description: "Email to verify.",
example: "example@acme.com",
})
@IsEmail()
@Expose()
email!: string;
@ApiProperty({
type: String,
description: "verification code sent to the email to verify",
example: "1ABG2C",
})
@Expose()
@IsString()
code!: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsEmail, IsPhoneNumber, IsString } from "class-validator";
export class VerifyPhoneInput {
@ApiProperty({
type: String,
description: "phone number to verify.",
example: "+37255556666",
})
@IsPhoneNumber()
@Expose()
phone!: string;
@ApiProperty({
type: String,
description: "verification code sent to the phone number to verify",
example: "1ABG2C",
})
@Expose()
@IsString()
code!: string;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class RequestEmailVerificationOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,10 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class RequestPhoneVerificationOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,135 @@
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import {
IsArray,
IsEnum,
IsNotEmptyObject,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class UserVerifiedEmailOutputData {
@Expose()
@IsNumber()
@ApiProperty({
description: "The unique identifier for the verified email.",
example: 789,
})
id!: number;
@Expose()
@IsString()
@ApiProperty({
description: "The verified email address.",
example: "user@example.com",
format: "email",
})
email!: string;
@Expose()
@IsNumber()
@ApiProperty({
description: "The ID of the associated user, if applicable.",
example: 45,
})
userId!: number;
}
export class TeamVerifiedEmailOutputData {
@Expose()
@IsNumber()
@ApiProperty({
description: "The unique identifier for the verified email.",
example: 789,
})
id!: number;
@Expose()
@IsString()
@ApiProperty({
description: "The verified email address.",
example: "user@example.com",
format: "email",
})
email!: string;
@Expose()
@IsNumber()
@ApiProperty({
description: "The ID of the associated team, if applicable.",
example: 89,
})
teamId!: number;
@Expose()
@IsNumber()
@IsOptional()
@ApiProperty({
description: "The ID of the associated user, if applicable.",
example: 45,
nullable: true,
required: false,
})
userId?: number | null;
}
export class TeamVerifiedEmailOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@Type(() => TeamVerifiedEmailOutputData)
data!: TeamVerifiedEmailOutputData;
}
export class UserVerifiedEmailOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@Type(() => UserVerifiedEmailOutputData)
data!: UserVerifiedEmailOutputData;
}
export class TeamVerifiedEmailsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeamVerifiedEmailOutputData)
data!: TeamVerifiedEmailOutputData[];
}
export class UserVerifiedEmailsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserVerifiedEmailOutputData)
data!: UserVerifiedEmailOutputData[];
}
@@ -0,0 +1,135 @@
import { ScheduleOutput } from "@/ee/schedules/schedules_2024_04_15/outputs/schedule.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import {
IsArray,
IsEnum,
IsNotEmptyObject,
IsNumber,
IsString,
ValidateNested,
IsOptional,
} from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class UserVerifiedPhoneOutputData {
@Expose()
@IsNumber()
@ApiProperty({
description: "The unique identifier for the verified email.",
example: 789,
})
id!: number;
@Expose()
@IsString()
@ApiProperty({
description: "The verified phone number.",
example: "+37255556666",
format: "phone",
})
phoneNumber!: string;
@Expose()
@IsNumber()
@ApiProperty({
description: "The ID of the associated user, if applicable.",
example: 45,
})
userId!: number;
}
export class TeamVerifiedPhoneOutputData {
@Expose()
@IsNumber()
@ApiProperty({
description: "The unique identifier for the verified email.",
example: 789,
})
id!: number;
@Expose()
@IsString()
@ApiProperty({
description: "The verified phone number.",
example: "+37255556666",
format: "phone",
})
phoneNumber!: string;
@Expose()
@IsNumber()
@IsOptional()
@ApiProperty({
description: "The ID of the associated user, if applicable.",
example: 45,
nullable: true,
required: false,
})
userId?: number | null;
@Expose()
@IsNumber()
@ApiProperty({
description: "The ID of the associated team, if applicable.",
example: 89,
})
teamId!: number;
}
export class UserVerifiedPhoneOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@Type(() => UserVerifiedPhoneOutputData)
data!: UserVerifiedPhoneOutputData;
}
export class TeamVerifiedPhoneOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@Type(() => TeamVerifiedPhoneOutputData)
data!: TeamVerifiedPhoneOutputData;
}
export class UserVerifiedPhonesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@IsArray()
@ValidateNested({ each: true })
@Type(() => UserVerifiedPhoneOutputData)
data!: UserVerifiedPhoneOutputData[];
}
export class TeamVerifiedPhonesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: ScheduleOutput,
})
@IsNotEmptyObject()
@IsArray()
@ValidateNested({ each: true })
@Type(() => TeamVerifiedPhoneOutputData)
data!: TeamVerifiedPhoneOutputData[];
}
@@ -0,0 +1,120 @@
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { UsersVerifiedResourcesRepository } from "@/modules/verified-resources/users-verified-resources.repository";
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
import {
verifyPhoneNumber,
sendVerificationCode as sendPhoneVerificationCode,
} from "@calcom/platform-libraries";
import { sendEmailVerificationByCode, verifyEmailCodeHandler } from "@calcom/platform-libraries/emails";
@Injectable()
export class VerifiedResourcesService {
constructor(
private readonly usersVerifiedResourcesRepository: UsersVerifiedResourcesRepository,
private readonly teamsVerifiedResourcesRepository: TeamsVerifiedResourcesRepository
) {}
async requestEmailVerificationCode(user: { username: string; locale: string }, email: string) {
const res = await sendEmailVerificationByCode({
email: email,
language: user.locale,
username: user.username,
isVerifyingEmail: true,
});
if (res.ok && !res.skipped) {
return true;
}
if (!res.ok) {
throw new BadRequestException("Email could not be verified.");
}
if (res.skipped) {
throw new ConflictException("Email is already verified.");
}
return true;
}
async requestPhoneVerificationCode(phone: string) {
try {
await sendPhoneVerificationCode(phone);
} catch (err) {
throw new BadRequestException("Could not send verification code to this phone number.");
}
return true;
}
async verifyPhone(userId: number, phone: string, code: string, teamId?: number) {
const result = await verifyPhoneNumber(phone, code, userId, teamId);
if (result) {
return teamId
? await this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumber(userId, phone, teamId)
: await this.usersVerifiedResourcesRepository.getUserVerifiedPhoneNumber(userId, phone);
}
throw new BadRequestException("Could not verify phone number.");
}
async verifyEmail(userId: number, email: string, code: string, teamId?: number) {
const isValidToken = await verifyEmailCodeHandler({
ctx: { user: { id: userId } },
input: { email, code, teamId },
});
if (isValidToken) {
const verifiedEmail = teamId
? await this.teamsVerifiedResourcesRepository.getTeamVerifiedEmail(userId, email, teamId)
: await this.usersVerifiedResourcesRepository.getUserVerifiedEmail(userId, email);
return verifiedEmail;
}
throw new BadRequestException("Invalid Verification Code");
}
async getUserVerifiedEmailById(userId: number, id: number) {
return this.usersVerifiedResourcesRepository.getUserVerifiedEmailById(userId, id);
}
async getTeamVerifiedEmailById(teamId: number, id: number) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedEmailById(id, teamId);
}
async getVerifiedEmail(userId: number, email: string) {
return this.usersVerifiedResourcesRepository.getUserVerifiedEmail(userId, email);
}
async getUserVerifiedEmails(userId: number, skip = 0, take = 250) {
return this.usersVerifiedResourcesRepository.getUserVerifiedEmails(userId, skip, take);
}
async getTeamVerifiedEmails(teamId: number, skip = 0, take = 250) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedEmails(teamId, skip, take);
}
async getUserVerifiedPhoneNumberById(userId: number, id: number) {
return this.usersVerifiedResourcesRepository.getUserVerifiedPhoneNumberById(userId, id);
}
async getTeamVerifiedPhoneNumberById(teamId: number, id: number) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumberById(id, teamId);
}
async getUserVerifiedPhoneNumber(userId: number, phoneNumber: string) {
return this.usersVerifiedResourcesRepository.getUserVerifiedEmail(userId, phoneNumber);
}
async getTeamVerifiedPhoneNumber(userId: number, phoneNumber: string, teamId: number) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumber(userId, phoneNumber, teamId);
}
async getUserVerifiedPhoneNumbers(userId: number, skip = 0, take = 250) {
return this.usersVerifiedResourcesRepository.getUserVerifiedPhoneNumbers(userId, skip, take);
}
async getTeamVerifiedPhoneNumbers(teamId: number, skip = 0, take = 250) {
return this.teamsVerifiedResourcesRepository.getTeamVerifiedPhoneNumbers(teamId, skip, take);
}
}
@@ -0,0 +1,66 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class TeamsVerifiedResourcesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getTeamVerifiedEmails(teamId: number, skip = 0, take = 250) {
return this.dbRead.prisma.verifiedEmail.findMany({
where: {
teamId,
},
skip,
take,
});
}
async getTeamVerifiedPhoneNumbers(teamId: number, skip = 0, take = 250) {
return this.dbRead.prisma.verifiedNumber.findMany({
where: {
teamId,
},
skip,
take,
});
}
async getTeamVerifiedEmail(userId: number, email: string, teamId: number) {
return this.dbRead.prisma.verifiedEmail.findFirstOrThrow({
where: {
userId,
email,
teamId,
},
});
}
async getTeamVerifiedPhoneNumber(userId: number, phone: string, teamId: number) {
return this.dbRead.prisma.verifiedNumber.findFirstOrThrow({
where: {
userId,
phoneNumber: phone,
teamId,
},
});
}
async getTeamVerifiedEmailById(id: number, teamId: number) {
return this.dbRead.prisma.verifiedEmail.findFirst({
where: {
id,
teamId,
},
});
}
async getTeamVerifiedPhoneNumberById(id: number, teamId: number) {
return this.dbRead.prisma.verifiedNumber.findFirst({
where: {
id,
teamId,
},
});
}
}
@@ -0,0 +1,64 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class UsersVerifiedResourcesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getUserVerifiedEmails(userId: number, skip = 0, take = 250) {
return this.dbRead.prisma.verifiedEmail.findMany({
where: {
userId,
},
skip,
take,
});
}
async getUserVerifiedPhoneNumber(userId: number, phone: string) {
return this.dbRead.prisma.verifiedNumber.findFirstOrThrow({
where: {
userId,
phoneNumber: phone,
},
});
}
async getUserVerifiedPhoneNumbers(userId: number, skip = 0, take = 250) {
return this.dbRead.prisma.verifiedNumber.findMany({
where: {
userId,
},
skip,
take,
});
}
async getUserVerifiedEmail(userId: number, email: string) {
return this.dbRead.prisma.verifiedEmail.findFirstOrThrow({
where: {
userId,
email,
},
});
}
async getUserVerifiedEmailById(userId: number, id: number) {
return this.dbRead.prisma.verifiedEmail.findFirst({
where: {
userId,
id,
},
});
}
async getUserVerifiedPhoneNumberById(userId: number, id: number) {
return this.dbRead.prisma.verifiedNumber.findFirst({
where: {
userId,
id,
},
});
}
}
@@ -0,0 +1,39 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrgTeamsVerifiedResourcesController } from "@/modules/organizations/teams/verified-resources/org-teams-verified-resources.controller";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TeamsVerifiedResourcesController } from "@/modules/teams/verified-resources/teams-verified-resources.controller";
import { UsersRepository } from "@/modules/users/users.repository";
import { UserVerifiedResourcesController } from "@/modules/verified-resources/controllers/users-verified-resources.controller";
import { VerifiedResourcesService } from "@/modules/verified-resources/services/verified-resources.service";
import { TeamsVerifiedResourcesRepository } from "@/modules/verified-resources/teams-verified-resources.repository";
import { UsersVerifiedResourcesRepository } from "@/modules/verified-resources/users-verified-resources.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule],
controllers: [
UserVerifiedResourcesController,
TeamsVerifiedResourcesController,
OrgTeamsVerifiedResourcesController,
],
providers: [
VerifiedResourcesService,
UsersVerifiedResourcesRepository,
TeamsVerifiedResourcesRepository,
MembershipsRepository,
OrganizationsTeamsRepository,
OrganizationsRepository,
StripeService,
AppsRepository,
CredentialsRepository,
UsersRepository,
],
exports: [VerifiedResourcesService],
})
export class VerifiedResourcesModule {}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
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 VerifiedResourcesRepositoryFixtures {
private prismaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.prismaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async createPhone(data: Prisma.VerifiedNumberCreateInput) {
return this.prismaWriteClient.verifiedNumber.create({ data });
}
async deleteEmailById(id: number) {
return this.prismaWriteClient.verifiedEmail.delete({ where: { id } });
}
async deletePhoneById(id: number) {
return this.prismaWriteClient.verifiedNumber.delete({ where: { id } });
}
}
File diff suppressed because it is too large Load Diff
+9
View File
@@ -4,12 +4,17 @@ import AttendeeRequestEmail from "@calcom/emails/templates/attendee-request-emai
import AttendeeRescheduledEmail from "@calcom/emails/templates/attendee-rescheduled-email";
import AttendeeScheduledEmail from "@calcom/emails/templates/attendee-scheduled-email";
import AttendeeUpdatedEmail from "@calcom/emails/templates/attendee-updated-email";
import AttendeeVerifyEmail from "@calcom/emails/templates/attendee-verify-email";
import OrganizerCancelledEmail from "@calcom/emails/templates/organizer-cancelled-email";
import OrganizerReassignedEmail from "@calcom/emails/templates/organizer-reassigned-email";
import OrganizerRequestEmail from "@calcom/emails/templates/organizer-request-email";
import OrganizerRescheduledEmail from "@calcom/emails/templates/organizer-rescheduled-email";
import OrganizerScheduledEmail from "@calcom/emails/templates/organizer-scheduled-email";
import { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail";
import { sendSignupToOrganizationEmail } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/utils";
import { verifyEmailCodeHandler } from "@calcom/trpc/server/routers/viewer/workflows/verifyEmailCode.handler";
export { AttendeeVerifyEmail };
export { AttendeeScheduledEmail };
@@ -34,3 +39,7 @@ export { OrganizerRequestEmail };
export { AttendeeRequestEmail };
export { sendSignupToOrganizationEmail };
export { sendEmailVerificationByCode };
export { verifyEmailCodeHandler };
+6
View File
@@ -4,6 +4,10 @@ import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
import * as newBookingMethods from "@calcom/features/bookings/lib/handleNewBooking";
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
import {
verifyPhoneNumber,
sendVerificationCode,
} from "@calcom/features/ee/workflows/lib/reminders/verifyPhoneNumber";
import { handleCreatePhoneCall } from "@calcom/features/handleCreatePhoneCall";
import handleMarkNoShow from "@calcom/features/handleMarkNoShow";
import * as instantMeetingMethods from "@calcom/features/instant-meeting/handleInstantMeeting";
@@ -117,3 +121,5 @@ export { getCalendarLinks } from "@calcom/lib/bookings/getCalendarLinks";
export { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
export type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.schema";
export { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
export { verifyPhoneNumber, sendVerificationCode };
@@ -10,7 +10,7 @@ import type { TVerifyEmailCodeInputSchema } from "./verifyEmailCode.schema";
type VerifyEmailCodeOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
user: Pick<NonNullable<TrpcSessionUser>, "id">;
};
input: TVerifyEmailCodeInputSchema;
};