Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1aa2f68e73 | ||
|
|
ad707b0ee4 | ||
|
|
49a2f4d4f7 | ||
|
|
f576979345 | ||
|
|
b45399ab78 | ||
|
|
306dc5e4fb | ||
|
|
605304b93b | ||
|
|
d6a17b2b1e | ||
|
|
4bee875a8c | ||
|
|
fe4ae3ae62 | ||
|
|
a245578ff2 | ||
|
|
f5ec696e15 | ||
|
|
ec9384858b | ||
|
|
47613f796e | ||
|
|
446c780d85 | ||
|
|
615968c640 | ||
|
|
fc89436b6a | ||
|
|
3c1015cb11 | ||
|
|
46c513330c | ||
|
|
ef1598faeb | ||
|
|
42a6903057 | ||
|
|
0f0c2b8b92 | ||
|
|
c400c36306 | ||
|
|
6410bd2eaf | ||
|
|
2ddc5317b8 | ||
|
|
6fffe3377d |
+3
-1
@@ -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
|
||||
|
||||
Vendored
+8
@@ -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"
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
+4
-4
@@ -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>();
|
||||
@@ -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,
|
||||
|
||||
+5
-1
@@ -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 } },
|
||||
});
|
||||
}
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+236
@@ -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();
|
||||
});
|
||||
});
|
||||
+256
@@ -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 } },
|
||||
});
|
||||
}
|
||||
|
||||
+229
@@ -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,
|
||||
|
||||
+196
@@ -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;
|
||||
}
|
||||
+10
@@ -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;
|
||||
}
|
||||
+10
@@ -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;
|
||||
|
||||
+26
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
```
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.2.8",
|
||||
"version": "5.2.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -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
Binary file not shown.
@@ -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">
|
||||
|
||||
+14
-1
@@ -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 })}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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 };
|
||||
|
||||
@@ -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.
Binary file not shown.
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);
|
||||
@@ -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])
|
||||
|
||||
@@ -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(),
|
||||
})),
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user