Compare commits

...
26 Commits
Author SHA1 Message Date
GitHub Actions 1aa2f68e73 chore: release v5.2.9
Post release / release (push) Canceled after 0s
2025-05-05 17:55:27 +00:00
Udit Takkar ad707b0ee4 chore: improve error message (#21112) 2025-05-05 16:41:44 +00:00
Alex van Andel 49a2f4d4f7 chore: Add proton.me to email provider list (#21117) 2025-05-05 16:09:43 +00:00
Benny Joo f576979345 perf: remove unnecessary routes from middleware (#21089)
* perf: Remove unnecessary routes from middleware

* remove tests not needed
2025-05-05 11:47:27 -04:00
Keith Williams b45399ab78 chore: Update CODEOWNERS for booking tRPC handlers (#21118) 2025-05-05 15:40:42 +00:00
306dc5e4fb fix: add back round robin data to insights/router-position (#21116)
* add back all data to router-position

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-05 15:30:55 +00:00
Anik Dhabal Babu 605304b93b fix: availability view error (#21115)
* fix: avaiability view error

* Update list.handler.ts
2025-05-05 14:41:17 +00:00
amritandTushar Bhatt d6a17b2b1e feat: booking URL validation to accept links without explicit mention of https:// (#21110)
* fix: trigger lingo.dev by removing duplicate value

* feat: allow urls without an explicit https://

* fixed the test that ensures malformed URLs like "http:/nope" are rejected

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-05 12:49:19 +00:00
Yadong (Adam) Zhang 4bee875a8c build(packages/features): resolved type error when building apps/web. (#21094) 2025-05-05 11:20:05 +00:00
Anik Dhabal Babu fe4ae3ae62 fix: {Organizer} placeholder in event title not updating on reschedule (#21034) 2025-05-05 10:22:29 +00:00
Anik Dhabal Babu a245578ff2 fix: meeting link fails when manually reassign (#21106)
* fix: meeting link fails when manually reassign

* update

* Update booking.ts
2025-05-05 09:32:56 +00:00
sean-brydonandEunjae Lee f5ec696e15 fix: button fixes v3 (#20557)
* fix button styling

* add button usage

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-05-05 09:31:14 +00:00
sean-brydonandUdit Takkar ec9384858b move form builder email input away from routing form widget (#21077)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-05 09:05:01 +00:00
Eunjae Lee 47613f796e feat: add membership creation date to Organization Member List table (CAL-5406) (#21008)
* feat: add membership creation date to Organization Member List table (CAL-5406)

This reverts commit 5aaca2b7aa.

* fill createdAt
2025-05-05 10:15:30 +02:00
luzpaz 446c780d85 fix: typos in apps/api/v2 (#21105)
Found via `codespell -q 3 -S "*.svg,./apps/web/public/static/locales,./packages/app-store/stripepayment/lib/currencyOptions.ts,./packages/lib/freeEmailDomainCheck/freeEmailDomains.ts" -L afterall,atleast,datea,fo,incase,ist,nam,notin,optionel,perview,reccuring`

Closes #21104
2025-05-04 15:58:37 +00:00
Shashwatt 615968c640 fix: add missing 'add' in profile username tip (#21049) (#21097) 2025-05-04 20:13:55 +05:30
Morgan fc89436b6a chore: bump platform libraries (#21101) 2025-05-04 10:13:28 +00:00
Joe Au-Yeung 3c1015cb11 Get correct userId (#21092) 2025-05-03 00:43:05 +00:00
Anik Dhabal Babu 46c513330c test: retry times for api v2 e2e test in CI (#21087)
* test: test retry times for api v2 test

* Update jest.setup-e2e.ts
2025-05-02 19:42:36 +00:00
sean-brydon ef1598faeb fix return (#21088) 2025-05-02 20:06:58 +01:00
Anik Dhabal Babu 42a6903057 fix: wrong info on cancelled email (#21085) 2025-05-02 17:19:19 +00:00
Morgan 0f0c2b8b92 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
2025-05-02 13:37:19 +03:00
VijayandTushar Bhatt c400c36306 fix: between operator for attribute matching (#21031)
* update json logic for raqb between operator

* nit

* add tests

* e2e tests

* after merge: updated tests due to latest changes in routingforms v3

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-02 10:29:48 +00:00
sean-brydon 6410bd2eaf fix: rf v3 tidy up mobile issues (#21078)
* remove incomplete booking from mobile

* add incomplete booking to dropdown on mobile

* padding
2025-05-02 10:01:07 +00:00
Alex van Andel 2ddc5317b8 fix: disabled sms hanging, by calling resolve() (#21070) 2025-05-02 02:23:42 +00:00
Joe Au-Yeung 6fffe3377d refactor: Replace attendee email with user id in idempotency key (#21058)
* Use userId instead of attendee email

* Only write if booking is accepted

* Create `getBookingRequest` function

* Return already requested booking

* Type fixes

* Type fix

* Type fix

* Pass empty seatReferenceId

* Handle no userId

* Update test
2025-05-02 00:46:19 +01:00
105 changed files with 5466 additions and 276 deletions
+3 -1
View File
@@ -3,7 +3,7 @@
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/**/* @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
/deploy/**/* @calcom/Foundation
@@ -26,5 +26,7 @@
/packages/lib/slots.ts @calcom/Foundation
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+6 -1
View File
@@ -24,7 +24,12 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida
*/
async function postHandler(req: NextApiRequest) {
const data = membershipCreateBodySchema.parse(req.body);
const args: Prisma.MembershipCreateArgs = { data };
const args: Prisma.MembershipCreateArgs = {
data: {
createdAt: new Date(),
...data,
},
};
await checkPermissions(req);
+1
View File
@@ -11,6 +11,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 8
+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.185",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -385,7 +385,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -430,7 +430,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -459,7 +459,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -502,7 +502,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -583,7 +583,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.rating).toEqual(bookingInThePast.rating);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -642,7 +642,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
await bookingsRepositoryFixture.deleteById(mockBooking.id);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -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,
],
})
@@ -63,6 +63,7 @@ export class MembershipsRepository {
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
const membership = await this.dbRead.prisma.membership.create({
data: {
createdAt: new Date(),
role,
teamId,
userId,
@@ -69,7 +69,11 @@ export class OrganizationsTeamsMembershipsRepository {
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
data: {
createdAt: new Date(),
...data,
teamId: teamId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -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),
};
}
}
@@ -38,7 +38,7 @@ export class OrganizationsUsersRepository {
member: {
teamId: orgId,
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
// Filter to only get users which have ALL of the assigned attribue options
// Filter to only get users which have ALL of the assigned attribute options
...(attributeQueryOperator === "AND" && {
AND: assignedOptionIds.map((optionId) => ({
AttributeToUser: { some: { attributeOptionId: optionId } },
@@ -46,11 +46,11 @@ export class OrganizationsUsersRepository {
}),
},
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
// Filter to get users which have AT LEAST ONE of the assigned attribue options
// Filter to get users which have AT LEAST ONE of the assigned attribute options
...(attributeQueryOperator === "OR" && {
attributeOption: { id: { in: assignedOptionIds } },
}),
// Filter to get users that have NONE the assigned attribue options
// Filter to get users that have NONE the assigned attribute options
...(attributeQueryOperator === "NONE" && {
NOT: {
attributeOption: { id: { in: assignedOptionIds } },
@@ -21,7 +21,11 @@ export class TeamsMembershipsRepository {
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
data: {
createdAt: new Date(),
...data,
teamId: teamId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -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),
};
}
}
@@ -149,7 +149,7 @@ export class TokensRepository {
this.dbWrite.prisma.refreshToken.create({
data: {
// note(Lauris): I am leaving userId because it was before adding ownerId to standardize payload like in the createOAuthTokens function for
// backwards compatability.
// backwards compatibility.
secret: this.jwtService.signRefreshToken({
clientId,
ownerId: tokenUserId,
@@ -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
@@ -13,7 +13,7 @@ export class MembershipRepositoryFixture {
}
async create(data: Prisma.MembershipCreateInput) {
return this.prismaWriteClient.membership.create({ data });
return this.prismaWriteClient.membership.create({ data: { createdAt: new Date(), ...data } });
}
async delete(membershipId: Membership["id"]) {
@@ -30,7 +30,7 @@ export class MembershipRepositoryFixture {
async addUserToOrg(user: User, org: Team, role: MembershipRole, accepted: boolean) {
const membership = await this.prismaWriteClient.membership.create({
data: { teamId: org.id, userId: user.id, role, accepted },
data: { createdAt: new Date(), teamId: org.id, userId: user.id, role, accepted },
});
await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } });
return membership;
@@ -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 } });
}
}
+1
View File
@@ -0,0 +1 @@
jest.retryTimes(process.env.CI ? 2 : 0); // Global retry for all tests
@@ -65,3 +65,34 @@ Buttons can be grouped together using the ButtonGroup component. The ButtonGroup
#### Combined
<ButtonGroupSizesCombined/>
## Usage
```tsx
// Primary button (default)
<Button>Primary Button</Button>
// Secondary button
<Button color="secondary">Secondary Button</Button>
// Minimal button
<Button color="minimal">Minimal Button</Button>
// Destructive button
<Button color="destructive">Destructive Button</Button>
// With start icon
<Button StartIcon="plus">Add Item</Button>
// With end icon
<Button EndIcon="chevronRight">View More</Button>
// With link
<Button href="/some-path">Navigate</Button>
// Different sizes
<Button size="xs">Extra Small</Button>
<Button size="sm">Small</Button>
<Button size="base">Base (Default)</Button>
<Button size="lg">Large</Button>
```
+1 -9
View File
@@ -3,7 +3,7 @@ import { describe, it, expect } from "vitest";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { checkPostMethod, POST_METHODS_ALLOWED_APP_ROUTES } from "./middleware";
import { checkPostMethod } from "./middleware";
describe("Middleware - POST requests restriction", () => {
const createRequest = (path: string, method: string) => {
@@ -24,14 +24,6 @@ describe("Middleware - POST requests restriction", () => {
expect(res2).toBeNull();
});
it("should allow POST requests to allowed app routes", async () => {
POST_METHODS_ALLOWED_APP_ROUTES.forEach(async (route) => {
const req = createRequest(route, "POST");
const res = checkPostMethod(req);
expect(res).toBeNull();
});
});
it("should block POST requests to not-allowed app routes", async () => {
const req = createRequest("/team/xyz", "POST");
const res = checkPostMethod(req);
+1 -18
View File
@@ -16,21 +16,9 @@ const safeGet = async <T = any>(key: string): Promise<T | undefined> => {
};
export const POST_METHODS_ALLOWED_API_ROUTES = ["/api/auth/signup", "/api/trpc/"];
// Some app routes are allowed because "revalidatePath()" is used to revalidate the cache for them
export const POST_METHODS_ALLOWED_APP_ROUTES = [
"/settings/my-account/general",
"/settings/developer/webhooks",
"/settings/developer/api-keys",
"/teams",
];
export function checkPostMethod(req: NextRequest) {
const pathname = req.nextUrl.pathname;
if (
![...POST_METHODS_ALLOWED_API_ROUTES, ...POST_METHODS_ALLOWED_APP_ROUTES].some((route) =>
pathname.startsWith(route)
) &&
req.method === "POST"
) {
if (!POST_METHODS_ALLOWED_API_ROUTES.some((route) => pathname.startsWith(route)) && req.method === "POST") {
return new NextResponse(null, {
status: 405,
statusText: "Method Not Allowed",
@@ -191,11 +179,6 @@ export const config = {
// API routes
"/api/auth/signup",
"/api/trpc/:path*",
// Routes allowed for POST method (matching `POST_METHODS_ALLOWED_APP_ROUTES` array)
"/settings/my-account/general",
"/settings/developer/webhooks",
"/settings/developer/api-keys",
"/teams",
],
};
@@ -40,12 +40,7 @@ export default function InsightsVirtualQueuesPage() {
/>
<div className="mt-10">
{selectedForm ? (
<TestForm
form={selectedForm}
supportsTeamMembersMatchingLogic={true}
showAllData={false}
isDialog
/>
<TestForm form={selectedForm} supportsTeamMembersMatchingLogic={true} showRRData={true} />
) : (
<></>
)}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.2.8",
"version": "5.2.9",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+6 -3
View File
@@ -287,7 +287,10 @@ test.describe("pro user", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("cannot book an unconfirmed event multiple times with the same email", async ({ page, users }) => {
test("booking an unconfirmed event with the same email brings you to the original request", async ({
page,
users,
}) => {
await page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
await selectFirstAvailableTimeSlotNextMonth(page);
@@ -297,8 +300,8 @@ test.describe("pro user", () => {
// go back to the booking page to re-book.
await page.goto(pageUrl);
await bookTimeSlot(page, { expectedStatusCode: 409 });
await expect(page.getByText("Could not book the meeting.")).toBeVisible();
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("can book with multiple guests", async ({ page, users }) => {
+2
View File
@@ -227,6 +227,7 @@ const createTeamAndAddUser = async (
const { role = MembershipRole.OWNER, id: userId } = user;
await prisma.membership.create({
data: {
createdAt: new Date(),
teamId: team.id,
userId,
role: role,
@@ -566,6 +567,7 @@ export const createUsersFixture = (
// Add teammates to the team
await prisma.membership.create({
data: {
createdAt: new Date(),
teamId: team.id,
userId: teamUser.id,
role: MembershipRole.MEMBER,
+1 -3
View File
@@ -660,9 +660,7 @@ test.describe("Out of office", () => {
await page.locator('[data-testid="add-filter-button"]').click();
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
await expect(
page
.locator('[data-testid="filter-popover-trigger-dateRange"] span', { hasText: "Last 7 Days" })
.nth(0)
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
).toBeVisible();
});
@@ -846,6 +846,7 @@
"booking_redirect_uri": "URL of your booking page",
"booking_cancel_redirect_uri": "URL of the page where your users can cancel their booking",
"booking_reschedule_redirect_uri": "URL of the page where your users can reschedule their booking",
"booking_conflict_error": "This slot is no longer available. Please return to the previous page and select another time.",
"open_invitations": "Open Invitations",
"new_team": "New Team",
"create_first_team_and_invite_others": "Create your first team and invite other users to work together.",
@@ -3066,7 +3067,7 @@
"link": "Link",
"rows_per_page": "rows per page",
"pagination_status": "{{currentRange}} of {{totalItems}}",
"tip_username_plus": "Tip: You can a '+' between usernames: cal.com/anna+brian to make a dynamic group meeting",
"tip_username_plus": "Tip: You can add a '+' between usernames: cal.com/anna+brian to make a dynamic group meeting",
"user_has_no_team_yet": "You don't have a team yet",
"no_team_members": "You don't have team members yet",
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
@@ -3107,6 +3108,9 @@
"enable_delegation_credential_description": "Grant Cal.com automatic access to the calendars of all organization members by enabling delegation credential.",
"disable_delegation_credential": "Disable Delegation Credential",
"disable_delegation_credential_description": "Once delegation credential is disabled, organization members who haven’t connected their calendars will need to do so manually.",
"last_active": "Last Active",
"member_since": "Member Since",
"last_updated": "Last Updated",
"salesforce_on_cancel_write_to_event": "On cancelled booking, write to event record instead of deleting event",
"salesforce_on_every_cancellation": "On every cancellation",
"report_issue": "Report issue",
@@ -3169,12 +3173,15 @@
"skip_trial": "Skip trial period",
"team_trials_skipped_successfully": "Team trials skipped successfully",
"sms_workflow_consent": "By entering your phone number you consent to receive SMS messages for this event. SMS rates may apply.",
"routing_preview_more_info_found_insights": "More info can be found in Routing Insights",
"routing_preview_more_info_found_insights": "More info can be found in <0>Routing Insights</0>",
"results": "Results",
"view_form": "View Form",
"sms_opt_out_message": "Text STOP to opt-out of SMS messages",
"routing_form_next_in_queue": "{{count}} next in queue",
"routing_form_select_members_to_email": "Send email responses to",
"routing_incomplete_booking_tab": "Incomplete Bookings",
"matching": "Matching",
"event_redirect": "Event Redirect",
"reset_form": "Reset Form",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+2
View File
@@ -68,6 +68,8 @@ describe("generate Csv for Org Users Table", () => {
teams: [],
attributes: [],
lastActiveAt: "",
createdAt: null,
updatedAt: null,
};
it("should throw if no headers", () => {
File diff suppressed because it is too large Load Diff
@@ -119,5 +119,41 @@ describe("Query Builder Config", () => {
it("should have maxNesting set to 1 in settings", () => {
assertMaxNesting(AttributesBaseConfig, 1);
});
it("should provide jsonlogic for between operator", () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const jsonLogic = AttributesBaseConfig.operators.between.jsonLogic(
{ var: "89ee81ae-953c-409b-aacc-700e1ce5ae20" },
"between",
["1", "5"]
);
// Verifies that it is using >= and <= in implementation and integer parsed
expect(jsonLogic).toEqual({
and: [
{ ">=": [{ var: "89ee81ae-953c-409b-aacc-700e1ce5ae20" }, 1] },
{ "<=": [{ var: "89ee81ae-953c-409b-aacc-700e1ce5ae20" }, 5] },
],
});
});
it("should provide jsonlogic for not_between operator", () => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const jsonLogic = AttributesBaseConfig.operators.not_between.jsonLogic(
{ var: "89ee81ae-953c-409b-aacc-700e1ce5ae20" },
"not_between",
["1", "5"]
);
// Verifies that it is using < and > in implementation and integer parsed
expect(jsonLogic).toEqual({
or: [
{ "<": [{ var: "89ee81ae-953c-409b-aacc-700e1ce5ae20" }, 1] },
{ ">": [{ var: "89ee81ae-953c-409b-aacc-700e1ce5ae20" }, 5] },
],
});
});
});
});
@@ -391,6 +391,7 @@ export function FormActionsProvider({
type FormActionType =
| "preview"
| "incompleteBooking"
| "edit"
| "copyLink"
| "toggle"
@@ -470,6 +471,9 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
edit: {
href: `${appUrl}/form-edit/${routingForm?.id}`,
},
incompleteBooking: {
href: `${appUrl}/incomplete-booking/${routingForm?.id}`,
},
download: {
href: `/api/integrations/routing-forms/responses/${routingForm?.id}`,
},
@@ -171,7 +171,7 @@ function SingleForm({ form, appUrl, Page, enrichedWithUserProfileForm }: SingleF
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</motion.div>
) : (
<div className="mx-auto w-full max-w-4xl">
<div className="mx-auto w-full max-w-4xl px-2">
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
</div>
)}
@@ -111,6 +111,17 @@ const Actions = ({
StartIcon="external-link">
{t("view_form")}
</FormAction>
{isMobile ? (
<FormAction
action="incompleteBooking"
className="w-full"
routingForm={form}
color="minimal"
type="button"
StartIcon="calendar">
{t("routing_incomplete_booking_tab")}
</FormAction>
) : null}
<FormAction
action="copyLink"
className="w-full"
@@ -300,15 +311,6 @@ export function Header({
label: t("routing"),
iconLeft: <Icon name="waypoints" className="h-3 w-3" />,
},
...(showIncompleteBookingTab
? [
{
value: "incomplete-booking",
label: t("routing_incomplete_booking_tab"),
iconLeft: <Icon name="calendar" className="h-3 w-3" />,
},
]
: []),
]}
/>
</div>
@@ -347,7 +349,7 @@ export function Header({
? [
{
value: "incomplete-booking",
label: t("incomplete_booking"),
label: t("routing_incomplete_booking_tab"),
iconLeft: <Icon name="calendar" className="h-3 w-3" />,
},
]
@@ -1,8 +1,10 @@
"use client";
import { motion } from "framer-motion";
import Link from "next/link";
import type { ReactNode } from "react";
import ServerTrans from "@calcom/lib/components/ServerTrans";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import type { IconName } from "@calcom/ui/components/icon";
@@ -263,7 +265,19 @@ export const ResultsView = ({
<div className="flex items-center gap-2 px-2 py-1">
<Icon name="info" className="h-3 w-3" />
<span data-testid="matching-members" className="text-subtle text-sm">
{t("routing_preview_more_info_found_insights")}
<ServerTrans
t={t}
i18nKey="routing_preview_more_info_found_insights"
components={[
<Link
key="routing_insights"
className="underline underline-offset-2"
target="_blank"
href="/insights/router-position">
Routing Insights
</Link>,
]}
/>
</span>
</div>
}>
@@ -1,6 +1,4 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Alert } from "@calcom/ui/components/alert";
export type MembersMatchResultType = {
isUsingAttributeWeights: boolean;
@@ -20,53 +18,15 @@ export type MembersMatchResultType = {
export const TeamMembersMatchResult = ({
membersMatchResult,
chosenRouteName,
showAllData,
}: {
membersMatchResult: MembersMatchResultType;
chosenRouteName: string;
showAllData: boolean;
}) => {
const { t } = useLocale();
if (!membersMatchResult) return null;
const hasMainWarnings = (membersMatchResult.mainWarnings?.length ?? 0) > 0;
const hasFallbackWarnings = (membersMatchResult.fallbackWarnings?.length ?? 0) > 0;
const renderFallbackLogicStatus = () => {
if (!membersMatchResult.checkedFallback) {
return t("fallback_not_needed");
} else if (
isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic) ||
membersMatchResult.teamMembersMatchingAttributeLogic.length > 0
) {
return t("yes");
} else {
return t("no");
}
};
const renderMainLogicStatus = () => {
return !membersMatchResult.checkedFallback ? t("yes") : t("no");
};
const renderQueue = () => {
if (isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic)) {
if (!showAllData) return <div className="mt-4">{t("no_active_queues")}</div>;
if (membersMatchResult.checkedFallback) {
return (
<span className="font-semibold">
{t(
"all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules_to_fallback"
)}
</span>
);
}
return (
<span className="font-semibold">
{t("all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules")}
</span>
);
return <div className="mt-4">{t("no_active_queues")}</div>;
}
const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic;
@@ -124,61 +84,16 @@ export const TeamMembersMatchResult = ({
return (
<div className="text-default mt-2 space-y-2">
{showAllData ? (
<>
<div data-testid="chosen-route">
{t("chosen_route")}: <span className="font-semibold">{chosenRouteName}</span>
</div>
<div data-testid="attribute-logic-matched" className={classNames(hasMainWarnings && "text-error")}>
{t("attribute_logic_matched")}: <span className="font-semibold">{renderMainLogicStatus()}</span>
{hasMainWarnings && (
<Alert
className="mt-2"
severity="warning"
title={membersMatchResult.mainWarnings?.join(", ")}
/>
)}
</div>
<div
data-testid="attribute-logic-fallback-matched"
className={classNames(hasFallbackWarnings && "text-error")}>
{t("attribute_logic_fallback_matched")}:{" "}
<span className="font-semibold">{renderFallbackLogicStatus()}</span>
{hasFallbackWarnings && (
<Alert
className="mt-2"
severity="warning"
title={membersMatchResult.fallbackWarnings?.join(", ")}
/>
)}
</div>
</>
) : (
<></>
)}
<div className="mt-4">
{membersMatchResult.contactOwnerEmail ? (
<div data-testid="contact-owner-email">
{t("contact_owner")}:{" "}
<span className="font-semibold">{membersMatchResult.contactOwnerEmail}</span>
</div>
) : showAllData ? (
<div data-testid="contact-owner-email">
{t("contact_owner")}: <span className="font-semibold">Not found</span>
</div>
) : (
<></>
)}
<div className="mt-2" data-testid="matching-members">
{showAllData ? (
<>
{membersMatchResult.isUsingAttributeWeights
? t("matching_members_queue_using_attribute_weights")
: t("matching_members_queue_using_event_assignee_weights")}
</>
) : (
<></>
)}
{renderQueue()}
</div>
</div>
@@ -23,6 +23,7 @@ import type { RoutingForm, FormResponse, NonRouterRoute } from "../../types/type
import FormInputFields from "../FormInputFields";
import { ResultsView as Results } from "./ResultSection";
import type { MembersMatchResultType } from "./TeamMembersMatchResult";
import { TeamMembersMatchResult } from "./TeamMembersMatchResult";
export type UptoDateForm = Brand<
NonNullable<SingleFormComponentProps["enrichedWithUserProfileForm"]>,
@@ -76,17 +77,17 @@ const FormView = ({
export const TestForm = ({
form,
supportsTeamMembersMatchingLogic,
showAllData = true,
renderFooter,
isDialog = false,
onClose: onCloseProp,
showRRData = false,
}: {
form: UptoDateForm | RoutingForm;
supportsTeamMembersMatchingLogic: boolean;
showAllData?: boolean;
renderFooter?: (onClose: () => void, onSubmit: () => void, isValid: boolean) => React.ReactNode;
isDialog?: boolean;
onClose?: () => void;
showRRData?: boolean;
}) => {
const { t } = useLocale();
const [response, setResponse] = useState<FormResponse>({});
@@ -201,7 +202,7 @@ export const TestForm = ({
<>
{isDialog ? (
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
) : (
) : !showRRData ? (
<div className="mb-6 flex items-center justify-between">
<h3 className="text-emphasis text-xl font-semibold">{t("preview")}</h3>
<div className="flex items-center gap-1">
@@ -228,6 +229,8 @@ export const TestForm = ({
</Button>
</div>
</div>
) : (
<></>
)}
<FormView
key={formKey}
@@ -247,7 +250,7 @@ export const TestForm = ({
<>
{isDialog ? (
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
) : (
) : !showRRData ? (
<div className="mb-6 flex items-center justify-between">
<h3 className="text-emphasis text-xl font-semibold">{t("results")}</h3>
<div className="flex items-center gap-1">
@@ -279,13 +282,26 @@ export const TestForm = ({
</Button>
</div>
</div>
) : (
<></>
)}
<Results
chosenRoute={chosenRoute}
supportsTeamMembersMatchingLogic={supportsTeamMembersMatchingLogic}
membersMatchResult={membersMatchResult}
isPending={findTeamMembersMatchingAttributeLogicMutation.isPending}
/>
{showRRData ? (
<>
<Button color="secondary" onClick={resetForm} variant="icon" StartIcon="refresh-cw">
{t("reset_form")}
</Button>
<TeamMembersMatchResult membersMatchResult={membersMatchResult} />
</>
) : (
<Results
chosenRoute={chosenRoute}
supportsTeamMembersMatchingLogic={supportsTeamMembersMatchingLogic}
membersMatchResult={membersMatchResult}
isPending={findTeamMembersMatchingAttributeLogicMutation.isPending}
/>
)}
{isDialog && (
<DialogFooter>
<Button onClick={onClose} color="secondary">
@@ -117,7 +117,13 @@ const operators: Operators = {
cardinality: 2,
valueLabels: ["Value from", "Value to"],
reversedOp: "not_between",
jsonLogic: "<=",
jsonLogic: (field: any, op: any, vals: [any, any]) => {
const min = parseInt(vals[0], 10);
const max = parseInt(vals[1], 10);
return {
and: [{ ">=": [field, min] }, { "<=": [field, max] }],
};
},
},
not_between: {
isNotOp: true,
@@ -126,6 +132,13 @@ const operators: Operators = {
cardinality: 2,
valueLabels: ["Value from", "Value to"],
reversedOp: "between",
jsonLogic: (field: any, op: any, vals: [any, any]) => {
const min = parseInt(vals[0], 10);
const max = parseInt(vals[1], 10);
return {
or: [{ "<": [field, min] }, { ">": [field, max] }],
};
},
},
is_empty: {
label: "Is empty",
@@ -58,6 +58,13 @@ async function selectFirstValueForAttributeValue({
});
}
async function selectOperatorOption({ fromLocator, option }: { fromLocator: Locator; option: number }) {
await selectOptionUsingLocator({
locator: fromLocator,
option,
});
}
async function addAttributeRoutingRule(page: Page) {
// TODO: Use a better selector maybe?
await page.locator('text="Add rule"').nth(1).click();
@@ -75,6 +82,25 @@ async function addAttributeRoutingRule(page: Page) {
});
}
async function addAttributeRoutingRuleWithOperator(page: Page, valueFrom: string, valueTo: string) {
await page.locator('text="Add rule"').nth(1).click();
const attributeQueryBuilder = page.locator(".group-container").nth(1);
const attributeSelectorFirstRule = attributeQueryBuilder.locator(".rule--field").nth(0);
await selectFirstAttributeOption({
fromLocator: attributeSelectorFirstRule,
});
const attributeOperatorSelector = attributeQueryBuilder.locator(".rule--operator").nth(0);
await selectOperatorOption({
fromLocator: attributeOperatorSelector,
option: 7,
});
const attributeValueSelector = attributeQueryBuilder.locator(".rule--value").nth(0);
await attributeValueSelector.locator("input").nth(0).fill(valueFrom);
await attributeValueSelector.locator("input").nth(1).fill(valueTo);
}
async function selectFirstEventRedirectOption(page: Page) {
await selectOption({
selector: {
@@ -694,6 +720,171 @@ test.describe("Routing Forms", () => {
});
});
test.describe("Form with Attribute Routing with Between operator - Not Matching - Team Form", () => {
test.beforeEach(async ({ page, users }) => {
const userFixture = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
isOrg: true,
hasSubteam: true,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const orgMembership = await userFixture.getOrgMembership();
const createdAttribute = await prisma.attribute.create({
data: {
teamId: orgMembership.teamId,
type: AttributeType.NUMBER,
name: "Company Size",
slug: `company-size-orgId-${orgMembership.teamId}`,
options: {
create: [
{
slug: "10",
value: "10",
},
],
},
},
include: {
options: true,
},
});
await prisma.attributeToUser.create({
data: {
member: {
connect: {
userId_teamId: {
userId: userFixture.id,
teamId: orgMembership.teamId,
},
},
},
attributeOption: {
connect: {
id: createdAttribute.options[0].id,
},
},
},
});
await userFixture.apiLogin();
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
test("should not match any member if between operator values are input such that", async ({ page }) => {
await addForm(page, {
forTeam: true,
});
await page.locator('[data-testid="toggle-group-item-route-builder"]').nth(1).click();
await page.waitForURL("/routing/route-builder/**");
await addNewRoute(page);
// This would select Round Robin event that we created above
await selectFirstEventRedirectOption(page);
await addAttributeRoutingRuleWithOperator(page, "1", "5");
await saveCurrentForm(page);
// asserting there is no match as attribute value for the member is 10, while input is between 1 and 5
await (async function testPreviewWhereThereIsNoMatch() {
await page.click('[data-testid="preview-button"]');
await page.click('[data-testid="submit-button"]');
await expect(page.locator('[data-testid="attribute-logic-matched"]')).toHaveText("No");
await expect(page.locator('[data-testid="attribute-logic-fallback-matched"]')).toHaveText("Yes");
await page.click('[data-testid="close-results-button"]');
})();
});
});
test.describe("Form with Attribute Routing with Between operator - Matching - Team Form", () => {
test.beforeEach(async ({ page, users }) => {
const userFixture = await users.create(
{ username: "routing-forms" },
{
hasTeam: true,
isOrg: true,
hasSubteam: true,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const orgMembership = await userFixture.getOrgMembership();
const createdAttribute = await prisma.attribute.create({
data: {
teamId: orgMembership.teamId,
type: AttributeType.NUMBER,
name: "Company Size",
slug: `company-size-orgId-${orgMembership.teamId}`,
options: {
create: [
{
slug: "3",
value: "3",
},
],
},
},
include: {
options: true,
},
});
await prisma.attributeToUser.create({
data: {
member: {
connect: {
userId_teamId: {
userId: userFixture.id,
teamId: orgMembership.teamId,
},
},
},
attributeOption: {
connect: {
id: createdAttribute.options[0].id,
},
},
},
});
await userFixture.apiLogin();
});
test.afterEach(async ({ users }) => {
// This also delete forms on cascade
await users.deleteAll();
});
test("should match the member if between operator values are input such that", async ({ page }) => {
await addForm(page, {
forTeam: true,
});
await page.locator('[data-testid="toggle-group-item-route-builder"]').nth(1).click();
await page.waitForURL("/routing/route-builder/**");
await addNewRoute(page);
// This would select Round Robin event that we created above
await selectFirstEventRedirectOption(page);
await addAttributeRoutingRuleWithOperator(page, "1", "5");
await saveCurrentForm(page);
// asserting there is a match as attribute value for the member is 3 that is between 1 and 5
await (async function testPreviewWhereThereIsMatch() {
await page.click('[data-testid="preview-button"]');
await page.click('[data-testid="submit-button"]');
await page.waitForSelector("text=@example.com");
await page.click('[data-testid="close-results-button"]');
})();
});
});
test.describe("Team Routing Form", () => {
test.afterEach(async ({ users }) => {
await users.deleteAll();
@@ -105,7 +105,7 @@ export const BaseScheduledEmail = (
<WhoInfo calEvent={props.calEvent} t={t} />
<LocationInfo calEvent={props.calEvent} t={t} />
<Info label={t("description")} description={props.calEvent.description} withSpacer formatted />
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer />
<Info label={t("additional_notes")} description={props.calEvent.additionalNotes} withSpacer formatted />
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
<UserFieldsResponses t={t} calEvent={props.calEvent} isOrganizer={props.isOrganizer} />
{props.calEvent.paymentInfo?.amount && (
@@ -1095,7 +1095,7 @@ describe("validate radioInput type field", () => {
});
describe("validate url type field", () => {
test(`should fail parsing if invalid url provided`, async ({}) => {
test(`should pass parsing if protocol is missing`, async ({}) => {
const schema = getBookingResponsesSchema({
bookingFields: [
{
@@ -1121,6 +1121,44 @@ describe("validate url type field", () => {
name: "test",
url: "www.example.com",
});
// Expect success because our logic now handles missing protocol
expect(parsedResponses.success).toBe(true);
if (!parsedResponses.success) {
throw new Error("Should not reach here");
}
expect(parsedResponses.data).toEqual({
email: "test@test.com",
name: "test",
url: "www.example.com",
});
});
test(`should fail parsing if url is truly invalid`, async ({}) => {
const schema = getBookingResponsesSchema({
bookingFields: [
{
name: "name",
type: "name",
required: true,
},
{
name: "email",
type: "email",
required: true,
},
{
name: "url",
type: "url",
required: true,
},
] as z.infer<typeof eventTypeBookingFields> & z.BRAND<"HAS_SYSTEM_FIELDS">,
view: "ALL_VIEWS",
});
const parsedResponses = await schema.safeParseAsync({
email: "test@test.com",
name: "test",
url: "http:/nope",
});
expect(parsedResponses.success).toBe(false);
if (parsedResponses.success) {
throw new Error("Should not reach here");
@@ -1132,6 +1170,7 @@ describe("validate url type field", () => {
})
);
});
test(`should successfully give responses if url type field value is valid`, async ({}) => {
const schema = getBookingResponsesSchema({
bookingFields: [
@@ -218,7 +218,8 @@ async function handler(input: CancelBookingInput) {
title: bookingToDelete?.title,
length: bookingToDelete?.eventType?.length,
type: bookingToDelete?.eventType?.slug as string,
description: bookingToDelete?.description || "",
additionalNotes: bookingToDelete?.description,
description: bookingToDelete.eventType?.description,
customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs),
eventTypeId: bookingToDelete.eventTypeId as number,
...getCalEventResponses({
@@ -53,7 +53,7 @@ import {
} from "@calcom/lib/delegationCredential/server";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getEventName } from "@calcom/lib/event";
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
@@ -110,6 +110,7 @@ import type { IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser } from
import { validateBookingTimeIsNotOutOfBounds } from "./handleNewBooking/validateBookingTimeIsNotOutOfBounds";
import { validateEventLength } from "./handleNewBooking/validateEventLength";
import handleSeats from "./handleSeats/handleSeats";
import { getBookingRequest } from "./requiresConfirmation/getBookingRequest";
const translator = short();
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
@@ -191,14 +192,18 @@ const buildDryRunBooking = ({
allHostUsers: { id: number }[];
isManagedEventType: boolean;
}) => {
const sanitizedOrganizerUser = {
...organizerUser,
credentials: undefined,
};
const booking = {
id: -101,
uid: "DRY_RUN_UID",
iCalUID: "DRY_RUN_ICAL_UID",
status: BookingStatus.ACCEPTED,
eventTypeId: eventTypeId,
user: organizerUser,
userId: organizerUser.id,
user: sanitizedOrganizerUser,
userId: sanitizedOrganizerUser.id,
title: eventName,
startTime: new Date(startTime),
endTime: new Date(endTime),
@@ -413,15 +418,63 @@ async function handler(
});
}
const shouldServeCache = await getShouldServeCache(_shouldServeCache, eventType.team?.id);
const bookingSeat = reqBody.rescheduleUid ? await getSeatedBooking(reqBody.rescheduleUid) : null;
const rescheduleUid = bookingSeat ? bookingSeat.booking.uid : reqBody.rescheduleUid;
const isTeamEventType =
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
let originalRescheduledBooking = rescheduleUid
? await getOriginalRescheduledBooking(rescheduleUid, !!eventType.seatsPerTimeSlot)
: null;
const paymentAppData = getPaymentAppData({
...eventType,
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata),
});
const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({
eventType,
bookingStartTime: reqBody.start,
userId,
originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id,
paymentAppData,
bookerEmail,
});
// If a request already exists for the timeslot, return that request
if (!isConfirmedByDefault && !userReschedulingIsOwner) {
const requestedBooking = await getBookingRequest({
eventTypeId,
bookerEmail,
bookerPhoneNumber,
startTime: new Date(dayjs(reqBody.start).utc().format()),
});
if (requestedBooking) {
const bookingResponse = {
...requestedBooking,
user: {
...requestedBooking.user,
email: null,
},
paymentRequired: false,
seatReferenceUid: "",
};
return {
...bookingResponse,
luckyUsers: bookingResponse.userId ? [bookingResponse.userId] : [],
isDryRun,
...(isDryRun ? { troubleshooterData } : {}),
paymentUid: undefined,
paymentId: undefined,
};
}
}
const shouldServeCache = await getShouldServeCache(_shouldServeCache, eventType.team?.id);
const isTeamEventType =
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
loggerWithEventDetails.info(
`Booking eventType ${eventTypeId} started`,
safeStringify({
@@ -536,13 +589,6 @@ async function handler(
reqBodyRescheduleUid: reqBody.rescheduleUid,
});
const bookingSeat = reqBody.rescheduleUid ? await getSeatedBooking(reqBody.rescheduleUid) : null;
const rescheduleUid = bookingSeat ? bookingSeat.booking.uid : reqBody.rescheduleUid;
let originalRescheduledBooking = rescheduleUid
? await getOriginalRescheduledBooking(rescheduleUid, !!eventType.seatsPerTimeSlot)
: null;
let luckyUserResponse;
let isFirstSeat = true;
@@ -804,15 +850,6 @@ async function handler(
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
const allCredentials = await getAllCredentials(organizerUser, eventType);
const { userReschedulingIsOwner, isConfirmedByDefault } = await getRequiresConfirmationFlags({
eventType,
bookingStartTime: reqBody.start,
userId,
originalRescheduledBookingOrganizerId: originalRescheduledBooking?.user?.id,
paymentAppData,
bookerEmail,
});
// If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale.
const attendeeInfoOnReschedule =
userReschedulingIsOwner && originalRescheduledBooking
@@ -1225,6 +1262,19 @@ async function handler(
!!originalRescheduledBooking.rescheduled &&
originalRescheduledBooking.status === BookingStatus.CANCELLED;
if (
changedOrganizer &&
originalRescheduledBooking &&
originalRescheduledBooking?.user?.name &&
organizerUser?.name
) {
evt.title = updateHostInEventName(
originalRescheduledBooking.title,
originalRescheduledBooking.user.name,
organizerUser.name
);
}
let results: EventResult<AdditionalInformation & { url?: string; iCalUID?: string }>[] = [];
let referencesToCreate: PartialReference[] = [];
@@ -1350,6 +1400,7 @@ async function handler(
allHostUsers: users,
isManagedEventType,
});
booking = dryRunBooking;
troubleshooterData = {
...troubleshooterData,
@@ -1364,7 +1415,7 @@ async function handler(
err.message
);
if (err.code === "P2002") {
throw new HttpError({ statusCode: 409, message: "booking_conflict" });
throw new HttpError({ statusCode: 409, message: ErrorCode.BookingConflict });
}
throw err;
}
@@ -1392,7 +1443,6 @@ async function handler(
: [];
if (changedOrganizer) {
evt.title = getEventName(eventNameObject);
// location might changed and will be new created in eventManager.create (organizer default location)
evt.videoCallData = undefined;
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
@@ -57,7 +57,6 @@ function updateEventDetails(
originalRescheduledBooking: OriginalRescheduledBooking | null
) {
if (originalRescheduledBooking) {
evt.title = originalRescheduledBooking?.title || evt.title;
evt.description = originalRescheduledBooking?.description || evt.description;
evt.location = evt.location || originalRescheduledBooking?.location;
}
@@ -2034,6 +2034,8 @@ describe("handleNewBooking", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
const dynamicEventName = "{Scheduler} and {Organiser}: Team Meeting";
await createBookingScenario(
getScenarioData({
eventTypes: [
@@ -2041,6 +2043,7 @@ describe("handleNewBooking", () => {
id: 1,
slotInterval: 15,
length: 15,
eventName: dynamicEventName,
users: [
{
id: 101,
@@ -2131,6 +2134,8 @@ describe("handleNewBooking", () => {
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`);
expect(createdBooking.title).toBe(`${booker.name} and ${roundRobinHost1.name}: Team Meeting`);
await expectBookingInDBToBeRescheduledFromTo({
from: {
uid: uidOfBookingToBeRescheduled,
@@ -0,0 +1,35 @@
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/enums";
export const getBookingRequest = async ({
bookerEmail,
bookerPhoneNumber,
startTime,
eventTypeId,
}: {
bookerEmail: string;
bookerPhoneNumber: string | undefined;
startTime: Date;
eventTypeId: number;
}) => {
// See if there's already an existing request
const booking = await prisma.booking.findFirst({
where: {
eventTypeId,
startTime,
attendees: {
some: {
email: bookerEmail,
phoneNumber: bookerPhoneNumber,
},
},
status: BookingStatus.PENDING,
},
include: {
attendees: true,
references: true,
user: true,
},
});
return booking;
};
@@ -30,6 +30,7 @@ export function ActiveFilters<TData>({ table }: ActiveFiltersProps<TData>) {
key={column.id}
column={column}
options={column.dateRangeOptions}
showColumnName
showClearButton
/>
);
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from "react";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button, buttonClasses } from "@calcom/ui/components/button";
import {
Command,
@@ -35,14 +36,21 @@ import { useFilterPopoverOpen } from "./useFilterPopoverOpen";
type DateRangeFilterProps = {
column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>;
options?: DateRangeFilterOptions;
showColumnName?: boolean;
showClearButton?: boolean;
};
export const DateRangeFilter = ({ column, options, showClearButton = false }: DateRangeFilterProps) => {
export const DateRangeFilter = ({
column,
options,
showColumnName = false,
showClearButton = false,
}: DateRangeFilterProps) => {
const { open, onOpenChange } = useFilterPopoverOpen(column.id);
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
const { updateFilter, removeFilter } = useDataTable();
const range = options?.range ?? "past";
const endOfDay = options?.endOfDay ?? false;
const forceCustom = range === "custom";
const forcePast = range === "past";
@@ -73,13 +81,13 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
type: ColumnFilterType.DATE_RANGE,
data: {
startDate: startDate.toDate().toISOString(),
endDate: endDate.toDate().toISOString(),
endDate: (endOfDay ? endDate.endOf("day") : endDate).toDate().toISOString(),
preset: preset.value,
},
});
}
},
[column.id]
[column.id, endOfDay]
);
useEffect(() => {
@@ -137,6 +145,10 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
customButtonLabel = `${format(startDate.toDate(), "LLL dd, y")} - ?`;
}
const selectedValue = isCustomPreset
? customButtonLabel
: t(selectedPreset.labelKey, selectedPreset.i18nOptions);
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
@@ -146,8 +158,15 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
StartIcon="calendar-range"
EndIcon="chevron-down"
data-testid={`filter-popover-trigger-${column.id}`}>
{!isCustomPreset && <span>{t(selectedPreset.labelKey, selectedPreset.i18nOptions)}</span>}
{isCustomPreset && <span>{customButtonLabel}</span>}
{showColumnName && (
<>
<span>{column.title}</span>
<Badge variant="gray" className="ml-2">
{selectedValue}
</Badge>
</>
)}
{!showColumnName && <span>{selectedValue}</span>}
</Button>
</PopoverTrigger>
<PopoverContent className="flex w-fit p-0" align="end">
@@ -4,6 +4,7 @@ import {
isMultiSelectFilterValue,
isTextFilterValue,
isNumberFilterValue,
isDateRangeFilterValue,
} from "./utils";
type MakeWhereClauseProps = {
@@ -154,6 +155,19 @@ export function makeWhereClause(props: MakeWhereClauseProps) {
default:
throw new Error(`Invalid operator for number filter: ${operator}`);
}
} else if (isDateRangeFilterValue(filterValue)) {
const { startDate, endDate } = filterValue.data;
if (!startDate || !endDate) {
throw new Error(`Invalid date range filter: ${JSON.stringify({ columnName, startDate, endDate })}`);
}
return {
[columnName]: {
...jsonPathObj,
gte: startDate,
lte: endDate,
},
};
}
throw new Error(`Invalid filter type: ${JSON.stringify({ columnName, filterValue })}`);
}
+2 -1
View File
@@ -113,7 +113,8 @@ export const ZFilterValue = z.union([
]);
export type DateRangeFilterOptions = {
range: "past" | "custom";
range?: "past" | "custom";
endOfDay?: boolean;
};
export type TextFilterOptions = {
@@ -126,6 +126,7 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
});
await prisma.membership.createMany({
data: newUsers.map((user) => ({
createdAt: new Date(),
userId: user.id,
teamId: group.teamId,
role: MembershipRole.MEMBER,
@@ -163,12 +164,14 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
.map((user) => {
return [
{
createdAt: new Date(),
userId: user.id,
teamId: group.teamId,
role: MembershipRole.MEMBER,
accepted: true,
},
{
createdAt: new Date(),
userId: user.id,
teamId: organizationId,
role: MembershipRole.MEMBER,
@@ -138,6 +138,7 @@ async function createOnboardingEligibleUserAndOnboarding(data: {
async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) {
return prismock.membership.create({
data: {
createdAt: new Date(),
userId: data.userId,
teamId: data.teamId,
role: data.role || MembershipRole.MEMBER,
@@ -31,6 +31,7 @@ function isNotACompanyEmail(email: string) {
"icloud.com",
"mail.com",
"protonmail.com",
"proton.me",
"zoho.com",
"yandex.com",
"gmx.com",
@@ -31,7 +31,7 @@ export function TeamsListing({
errorMsgFromInvite,
}: TeamsListingProps) {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const token = searchParams?.get("token");
const { t } = useLocale();
const router = useRouter();
+10 -1
View File
@@ -205,7 +205,16 @@ export const Components: Record<FieldType, Component> = {
if (!props) {
return <div />;
}
return <Widgets.TextWidget type="email" id={props.name} noLabel={true} {...props} />;
return (
<InputField
type="email"
id={props.name}
noLabel={true}
{...props}
onChange={(e) => props.setValue(e.target.value)}
/>
);
},
},
address: {
+20 -1
View File
@@ -403,12 +403,31 @@ export const fieldTypesSchemaMap: Partial<
const value = response ?? "";
const urlSchema = z.string().url();
if (!urlSchema.safeParse(value).success) {
// Check for malformed protocols (missing second slash test case)
if (value.match(/^https?:\/[^\/]/)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("url_validation_error"),
});
return;
}
// 1. Try validating the original value
if (urlSchema.safeParse(value).success) {
return;
}
// 2. If it failed, try prepending https://
const valueWithHttps = `https://${value}`;
if (urlSchema.safeParse(valueWithHttps).success) {
return;
}
// 3. If all attempts fail, throw err
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: m("url_validation_error"),
});
},
},
};
@@ -131,7 +131,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
enableHiding: false,
size: 200,
header: () => {
return `Managed Users`;
return t("managed_users");
},
cell: ({ row }) => {
if (isPending) {
@@ -166,7 +166,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
{
id: "role",
accessorFn: (data) => data.role,
header: "Role",
header: t("role"),
size: 100,
cell: ({ row, table }) => {
if (isPending) {
@@ -188,7 +188,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
{
id: "teams",
accessorFn: (data) => data.teams.map((team) => team.name),
header: "Teams",
header: t("teams"),
size: 140,
cell: ({ row, table }) => {
if (isPending) {
@@ -74,6 +74,8 @@ const initalColumnVisibility = {
member: true,
role: true,
teams: true,
createdAt: false,
updatedAt: false,
actions: true,
};
@@ -366,9 +368,52 @@ function UserListTableContent() {
...generateAttributeColumns(),
{
id: "lastActiveAt",
header: "Last Active",
accessorKey: "lastActiveAt",
header: t("last_active"),
enableSorting: false,
enableColumnFilter: true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
endOfDay: true,
},
},
},
cell: ({ row }) => <div>{row.original.lastActiveAt}</div>,
},
{
id: "createdAt",
accessorKey: "createdAt",
header: t("member_since"),
enableSorting: false,
enableColumnFilter: true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
endOfDay: true,
},
},
},
cell: ({ row }) => <div>{row.original.createdAt || ""}</div>,
},
{
id: "updatedAt",
accessorKey: "updatedAt",
header: t("last_updated"),
enableSorting: false,
enableColumnFilter: true,
meta: {
filter: {
type: ColumnFilterType.DATE_RANGE,
dateRangeOptions: {
endOfDay: true,
},
},
},
cell: ({ row }) => <div>{row.original.updatedAt || ""}</div>,
},
{
id: "actions",
enableHiding: false,
+1
View File
@@ -17,4 +17,5 @@ export enum ErrorCode {
UpdatingOauthClientError = "updating_oauth_client_error",
CreatingOauthClientError = "creating_oauth_client_error",
BookingTimeOutOfBounds = "booking_time_out_of_bounds_error",
BookingConflict = "booking_conflict_error",
}
+17
View File
@@ -110,6 +110,23 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
return dynamicEventName;
}
export function updateHostInEventName(eventName: string, oldHost: string, newHost: string) {
const oldHostFirstName = oldHost.split(" ")[0];
const newHostFirstName = newHost.split(" ")[0];
if (!eventName.includes(oldHost) && !eventName.includes(oldHostFirstName)) {
return eventName;
}
let updatedEventName = eventName;
updatedEventName = updatedEventName.replace(oldHost, newHost);
updatedEventName = updatedEventName.replace(oldHostFirstName, newHostFirstName);
return updatedEventName;
}
export const validateCustomEventName = (value: string, bookingFields?: Prisma.JsonObject | null) => {
let customInputVariables: string[] = [];
if (bookingFields) {
@@ -395,6 +395,10 @@ export class BookingRepository {
},
where: {
type: "daily_video",
deleted: null,
},
orderBy: {
id: "asc",
},
},
},
+9 -2
View File
@@ -15,6 +15,7 @@ type IMembership = {
userId: number;
accepted: boolean;
role: MembershipRole;
createdAt?: Date;
};
const membershipSelect = Prisma.validator<Prisma.MembershipSelect>()({
@@ -71,13 +72,19 @@ const getWhereForfindAllByUpId = async (upId: string, where?: Prisma.MembershipW
export class MembershipRepository {
static async create(data: IMembership) {
return await prisma.membership.create({
data,
data: {
createdAt: new Date(),
...data,
},
});
}
static async createMany(data: IMembership[]) {
return await prisma.membership.createMany({
data,
data: data.map((item) => ({
createdAt: new Date(),
...item,
})),
});
}
@@ -57,6 +57,7 @@ export class OrganizationRepository {
await prisma.membership.create({
data: {
createdAt: new Date(),
userId: owner.id,
role: MembershipRole.OWNER,
accepted: true,
@@ -102,6 +103,7 @@ export class OrganizationRepository {
await prisma.membership.create({
data: {
createdAt: new Date(),
userId: ownerInDb.id,
role: MembershipRole.OWNER,
accepted: true,
+1
View File
@@ -250,6 +250,7 @@ export class TeamRepository {
try {
await prisma.membership.create({
data: {
createdAt: new Date(),
teamId: verificationToken.teamId,
userId: userId,
role: MembershipRole.MEMBER,
@@ -54,6 +54,7 @@ async function createMockUserWithMembership({ orgId }: { orgId: number }) {
});
const membership = await prismock.membership.create({
data: {
createdAt: new Date(),
role: MembershipRole.MEMBER,
disableImpersonation: false,
accepted: true,
+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 };
Binary file not shown.
@@ -8,22 +8,17 @@ export function bookingIdempotencyKeyExtension() {
query: {
booking: {
async create({ args, query }) {
const uniqueEmailJoinInput: string[] = [];
if (args.data.attendees?.create && !Array.isArray(args.data.attendees?.create)) {
uniqueEmailJoinInput.push(args.data.attendees?.create.email);
if (args.data.status === BookingStatus.ACCEPTED) {
const idempotencyKey = uuidv5(
`${
args.data.eventType?.connect?.id
}.${args.data.startTime.valueOf()}.${args.data.endTime.valueOf()}.${
args.data?.user?.connect?.id
}`,
uuidv5.URL
);
args.data.idempotencyKey = idempotencyKey;
}
if (args.data.attendees?.createMany && Array.isArray(args.data.attendees?.createMany.data)) {
uniqueEmailJoinInput.push(...args.data.attendees?.createMany.data.map((record) => record.email));
}
const idempotencyKey = uuidv5(
`${
args.data.eventType?.connect?.id
}.${args.data.startTime.valueOf()}.${args.data.endTime.valueOf()}.${uniqueEmailJoinInput.join(
","
)}`,
uuidv5.URL
);
args.data.idempotencyKey = idempotencyKey;
return query(args);
},
async update({ args, query }) {
@@ -0,0 +1,2 @@
ALTER TABLE "Membership" ADD COLUMN "createdAt" TIMESTAMP(3),
ADD COLUMN "updatedAt" TIMESTAMP(3);
+2
View File
@@ -549,6 +549,8 @@ model Membership {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
disableImpersonation Boolean @default(false)
AttributeToUser AttributeToUser[]
createdAt DateTime?
updatedAt DateTime? @updatedAt
@@unique([userId, teamId])
@@index([teamId])
+4
View File
@@ -188,6 +188,8 @@ async function main() {
userId: member.user.id,
role: member.role,
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
})),
});
}
@@ -424,6 +426,8 @@ async function createPerformanceData() {
userId: memberId.id,
role: "MEMBER",
accepted: true,
createdAt: new Date(),
updatedAt: new Date(),
})),
});
+1
View File
@@ -255,6 +255,7 @@ export async function createTeamAndAddUsers(
const { role = MembershipRole.OWNER, id, username } = user;
await prisma.membership.create({
data: {
createdAt: new Date(),
teamId: team.id,
userId: id,
role: role,
+3
View File
@@ -122,6 +122,7 @@ const createTeam = async (team: Prisma.TeamCreateInput) => {
const associateUserAndOrg = async ({ teamId, userId, role, username }: AssociateUserAndOrgProps) => {
await prisma.membership.create({
data: {
createdAt: new Date(),
teamId,
userId,
role: role as MembershipRole,
@@ -250,6 +251,7 @@ async function createTeamAndAddUsers(
const { role = MembershipRole.OWNER, id, username } = user;
await prisma.membership.create({
data: {
createdAt: new Date(),
teamId: team.id,
userId: id,
role: role,
@@ -567,6 +569,7 @@ async function createOrganizationAndAddMembersAndTeams({
}
await prisma.membership.create({
data: {
createdAt: new Date(),
teamId: team.id,
userId: member.id,
role: role,
+27 -28
View File
@@ -8,7 +8,7 @@ import { TimeFormat } from "@calcom/lib/timeFormat";
import prisma from "@calcom/prisma";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
const handleSendingSMS = ({
const handleSendingSMS = async ({
reminderPhone,
smsMessage,
senderID,
@@ -19,39 +19,38 @@ const handleSendingSMS = ({
senderID: string;
teamId: number;
}) => {
return new Promise(async (resolve, reject) => {
try {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
parent: {
select: {
parent: {
isOrganization: true,
organizationSettings: {
select: {
isOrganization: true,
organizationSettings: {
select: {
disablePhoneOnlySMSNotifications: true,
},
},
disablePhoneOnlySMSNotifications: true,
},
},
},
});
if (
!team?.parent?.isOrganization ||
team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications
)
return;
await checkSMSRateLimit({ identifier: `handleSendingSMS:team:${teamId}`, rateLimitingType: "sms" });
const sms = twilio.sendSMS(reminderPhone, smsMessage, senderID, teamId);
resolve(sms);
} catch (e) {
reject(console.error(`twilio.sendSMS failed`, e));
}
},
},
});
if (!team?.parent?.isOrganization || team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications) {
return; // resolves implicitly (as undefined)
}
try {
await checkSMSRateLimit({
identifier: `handleSendingSMS:team:${teamId}`,
rateLimitingType: "sms",
});
const sms = await twilio.sendSMS(reminderPhone, smsMessage, senderID, teamId);
return sms;
} catch (e) {
console.error("twilio.sendSMS failed", e);
throw e; // propagate the error
}
};
export default abstract class SMSManager {
@@ -27,17 +27,28 @@ export const listHandler = async ({ ctx }: ListOptions) => {
},
});
const defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
if (schedules.length === 0) {
return {
schedules: [],
};
}
if (!user.defaultScheduleId) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId,
},
});
let defaultScheduleId: number | null;
try {
defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
if (!user.defaultScheduleId) {
await prisma.user.update({
where: {
id: user.id,
},
data: {
defaultScheduleId,
},
});
}
} catch (error) {
defaultScheduleId = null;
}
return {
@@ -83,6 +83,15 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
const teamFilter = filters.find((filter) => filter.id === "teams") as
| TypedColumnFilter<ColumnFilterType.MULTI_SELECT>
| undefined;
const lastActiveAtFilter = filters.find((filter) => filter.id === "lastActiveAt") as
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
| undefined;
const createdAtFilter = filters.find((filter) => filter.id === "createdAt") as
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
| undefined;
const updatedAtFilter = filters.find((filter) => filter.id === "updatedAt") as
| TypedColumnFilter<ColumnFilterType.DATE_RANGE>
| undefined;
const whereClause: Prisma.MembershipWhereInput = {
user: {
@@ -97,6 +106,11 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
},
},
}),
...(lastActiveAtFilter &&
makeWhereClause({
columnName: "lastActiveAt",
filterValue: lastActiveAtFilter.value,
})),
},
teamId: organizationId,
...(searchTerm && {
@@ -112,10 +126,27 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
columnName: "role",
filterValue: roleFilter.value,
})),
...(createdAtFilter &&
makeWhereClause({
columnName: "createdAt",
filterValue: createdAtFilter.value,
})),
...(updatedAtFilter &&
makeWhereClause({
columnName: "updatedAt",
filterValue: updatedAtFilter.value,
})),
};
const attributeFilters: Prisma.MembershipWhereInput["AttributeToUser"][] = filters
.filter((filter) => filter.id !== "role" && filter.id !== "teams")
.filter(
(filter) =>
filter.id !== "role" &&
filter.id !== "teams" &&
filter.id !== "lastActiveAt" &&
filter.id !== "createdAt" &&
filter.id !== "updatedAt"
)
.map((filter) => {
if (filter.value.type === ColumnFilterType.MULTI_SELECT && isAllString(filter.value.data)) {
const attributeOptionValues: string[] = [];
@@ -163,6 +194,8 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
id: true,
role: true,
accepted: true,
createdAt: true,
updatedAt: true,
user: {
select: {
id: true,
@@ -249,6 +282,20 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
.format(membership.user.lastActiveAt)
.toLowerCase()
: null,
createdAt: membership.createdAt
? new Intl.DateTimeFormat(ctx.user.locale, {
timeZone: ctx.user.timeZone,
})
.format(membership.createdAt)
.toLowerCase()
: null,
updatedAt: membership.updatedAt
? new Intl.DateTimeFormat(ctx.user.locale, {
timeZone: ctx.user.timeZone,
})
.format(membership.updatedAt)
.toLowerCase()
: null,
avatarUrl: user.avatarUrl,
teams: user.teams
.filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team
@@ -65,6 +65,7 @@ export const addMembersToTeams = async ({ user, input }: AddBulkToTeamProps) =>
const userMembership = usersInOrganization.find((membership) => membership.userId === userId);
const accepted = userMembership && userMembership.accepted;
return {
createdAt: new Date(),
userId,
teamId,
role: MembershipRole.MEMBER,

Some files were not shown because too many files have changed in this diff Show More