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 |
No files matched your search
+3
-1
@@ -3,7 +3,7 @@
|
|||||||
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
|
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
|
||||||
/apps/web/modules/**/* @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/lib/**/* @calcom/Foundation
|
||||||
/apps/web/middleware.ts @calcom/Foundation
|
/apps/web/middleware.ts @calcom/Foundation
|
||||||
/deploy/**/* @calcom/Foundation
|
/deploy/**/* @calcom/Foundation
|
||||||
@@ -26,5 +26,7 @@
|
|||||||
/packages/lib/slots.ts @calcom/Foundation
|
/packages/lib/slots.ts @calcom/Foundation
|
||||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||||
/packages/prisma/**/* @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/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||||
/packages/ui/**/* @calcom/Consumer @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) {
|
async function postHandler(req: NextApiRequest) {
|
||||||
const data = membershipCreateBodySchema.parse(req.body);
|
const data = membershipCreateBodySchema.parse(req.body);
|
||||||
const args: Prisma.MembershipCreateArgs = { data };
|
const args: Prisma.MembershipCreateArgs = {
|
||||||
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
await checkPermissions(req);
|
await checkPermissions(req);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||||
|
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
|
||||||
"reporters": ["default", "jest-summarizing-reporter"],
|
"reporters": ["default", "jest-summarizing-reporter"],
|
||||||
"workerIdleMemoryLimit": "512MB",
|
"workerIdleMemoryLimit": "512MB",
|
||||||
"maxWorkers": 8
|
"maxWorkers": 8
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@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-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
|
|||||||
+4
-4
@@ -385,7 +385,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
|
|||||||
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
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;
|
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
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;
|
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
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;
|
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
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);
|
expect(data.rating).toEqual(bookingInThePast.rating);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
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);
|
await bookingsRepositoryFixture.deleteById(mockBooking.id);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
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 { getEnv } from "@/env";
|
||||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||||
|
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||||
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||||
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
|
|||||||
ttl: z.number(),
|
ttl: z.number(),
|
||||||
blockDuration: z.number(),
|
blockDuration: z.number(),
|
||||||
});
|
});
|
||||||
type RateLimitType = z.infer<typeof rateLimitSchema>;
|
export type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||||
const rateLimitsSchema = z.array(rateLimitSchema);
|
const rateLimitsSchema = z.array(rateLimitSchema);
|
||||||
|
|
||||||
const sixtySecondsMs = 60 * 1000;
|
const sixtySecondsMs = 60 * 1000;
|
||||||
@@ -52,7 +53,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
||||||
const { context } = requestProps;
|
const { context } = requestProps;
|
||||||
|
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
|
||||||
const request = context.switchToHttp().getRequest<Request>();
|
const request = context.switchToHttp().getRequest<Request>();
|
||||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||||
const response = context.switchToHttp().getResponse<Response>();
|
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}"`
|
)}", 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_")) {
|
if (tracker.startsWith("api_key_")) {
|
||||||
return this.handleApiKeyRequest(tracker, response);
|
return this.handleApiKeyRequest(tracker, response);
|
||||||
} else {
|
} 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> {
|
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||||
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/
|
|||||||
import { RouterModule } from "@/modules/router/router.module";
|
import { RouterModule } from "@/modules/router/router.module";
|
||||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||||
import { TimezoneModule } from "@/modules/timezones/timezones.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 type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
@@ -33,6 +34,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
|||||||
OrganizationsUsersBookingsModule,
|
OrganizationsUsersBookingsModule,
|
||||||
OrganizationsBookingsModule,
|
OrganizationsBookingsModule,
|
||||||
OrganizationsRoutingFormsModule,
|
OrganizationsRoutingFormsModule,
|
||||||
|
VerifiedResourcesModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export class MembershipsRepository {
|
|||||||
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
|
||||||
const membership = await this.dbRead.prisma.membership.create({
|
const membership = await this.dbRead.prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
role,
|
role,
|
||||||
teamId,
|
teamId,
|
||||||
userId,
|
userId,
|
||||||
|
|||||||
+5
-1
@@ -69,7 +69,11 @@ export class OrganizationsTeamsMembershipsRepository {
|
|||||||
|
|
||||||
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
|
||||||
return this.dbWrite.prisma.membership.create({
|
return this.dbWrite.prisma.membership.create({
|
||||||
data: { ...data, teamId: teamId },
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
teamId: teamId,
|
||||||
|
},
|
||||||
include: { user: { select: MembershipUserSelect } },
|
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 { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
|
||||||
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
|
||||||
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.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 { 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 { 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";
|
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,
|
IsTeamInOrg,
|
||||||
IsRoutingFormInTeam,
|
IsRoutingFormInTeam,
|
||||||
PlatformPlanGuard,
|
PlatformPlanGuard,
|
||||||
|
RolesGuard,
|
||||||
IsAdminAPIEnabledGuard
|
IsAdminAPIEnabledGuard
|
||||||
)
|
)
|
||||||
@ApiHeader(API_KEY_HEADER)
|
@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: {
|
member: {
|
||||||
teamId: orgId,
|
teamId: orgId,
|
||||||
...(teamIds && { user: { teams: { some: { teamId: { in: teamIds } } } } }),
|
...(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" && {
|
...(attributeQueryOperator === "AND" && {
|
||||||
AND: assignedOptionIds.map((optionId) => ({
|
AND: assignedOptionIds.map((optionId) => ({
|
||||||
AttributeToUser: { some: { attributeOptionId: optionId } },
|
AttributeToUser: { some: { attributeOptionId: optionId } },
|
||||||
@@ -46,11 +46,11 @@ export class OrganizationsUsersRepository {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
|
...(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" && {
|
...(attributeQueryOperator === "OR" && {
|
||||||
attributeOption: { id: { in: assignedOptionIds } },
|
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" && {
|
...(attributeQueryOperator === "NONE" && {
|
||||||
NOT: {
|
NOT: {
|
||||||
attributeOption: { id: { in: assignedOptionIds } },
|
attributeOption: { id: { in: assignedOptionIds } },
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ export class TeamsMembershipsRepository {
|
|||||||
|
|
||||||
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
|
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
|
||||||
return this.dbWrite.prisma.membership.create({
|
return this.dbWrite.prisma.membership.create({
|
||||||
data: { ...data, teamId: teamId },
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
teamId: teamId,
|
||||||
|
},
|
||||||
include: { user: { select: MembershipUserSelect } },
|
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({
|
this.dbWrite.prisma.refreshToken.create({
|
||||||
data: {
|
data: {
|
||||||
// note(Lauris): I am leaving userId because it was before adding ownerId to standardize payload like in the createOAuthTokens function for
|
// 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({
|
secret: this.jwtService.signRefreshToken({
|
||||||
clientId,
|
clientId,
|
||||||
ownerId: tokenUserId,
|
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) {
|
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"]) {
|
async delete(membershipId: Membership["id"]) {
|
||||||
@@ -30,7 +30,7 @@ export class MembershipRepositoryFixture {
|
|||||||
|
|
||||||
async addUserToOrg(user: User, org: Team, role: MembershipRole, accepted: boolean) {
|
async addUserToOrg(user: User, org: Team, role: MembershipRole, accepted: boolean) {
|
||||||
const membership = await this.prismaWriteClient.membership.create({
|
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 } });
|
await this.prismaWriteClient.user.update({ where: { id: user.id }, data: { organizationId: org.id } });
|
||||||
return membership;
|
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
|
#### Combined
|
||||||
|
|
||||||
<ButtonGroupSizesCombined/>
|
<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 { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
|
||||||
import { checkPostMethod, POST_METHODS_ALLOWED_APP_ROUTES } from "./middleware";
|
import { checkPostMethod } from "./middleware";
|
||||||
|
|
||||||
describe("Middleware - POST requests restriction", () => {
|
describe("Middleware - POST requests restriction", () => {
|
||||||
const createRequest = (path: string, method: string) => {
|
const createRequest = (path: string, method: string) => {
|
||||||
@@ -24,14 +24,6 @@ describe("Middleware - POST requests restriction", () => {
|
|||||||
expect(res2).toBeNull();
|
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 () => {
|
it("should block POST requests to not-allowed app routes", async () => {
|
||||||
const req = createRequest("/team/xyz", "POST");
|
const req = createRequest("/team/xyz", "POST");
|
||||||
const res = checkPostMethod(req);
|
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/"];
|
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) {
|
export function checkPostMethod(req: NextRequest) {
|
||||||
const pathname = req.nextUrl.pathname;
|
const pathname = req.nextUrl.pathname;
|
||||||
if (
|
if (!POST_METHODS_ALLOWED_API_ROUTES.some((route) => pathname.startsWith(route)) && req.method === "POST") {
|
||||||
![...POST_METHODS_ALLOWED_API_ROUTES, ...POST_METHODS_ALLOWED_APP_ROUTES].some((route) =>
|
|
||||||
pathname.startsWith(route)
|
|
||||||
) &&
|
|
||||||
req.method === "POST"
|
|
||||||
) {
|
|
||||||
return new NextResponse(null, {
|
return new NextResponse(null, {
|
||||||
status: 405,
|
status: 405,
|
||||||
statusText: "Method Not Allowed",
|
statusText: "Method Not Allowed",
|
||||||
@@ -191,11 +179,6 @@ export const config = {
|
|||||||
// API routes
|
// API routes
|
||||||
"/api/auth/signup",
|
"/api/auth/signup",
|
||||||
"/api/trpc/:path*",
|
"/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">
|
<div className="mt-10">
|
||||||
{selectedForm ? (
|
{selectedForm ? (
|
||||||
<TestForm
|
<TestForm form={selectedForm} supportsTeamMembersMatchingLogic={true} showRRData={true} />
|
||||||
form={selectedForm}
|
|
||||||
supportsTeamMembersMatchingLogic={true}
|
|
||||||
showAllData={false}
|
|
||||||
isDialog
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<></>
|
<></>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.2.8",
|
"version": "5.2.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
|
|||||||
@@ -287,7 +287,10 @@ test.describe("pro user", () => {
|
|||||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
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 page.locator('[data-testid="event-type-link"]:has-text("Opt in")').click();
|
||||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||||
|
|
||||||
@@ -297,8 +300,8 @@ test.describe("pro user", () => {
|
|||||||
// go back to the booking page to re-book.
|
// go back to the booking page to re-book.
|
||||||
await page.goto(pageUrl);
|
await page.goto(pageUrl);
|
||||||
|
|
||||||
await bookTimeSlot(page, { expectedStatusCode: 409 });
|
await bookTimeSlot(page);
|
||||||
await expect(page.getByText("Could not book the meeting.")).toBeVisible();
|
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can book with multiple guests", async ({ page, users }) => {
|
test("can book with multiple guests", async ({ page, users }) => {
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ const createTeamAndAddUser = async (
|
|||||||
const { role = MembershipRole.OWNER, id: userId } = user;
|
const { role = MembershipRole.OWNER, id: userId } = user;
|
||||||
await prisma.membership.create({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
userId,
|
userId,
|
||||||
role: role,
|
role: role,
|
||||||
@@ -566,6 +567,7 @@ export const createUsersFixture = (
|
|||||||
// Add teammates to the team
|
// Add teammates to the team
|
||||||
await prisma.membership.create({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
userId: teamUser.id,
|
userId: teamUser.id,
|
||||||
role: MembershipRole.MEMBER,
|
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-button"]').click();
|
||||||
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
|
await page.locator('[data-testid="add-filter-item-dateRange"]').click();
|
||||||
await expect(
|
await expect(
|
||||||
page
|
page.locator('[data-testid="filter-popover-trigger-dateRange"]', { hasText: "Last 7 Days" }).first()
|
||||||
.locator('[data-testid="filter-popover-trigger-dateRange"] span', { hasText: "Last 7 Days" })
|
|
||||||
.nth(0)
|
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -846,6 +846,7 @@
|
|||||||
"booking_redirect_uri": "URL of your booking page",
|
"booking_redirect_uri": "URL of your booking page",
|
||||||
"booking_cancel_redirect_uri": "URL of the page where your users can cancel their booking",
|
"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_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",
|
"open_invitations": "Open Invitations",
|
||||||
"new_team": "New Team",
|
"new_team": "New Team",
|
||||||
"create_first_team_and_invite_others": "Create your first team and invite other users to work together.",
|
"create_first_team_and_invite_others": "Create your first team and invite other users to work together.",
|
||||||
@@ -3066,7 +3067,7 @@
|
|||||||
"link": "Link",
|
"link": "Link",
|
||||||
"rows_per_page": "rows per page",
|
"rows_per_page": "rows per page",
|
||||||
"pagination_status": "{{currentRange}} of {{totalItems}}",
|
"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",
|
"user_has_no_team_yet": "You don't have a team yet",
|
||||||
"no_team_members": "You don't have team members 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.",
|
"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.",
|
"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": "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.",
|
"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_cancel_write_to_event": "On cancelled booking, write to event record instead of deleting event",
|
||||||
"salesforce_on_every_cancellation": "On every cancellation",
|
"salesforce_on_every_cancellation": "On every cancellation",
|
||||||
"report_issue": "Report issue",
|
"report_issue": "Report issue",
|
||||||
@@ -3169,12 +3173,15 @@
|
|||||||
"skip_trial": "Skip trial period",
|
"skip_trial": "Skip trial period",
|
||||||
"team_trials_skipped_successfully": "Team trials skipped successfully",
|
"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.",
|
"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",
|
"results": "Results",
|
||||||
"view_form": "View Form",
|
"view_form": "View Form",
|
||||||
"sms_opt_out_message": "Text STOP to opt-out of SMS messages",
|
"sms_opt_out_message": "Text STOP to opt-out of SMS messages",
|
||||||
"routing_form_next_in_queue": "{{count}} next in queue",
|
"routing_form_next_in_queue": "{{count}} next in queue",
|
||||||
"routing_form_select_members_to_email": "Send email responses to",
|
"routing_form_select_members_to_email": "Send email responses to",
|
||||||
"routing_incomplete_booking_tab": "Incomplete Bookings",
|
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"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: [],
|
teams: [],
|
||||||
attributes: [],
|
attributes: [],
|
||||||
lastActiveAt: "",
|
lastActiveAt: "",
|
||||||
|
createdAt: null,
|
||||||
|
updatedAt: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should throw if no headers", () => {
|
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", () => {
|
it("should have maxNesting set to 1 in settings", () => {
|
||||||
assertMaxNesting(AttributesBaseConfig, 1);
|
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 =
|
type FormActionType =
|
||||||
| "preview"
|
| "preview"
|
||||||
|
| "incompleteBooking"
|
||||||
| "edit"
|
| "edit"
|
||||||
| "copyLink"
|
| "copyLink"
|
||||||
| "toggle"
|
| "toggle"
|
||||||
@@ -470,6 +471,9 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
|
|||||||
edit: {
|
edit: {
|
||||||
href: `${appUrl}/form-edit/${routingForm?.id}`,
|
href: `${appUrl}/form-edit/${routingForm?.id}`,
|
||||||
},
|
},
|
||||||
|
incompleteBooking: {
|
||||||
|
href: `${appUrl}/incomplete-booking/${routingForm?.id}`,
|
||||||
|
},
|
||||||
download: {
|
download: {
|
||||||
href: `/api/integrations/routing-forms/responses/${routingForm?.id}`,
|
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} />
|
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
|
||||||
</motion.div>
|
</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} />
|
<Page hookForm={hookForm} form={form} appUrl={appUrl} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -111,6 +111,17 @@ const Actions = ({
|
|||||||
StartIcon="external-link">
|
StartIcon="external-link">
|
||||||
{t("view_form")}
|
{t("view_form")}
|
||||||
</FormAction>
|
</FormAction>
|
||||||
|
{isMobile ? (
|
||||||
|
<FormAction
|
||||||
|
action="incompleteBooking"
|
||||||
|
className="w-full"
|
||||||
|
routingForm={form}
|
||||||
|
color="minimal"
|
||||||
|
type="button"
|
||||||
|
StartIcon="calendar">
|
||||||
|
{t("routing_incomplete_booking_tab")}
|
||||||
|
</FormAction>
|
||||||
|
) : null}
|
||||||
<FormAction
|
<FormAction
|
||||||
action="copyLink"
|
action="copyLink"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -300,15 +311,6 @@ export function Header({
|
|||||||
label: t("routing"),
|
label: t("routing"),
|
||||||
iconLeft: <Icon name="waypoints" className="h-3 w-3" />,
|
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>
|
</div>
|
||||||
@@ -347,7 +349,7 @@ export function Header({
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
value: "incomplete-booking",
|
value: "incomplete-booking",
|
||||||
label: t("incomplete_booking"),
|
label: t("routing_incomplete_booking_tab"),
|
||||||
iconLeft: <Icon name="calendar" className="h-3 w-3" />,
|
iconLeft: <Icon name="calendar" className="h-3 w-3" />,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
import type { IconName } from "@calcom/ui/components/icon";
|
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">
|
<div className="flex items-center gap-2 px-2 py-1">
|
||||||
<Icon name="info" className="h-3 w-3" />
|
<Icon name="info" className="h-3 w-3" />
|
||||||
<span data-testid="matching-members" className="text-subtle text-sm">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import classNames from "@calcom/ui/classNames";
|
|
||||||
import { Alert } from "@calcom/ui/components/alert";
|
|
||||||
|
|
||||||
export type MembersMatchResultType = {
|
export type MembersMatchResultType = {
|
||||||
isUsingAttributeWeights: boolean;
|
isUsingAttributeWeights: boolean;
|
||||||
@@ -20,53 +18,15 @@ export type MembersMatchResultType = {
|
|||||||
|
|
||||||
export const TeamMembersMatchResult = ({
|
export const TeamMembersMatchResult = ({
|
||||||
membersMatchResult,
|
membersMatchResult,
|
||||||
chosenRouteName,
|
|
||||||
showAllData,
|
|
||||||
}: {
|
}: {
|
||||||
membersMatchResult: MembersMatchResultType;
|
membersMatchResult: MembersMatchResultType;
|
||||||
chosenRouteName: string;
|
|
||||||
showAllData: boolean;
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
if (!membersMatchResult) return null;
|
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 = () => {
|
const renderQueue = () => {
|
||||||
if (isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic)) {
|
if (isNoLogicFound(membersMatchResult.teamMembersMatchingAttributeLogic)) {
|
||||||
if (!showAllData) return <div className="mt-4">{t("no_active_queues")}</div>;
|
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic;
|
const matchingMembers = membersMatchResult.teamMembersMatchingAttributeLogic;
|
||||||
@@ -124,61 +84,16 @@ export const TeamMembersMatchResult = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-default mt-2 space-y-2">
|
<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">
|
<div className="mt-4">
|
||||||
{membersMatchResult.contactOwnerEmail ? (
|
{membersMatchResult.contactOwnerEmail ? (
|
||||||
<div data-testid="contact-owner-email">
|
<div data-testid="contact-owner-email">
|
||||||
{t("contact_owner")}:{" "}
|
{t("contact_owner")}:{" "}
|
||||||
<span className="font-semibold">{membersMatchResult.contactOwnerEmail}</span>
|
<span className="font-semibold">{membersMatchResult.contactOwnerEmail}</span>
|
||||||
</div>
|
</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">
|
<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()}
|
{renderQueue()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import type { RoutingForm, FormResponse, NonRouterRoute } from "../../types/type
|
|||||||
import FormInputFields from "../FormInputFields";
|
import FormInputFields from "../FormInputFields";
|
||||||
import { ResultsView as Results } from "./ResultSection";
|
import { ResultsView as Results } from "./ResultSection";
|
||||||
import type { MembersMatchResultType } from "./TeamMembersMatchResult";
|
import type { MembersMatchResultType } from "./TeamMembersMatchResult";
|
||||||
|
import { TeamMembersMatchResult } from "./TeamMembersMatchResult";
|
||||||
|
|
||||||
export type UptoDateForm = Brand<
|
export type UptoDateForm = Brand<
|
||||||
NonNullable<SingleFormComponentProps["enrichedWithUserProfileForm"]>,
|
NonNullable<SingleFormComponentProps["enrichedWithUserProfileForm"]>,
|
||||||
@@ -76,17 +77,17 @@ const FormView = ({
|
|||||||
export const TestForm = ({
|
export const TestForm = ({
|
||||||
form,
|
form,
|
||||||
supportsTeamMembersMatchingLogic,
|
supportsTeamMembersMatchingLogic,
|
||||||
showAllData = true,
|
|
||||||
renderFooter,
|
renderFooter,
|
||||||
isDialog = false,
|
isDialog = false,
|
||||||
onClose: onCloseProp,
|
onClose: onCloseProp,
|
||||||
|
showRRData = false,
|
||||||
}: {
|
}: {
|
||||||
form: UptoDateForm | RoutingForm;
|
form: UptoDateForm | RoutingForm;
|
||||||
supportsTeamMembersMatchingLogic: boolean;
|
supportsTeamMembersMatchingLogic: boolean;
|
||||||
showAllData?: boolean;
|
|
||||||
renderFooter?: (onClose: () => void, onSubmit: () => void, isValid: boolean) => React.ReactNode;
|
renderFooter?: (onClose: () => void, onSubmit: () => void, isValid: boolean) => React.ReactNode;
|
||||||
isDialog?: boolean;
|
isDialog?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
showRRData?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [response, setResponse] = useState<FormResponse>({});
|
const [response, setResponse] = useState<FormResponse>({});
|
||||||
@@ -201,7 +202,7 @@ export const TestForm = ({
|
|||||||
<>
|
<>
|
||||||
{isDialog ? (
|
{isDialog ? (
|
||||||
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
|
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
|
||||||
) : (
|
) : !showRRData ? (
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h3 className="text-emphasis text-xl font-semibold">{t("preview")}</h3>
|
<h3 className="text-emphasis text-xl font-semibold">{t("preview")}</h3>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -228,6 +229,8 @@ export const TestForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
)}
|
)}
|
||||||
<FormView
|
<FormView
|
||||||
key={formKey}
|
key={formKey}
|
||||||
@@ -247,7 +250,7 @@ export const TestForm = ({
|
|||||||
<>
|
<>
|
||||||
{isDialog ? (
|
{isDialog ? (
|
||||||
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
|
<DialogHeader title={t("test_routing_form")} subtitle={t("test_preview_description")} />
|
||||||
) : (
|
) : !showRRData ? (
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h3 className="text-emphasis text-xl font-semibold">{t("results")}</h3>
|
<h3 className="text-emphasis text-xl font-semibold">{t("results")}</h3>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -279,13 +282,26 @@ export const TestForm = ({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<></>
|
||||||
)}
|
)}
|
||||||
<Results
|
|
||||||
chosenRoute={chosenRoute}
|
{showRRData ? (
|
||||||
supportsTeamMembersMatchingLogic={supportsTeamMembersMatchingLogic}
|
<>
|
||||||
membersMatchResult={membersMatchResult}
|
<Button color="secondary" onClick={resetForm} variant="icon" StartIcon="refresh-cw">
|
||||||
isPending={findTeamMembersMatchingAttributeLogicMutation.isPending}
|
{t("reset_form")}
|
||||||
/>
|
</Button>
|
||||||
|
<TeamMembersMatchResult membersMatchResult={membersMatchResult} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Results
|
||||||
|
chosenRoute={chosenRoute}
|
||||||
|
supportsTeamMembersMatchingLogic={supportsTeamMembersMatchingLogic}
|
||||||
|
membersMatchResult={membersMatchResult}
|
||||||
|
isPending={findTeamMembersMatchingAttributeLogicMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{isDialog && (
|
{isDialog && (
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={onClose} color="secondary">
|
<Button onClick={onClose} color="secondary">
|
||||||
|
|||||||
+14
-1
@@ -117,7 +117,13 @@ const operators: Operators = {
|
|||||||
cardinality: 2,
|
cardinality: 2,
|
||||||
valueLabels: ["Value from", "Value to"],
|
valueLabels: ["Value from", "Value to"],
|
||||||
reversedOp: "not_between",
|
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: {
|
not_between: {
|
||||||
isNotOp: true,
|
isNotOp: true,
|
||||||
@@ -126,6 +132,13 @@ const operators: Operators = {
|
|||||||
cardinality: 2,
|
cardinality: 2,
|
||||||
valueLabels: ["Value from", "Value to"],
|
valueLabels: ["Value from", "Value to"],
|
||||||
reversedOp: "between",
|
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: {
|
is_empty: {
|
||||||
label: "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) {
|
async function addAttributeRoutingRule(page: Page) {
|
||||||
// TODO: Use a better selector maybe?
|
// TODO: Use a better selector maybe?
|
||||||
await page.locator('text="Add rule"').nth(1).click();
|
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) {
|
async function selectFirstEventRedirectOption(page: Page) {
|
||||||
await selectOption({
|
await selectOption({
|
||||||
selector: {
|
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.describe("Team Routing Form", () => {
|
||||||
test.afterEach(async ({ users }) => {
|
test.afterEach(async ({ users }) => {
|
||||||
await users.deleteAll();
|
await users.deleteAll();
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export const BaseScheduledEmail = (
|
|||||||
<WhoInfo calEvent={props.calEvent} t={t} />
|
<WhoInfo calEvent={props.calEvent} t={t} />
|
||||||
<LocationInfo calEvent={props.calEvent} t={t} />
|
<LocationInfo calEvent={props.calEvent} t={t} />
|
||||||
<Info label={t("description")} description={props.calEvent.description} withSpacer formatted />
|
<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} />}
|
{props.includeAppsStatus && <AppsStatus calEvent={props.calEvent} t={t} />}
|
||||||
<UserFieldsResponses t={t} calEvent={props.calEvent} isOrganizer={props.isOrganizer} />
|
<UserFieldsResponses t={t} calEvent={props.calEvent} isOrganizer={props.isOrganizer} />
|
||||||
{props.calEvent.paymentInfo?.amount && (
|
{props.calEvent.paymentInfo?.amount && (
|
||||||
|
|||||||
@@ -1095,7 +1095,7 @@ describe("validate radioInput type field", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("validate url 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({
|
const schema = getBookingResponsesSchema({
|
||||||
bookingFields: [
|
bookingFields: [
|
||||||
{
|
{
|
||||||
@@ -1121,6 +1121,44 @@ describe("validate url type field", () => {
|
|||||||
name: "test",
|
name: "test",
|
||||||
url: "www.example.com",
|
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);
|
expect(parsedResponses.success).toBe(false);
|
||||||
if (parsedResponses.success) {
|
if (parsedResponses.success) {
|
||||||
throw new Error("Should not reach here");
|
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 ({}) => {
|
test(`should successfully give responses if url type field value is valid`, async ({}) => {
|
||||||
const schema = getBookingResponsesSchema({
|
const schema = getBookingResponsesSchema({
|
||||||
bookingFields: [
|
bookingFields: [
|
||||||
|
|||||||
@@ -218,7 +218,8 @@ async function handler(input: CancelBookingInput) {
|
|||||||
title: bookingToDelete?.title,
|
title: bookingToDelete?.title,
|
||||||
length: bookingToDelete?.eventType?.length,
|
length: bookingToDelete?.eventType?.length,
|
||||||
type: bookingToDelete?.eventType?.slug as string,
|
type: bookingToDelete?.eventType?.slug as string,
|
||||||
description: bookingToDelete?.description || "",
|
additionalNotes: bookingToDelete?.description,
|
||||||
|
description: bookingToDelete.eventType?.description,
|
||||||
customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs),
|
customInputs: isPrismaObjOrUndefined(bookingToDelete.customInputs),
|
||||||
eventTypeId: bookingToDelete.eventTypeId as number,
|
eventTypeId: bookingToDelete.eventTypeId as number,
|
||||||
...getCalEventResponses({
|
...getCalEventResponses({
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ import {
|
|||||||
} from "@calcom/lib/delegationCredential/server";
|
} from "@calcom/lib/delegationCredential/server";
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
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 { extractBaseEmail } from "@calcom/lib/extract-base-email";
|
||||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||||
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
|
||||||
@@ -110,6 +110,7 @@ import type { IEventTypePaymentCredentialType, Invitee, IsFixedAwareUser } from
|
|||||||
import { validateBookingTimeIsNotOutOfBounds } from "./handleNewBooking/validateBookingTimeIsNotOutOfBounds";
|
import { validateBookingTimeIsNotOutOfBounds } from "./handleNewBooking/validateBookingTimeIsNotOutOfBounds";
|
||||||
import { validateEventLength } from "./handleNewBooking/validateEventLength";
|
import { validateEventLength } from "./handleNewBooking/validateEventLength";
|
||||||
import handleSeats from "./handleSeats/handleSeats";
|
import handleSeats from "./handleSeats/handleSeats";
|
||||||
|
import { getBookingRequest } from "./requiresConfirmation/getBookingRequest";
|
||||||
|
|
||||||
const translator = short();
|
const translator = short();
|
||||||
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
|
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
|
||||||
@@ -191,14 +192,18 @@ const buildDryRunBooking = ({
|
|||||||
allHostUsers: { id: number }[];
|
allHostUsers: { id: number }[];
|
||||||
isManagedEventType: boolean;
|
isManagedEventType: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const sanitizedOrganizerUser = {
|
||||||
|
...organizerUser,
|
||||||
|
credentials: undefined,
|
||||||
|
};
|
||||||
const booking = {
|
const booking = {
|
||||||
id: -101,
|
id: -101,
|
||||||
uid: "DRY_RUN_UID",
|
uid: "DRY_RUN_UID",
|
||||||
iCalUID: "DRY_RUN_ICAL_UID",
|
iCalUID: "DRY_RUN_ICAL_UID",
|
||||||
status: BookingStatus.ACCEPTED,
|
status: BookingStatus.ACCEPTED,
|
||||||
eventTypeId: eventTypeId,
|
eventTypeId: eventTypeId,
|
||||||
user: organizerUser,
|
user: sanitizedOrganizerUser,
|
||||||
userId: organizerUser.id,
|
userId: sanitizedOrganizerUser.id,
|
||||||
title: eventName,
|
title: eventName,
|
||||||
startTime: new Date(startTime),
|
startTime: new Date(startTime),
|
||||||
endTime: new Date(endTime),
|
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 =
|
let originalRescheduledBooking = rescheduleUid
|
||||||
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
|
? await getOriginalRescheduledBooking(rescheduleUid, !!eventType.seatsPerTimeSlot)
|
||||||
|
: null;
|
||||||
|
|
||||||
const paymentAppData = getPaymentAppData({
|
const paymentAppData = getPaymentAppData({
|
||||||
...eventType,
|
...eventType,
|
||||||
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventType.metadata),
|
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(
|
loggerWithEventDetails.info(
|
||||||
`Booking eventType ${eventTypeId} started`,
|
`Booking eventType ${eventTypeId} started`,
|
||||||
safeStringify({
|
safeStringify({
|
||||||
@@ -536,13 +589,6 @@ async function handler(
|
|||||||
reqBodyRescheduleUid: reqBody.rescheduleUid,
|
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 luckyUserResponse;
|
||||||
let isFirstSeat = true;
|
let isFirstSeat = true;
|
||||||
|
|
||||||
@@ -804,15 +850,6 @@ async function handler(
|
|||||||
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
|
const tOrganizer = await getTranslation(organizerUser?.locale ?? "en", "common");
|
||||||
const allCredentials = await getAllCredentials(organizerUser, eventType);
|
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.
|
// If the Organizer himself is rescheduling, the booker should be sent the communication in his timezone and locale.
|
||||||
const attendeeInfoOnReschedule =
|
const attendeeInfoOnReschedule =
|
||||||
userReschedulingIsOwner && originalRescheduledBooking
|
userReschedulingIsOwner && originalRescheduledBooking
|
||||||
@@ -1225,6 +1262,19 @@ async function handler(
|
|||||||
!!originalRescheduledBooking.rescheduled &&
|
!!originalRescheduledBooking.rescheduled &&
|
||||||
originalRescheduledBooking.status === BookingStatus.CANCELLED;
|
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 results: EventResult<AdditionalInformation & { url?: string; iCalUID?: string }>[] = [];
|
||||||
let referencesToCreate: PartialReference[] = [];
|
let referencesToCreate: PartialReference[] = [];
|
||||||
|
|
||||||
@@ -1350,6 +1400,7 @@ async function handler(
|
|||||||
allHostUsers: users,
|
allHostUsers: users,
|
||||||
isManagedEventType,
|
isManagedEventType,
|
||||||
});
|
});
|
||||||
|
|
||||||
booking = dryRunBooking;
|
booking = dryRunBooking;
|
||||||
troubleshooterData = {
|
troubleshooterData = {
|
||||||
...troubleshooterData,
|
...troubleshooterData,
|
||||||
@@ -1364,7 +1415,7 @@ async function handler(
|
|||||||
err.message
|
err.message
|
||||||
);
|
);
|
||||||
if (err.code === "P2002") {
|
if (err.code === "P2002") {
|
||||||
throw new HttpError({ statusCode: 409, message: "booking_conflict" });
|
throw new HttpError({ statusCode: 409, message: ErrorCode.BookingConflict });
|
||||||
}
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
@@ -1392,7 +1443,6 @@ async function handler(
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
if (changedOrganizer) {
|
if (changedOrganizer) {
|
||||||
evt.title = getEventName(eventNameObject);
|
|
||||||
// location might changed and will be new created in eventManager.create (organizer default location)
|
// location might changed and will be new created in eventManager.create (organizer default location)
|
||||||
evt.videoCallData = undefined;
|
evt.videoCallData = undefined;
|
||||||
// To prevent "The requested identifier already exists" error while updating event, we need to remove iCalUID
|
// 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
|
originalRescheduledBooking: OriginalRescheduledBooking | null
|
||||||
) {
|
) {
|
||||||
if (originalRescheduledBooking) {
|
if (originalRescheduledBooking) {
|
||||||
evt.title = originalRescheduledBooking?.title || evt.title;
|
|
||||||
evt.description = originalRescheduledBooking?.description || evt.description;
|
evt.description = originalRescheduledBooking?.description || evt.description;
|
||||||
evt.location = evt.location || originalRescheduledBooking?.location;
|
evt.location = evt.location || originalRescheduledBooking?.location;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2034,6 +2034,8 @@ describe("handleNewBooking", () => {
|
|||||||
|
|
||||||
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
|
||||||
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
|
||||||
|
const dynamicEventName = "{Scheduler} and {Organiser}: Team Meeting";
|
||||||
|
|
||||||
await createBookingScenario(
|
await createBookingScenario(
|
||||||
getScenarioData({
|
getScenarioData({
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
@@ -2041,6 +2043,7 @@ describe("handleNewBooking", () => {
|
|||||||
id: 1,
|
id: 1,
|
||||||
slotInterval: 15,
|
slotInterval: 15,
|
||||||
length: 15,
|
length: 15,
|
||||||
|
eventName: dynamicEventName,
|
||||||
users: [
|
users: [
|
||||||
{
|
{
|
||||||
id: 101,
|
id: 101,
|
||||||
@@ -2131,6 +2134,8 @@ describe("handleNewBooking", () => {
|
|||||||
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
|
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
|
||||||
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15: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({
|
await expectBookingInDBToBeRescheduledFromTo({
|
||||||
from: {
|
from: {
|
||||||
uid: uidOfBookingToBeRescheduled,
|
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}
|
key={column.id}
|
||||||
column={column}
|
column={column}
|
||||||
options={column.dateRangeOptions}
|
options={column.dateRangeOptions}
|
||||||
|
showColumnName
|
||||||
showClearButton
|
showClearButton
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useState, useEffect, useCallback } from "react";
|
|||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
import { Button, buttonClasses } from "@calcom/ui/components/button";
|
import { Button, buttonClasses } from "@calcom/ui/components/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
@@ -35,14 +36,21 @@ import { useFilterPopoverOpen } from "./useFilterPopoverOpen";
|
|||||||
type DateRangeFilterProps = {
|
type DateRangeFilterProps = {
|
||||||
column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>;
|
column: Extract<FilterableColumn, { type: ColumnFilterType.DATE_RANGE }>;
|
||||||
options?: DateRangeFilterOptions;
|
options?: DateRangeFilterOptions;
|
||||||
|
showColumnName?: boolean;
|
||||||
showClearButton?: 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 { open, onOpenChange } = useFilterPopoverOpen(column.id);
|
||||||
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
|
const filterValue = useFilterValue(column.id, ZDateRangeFilterValue);
|
||||||
const { updateFilter, removeFilter } = useDataTable();
|
const { updateFilter, removeFilter } = useDataTable();
|
||||||
const range = options?.range ?? "past";
|
const range = options?.range ?? "past";
|
||||||
|
const endOfDay = options?.endOfDay ?? false;
|
||||||
const forceCustom = range === "custom";
|
const forceCustom = range === "custom";
|
||||||
const forcePast = range === "past";
|
const forcePast = range === "past";
|
||||||
|
|
||||||
@@ -73,13 +81,13 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
|
|||||||
type: ColumnFilterType.DATE_RANGE,
|
type: ColumnFilterType.DATE_RANGE,
|
||||||
data: {
|
data: {
|
||||||
startDate: startDate.toDate().toISOString(),
|
startDate: startDate.toDate().toISOString(),
|
||||||
endDate: endDate.toDate().toISOString(),
|
endDate: (endOfDay ? endDate.endOf("day") : endDate).toDate().toISOString(),
|
||||||
preset: preset.value,
|
preset: preset.value,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[column.id]
|
[column.id, endOfDay]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -137,6 +145,10 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
|
|||||||
customButtonLabel = `${format(startDate.toDate(), "LLL dd, y")} - ?`;
|
customButtonLabel = `${format(startDate.toDate(), "LLL dd, y")} - ?`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedValue = isCustomPreset
|
||||||
|
? customButtonLabel
|
||||||
|
: t(selectedPreset.labelKey, selectedPreset.i18nOptions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={onOpenChange}>
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
@@ -146,8 +158,15 @@ export const DateRangeFilter = ({ column, options, showClearButton = false }: Da
|
|||||||
StartIcon="calendar-range"
|
StartIcon="calendar-range"
|
||||||
EndIcon="chevron-down"
|
EndIcon="chevron-down"
|
||||||
data-testid={`filter-popover-trigger-${column.id}`}>
|
data-testid={`filter-popover-trigger-${column.id}`}>
|
||||||
{!isCustomPreset && <span>{t(selectedPreset.labelKey, selectedPreset.i18nOptions)}</span>}
|
{showColumnName && (
|
||||||
{isCustomPreset && <span>{customButtonLabel}</span>}
|
<>
|
||||||
|
<span>{column.title}</span>
|
||||||
|
<Badge variant="gray" className="ml-2">
|
||||||
|
{selectedValue}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!showColumnName && <span>{selectedValue}</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="flex w-fit p-0" align="end">
|
<PopoverContent className="flex w-fit p-0" align="end">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
isMultiSelectFilterValue,
|
isMultiSelectFilterValue,
|
||||||
isTextFilterValue,
|
isTextFilterValue,
|
||||||
isNumberFilterValue,
|
isNumberFilterValue,
|
||||||
|
isDateRangeFilterValue,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
type MakeWhereClauseProps = {
|
type MakeWhereClauseProps = {
|
||||||
@@ -154,6 +155,19 @@ export function makeWhereClause(props: MakeWhereClauseProps) {
|
|||||||
default:
|
default:
|
||||||
throw new Error(`Invalid operator for number filter: ${operator}`);
|
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 })}`);
|
throw new Error(`Invalid filter type: ${JSON.stringify({ columnName, filterValue })}`);
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,8 @@ export const ZFilterValue = z.union([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export type DateRangeFilterOptions = {
|
export type DateRangeFilterOptions = {
|
||||||
range: "past" | "custom";
|
range?: "past" | "custom";
|
||||||
|
endOfDay?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TextFilterOptions = {
|
export type TextFilterOptions = {
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
|
|||||||
});
|
});
|
||||||
await prisma.membership.createMany({
|
await prisma.membership.createMany({
|
||||||
data: newUsers.map((user) => ({
|
data: newUsers.map((user) => ({
|
||||||
|
createdAt: new Date(),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: group.teamId,
|
teamId: group.teamId,
|
||||||
role: MembershipRole.MEMBER,
|
role: MembershipRole.MEMBER,
|
||||||
@@ -163,12 +164,14 @@ const handleGroupEvents = async (event: DirectorySyncEvent, organizationId: numb
|
|||||||
.map((user) => {
|
.map((user) => {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
createdAt: new Date(),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: group.teamId,
|
teamId: group.teamId,
|
||||||
role: MembershipRole.MEMBER,
|
role: MembershipRole.MEMBER,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
createdAt: new Date(),
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
teamId: organizationId,
|
teamId: organizationId,
|
||||||
role: MembershipRole.MEMBER,
|
role: MembershipRole.MEMBER,
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ async function createOnboardingEligibleUserAndOnboarding(data: {
|
|||||||
async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) {
|
async function createTestMembership(data: { userId: number; teamId: number; role?: MembershipRole }) {
|
||||||
return prismock.membership.create({
|
return prismock.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
userId: data.userId,
|
userId: data.userId,
|
||||||
teamId: data.teamId,
|
teamId: data.teamId,
|
||||||
role: data.role || MembershipRole.MEMBER,
|
role: data.role || MembershipRole.MEMBER,
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function isNotACompanyEmail(email: string) {
|
|||||||
"icloud.com",
|
"icloud.com",
|
||||||
"mail.com",
|
"mail.com",
|
||||||
"protonmail.com",
|
"protonmail.com",
|
||||||
|
"proton.me",
|
||||||
"zoho.com",
|
"zoho.com",
|
||||||
"yandex.com",
|
"yandex.com",
|
||||||
"gmx.com",
|
"gmx.com",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function TeamsListing({
|
|||||||
errorMsgFromInvite,
|
errorMsgFromInvite,
|
||||||
}: TeamsListingProps) {
|
}: TeamsListingProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const token = searchParams.get("token");
|
const token = searchParams?.get("token");
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,16 @@ export const Components: Record<FieldType, Component> = {
|
|||||||
if (!props) {
|
if (!props) {
|
||||||
return <div />;
|
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: {
|
address: {
|
||||||
|
|||||||
@@ -403,12 +403,31 @@ export const fieldTypesSchemaMap: Partial<
|
|||||||
const value = response ?? "";
|
const value = response ?? "";
|
||||||
const urlSchema = z.string().url();
|
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({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: m("url_validation_error"),
|
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,
|
enableHiding: false,
|
||||||
size: 200,
|
size: 200,
|
||||||
header: () => {
|
header: () => {
|
||||||
return `Managed Users`;
|
return t("managed_users");
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
@@ -166,7 +166,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
|
|||||||
{
|
{
|
||||||
id: "role",
|
id: "role",
|
||||||
accessorFn: (data) => data.role,
|
accessorFn: (data) => data.role,
|
||||||
header: "Role",
|
header: t("role"),
|
||||||
size: 100,
|
size: 100,
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
@@ -188,7 +188,7 @@ function UserListTableContent({ oAuthClientId }: PlatformManagedUsersTableProps)
|
|||||||
{
|
{
|
||||||
id: "teams",
|
id: "teams",
|
||||||
accessorFn: (data) => data.teams.map((team) => team.name),
|
accessorFn: (data) => data.teams.map((team) => team.name),
|
||||||
header: "Teams",
|
header: t("teams"),
|
||||||
size: 140,
|
size: 140,
|
||||||
cell: ({ row, table }) => {
|
cell: ({ row, table }) => {
|
||||||
if (isPending) {
|
if (isPending) {
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ const initalColumnVisibility = {
|
|||||||
member: true,
|
member: true,
|
||||||
role: true,
|
role: true,
|
||||||
teams: true,
|
teams: true,
|
||||||
|
createdAt: false,
|
||||||
|
updatedAt: false,
|
||||||
actions: true,
|
actions: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -366,9 +368,52 @@ function UserListTableContent() {
|
|||||||
...generateAttributeColumns(),
|
...generateAttributeColumns(),
|
||||||
{
|
{
|
||||||
id: "lastActiveAt",
|
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>,
|
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",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ export enum ErrorCode {
|
|||||||
UpdatingOauthClientError = "updating_oauth_client_error",
|
UpdatingOauthClientError = "updating_oauth_client_error",
|
||||||
CreatingOauthClientError = "creating_oauth_client_error",
|
CreatingOauthClientError = "creating_oauth_client_error",
|
||||||
BookingTimeOutOfBounds = "booking_time_out_of_bounds_error",
|
BookingTimeOutOfBounds = "booking_time_out_of_bounds_error",
|
||||||
|
BookingConflict = "booking_conflict_error",
|
||||||
}
|
}
|
||||||
@@ -110,6 +110,23 @@ export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView
|
|||||||
return dynamicEventName;
|
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) => {
|
export const validateCustomEventName = (value: string, bookingFields?: Prisma.JsonObject | null) => {
|
||||||
let customInputVariables: string[] = [];
|
let customInputVariables: string[] = [];
|
||||||
if (bookingFields) {
|
if (bookingFields) {
|
||||||
|
|||||||
@@ -395,6 +395,10 @@ export class BookingRepository {
|
|||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
type: "daily_video",
|
type: "daily_video",
|
||||||
|
deleted: null,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
id: "asc",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type IMembership = {
|
|||||||
userId: number;
|
userId: number;
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
role: MembershipRole;
|
role: MembershipRole;
|
||||||
|
createdAt?: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
const membershipSelect = Prisma.validator<Prisma.MembershipSelect>()({
|
const membershipSelect = Prisma.validator<Prisma.MembershipSelect>()({
|
||||||
@@ -71,13 +72,19 @@ const getWhereForfindAllByUpId = async (upId: string, where?: Prisma.MembershipW
|
|||||||
export class MembershipRepository {
|
export class MembershipRepository {
|
||||||
static async create(data: IMembership) {
|
static async create(data: IMembership) {
|
||||||
return await prisma.membership.create({
|
return await prisma.membership.create({
|
||||||
data,
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async createMany(data: IMembership[]) {
|
static async createMany(data: IMembership[]) {
|
||||||
return await prisma.membership.createMany({
|
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({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
userId: owner.id,
|
userId: owner.id,
|
||||||
role: MembershipRole.OWNER,
|
role: MembershipRole.OWNER,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
@@ -102,6 +103,7 @@ export class OrganizationRepository {
|
|||||||
|
|
||||||
await prisma.membership.create({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
userId: ownerInDb.id,
|
userId: ownerInDb.id,
|
||||||
role: MembershipRole.OWNER,
|
role: MembershipRole.OWNER,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ export class TeamRepository {
|
|||||||
try {
|
try {
|
||||||
await prisma.membership.create({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
teamId: verificationToken.teamId,
|
teamId: verificationToken.teamId,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
role: MembershipRole.MEMBER,
|
role: MembershipRole.MEMBER,
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ async function createMockUserWithMembership({ orgId }: { orgId: number }) {
|
|||||||
});
|
});
|
||||||
const membership = await prismock.membership.create({
|
const membership = await prismock.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
role: MembershipRole.MEMBER,
|
role: MembershipRole.MEMBER,
|
||||||
disableImpersonation: false,
|
disableImpersonation: false,
|
||||||
accepted: true,
|
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 AttendeeRescheduledEmail from "@calcom/emails/templates/attendee-rescheduled-email";
|
||||||
import AttendeeScheduledEmail from "@calcom/emails/templates/attendee-scheduled-email";
|
import AttendeeScheduledEmail from "@calcom/emails/templates/attendee-scheduled-email";
|
||||||
import AttendeeUpdatedEmail from "@calcom/emails/templates/attendee-updated-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 OrganizerCancelledEmail from "@calcom/emails/templates/organizer-cancelled-email";
|
||||||
import OrganizerReassignedEmail from "@calcom/emails/templates/organizer-reassigned-email";
|
import OrganizerReassignedEmail from "@calcom/emails/templates/organizer-reassigned-email";
|
||||||
import OrganizerRequestEmail from "@calcom/emails/templates/organizer-request-email";
|
import OrganizerRequestEmail from "@calcom/emails/templates/organizer-request-email";
|
||||||
import OrganizerRescheduledEmail from "@calcom/emails/templates/organizer-rescheduled-email";
|
import OrganizerRescheduledEmail from "@calcom/emails/templates/organizer-rescheduled-email";
|
||||||
import OrganizerScheduledEmail from "@calcom/emails/templates/organizer-scheduled-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 { 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 };
|
export { AttendeeScheduledEmail };
|
||||||
|
|
||||||
@@ -34,3 +39,7 @@ export { OrganizerRequestEmail };
|
|||||||
export { AttendeeRequestEmail };
|
export { AttendeeRequestEmail };
|
||||||
|
|
||||||
export { sendSignupToOrganizationEmail };
|
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 handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||||
import * as newBookingMethods from "@calcom/features/bookings/lib/handleNewBooking";
|
import * as newBookingMethods from "@calcom/features/bookings/lib/handleNewBooking";
|
||||||
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
|
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 { handleCreatePhoneCall } from "@calcom/features/handleCreatePhoneCall";
|
||||||
import handleMarkNoShow from "@calcom/features/handleMarkNoShow";
|
import handleMarkNoShow from "@calcom/features/handleMarkNoShow";
|
||||||
import * as instantMeetingMethods from "@calcom/features/instant-meeting/handleInstantMeeting";
|
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 { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
|
||||||
export type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.schema";
|
export type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/trpc/server/routers/viewer/attributes/findTeamMembersMatchingAttributeLogic.schema";
|
||||||
export { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
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: {
|
query: {
|
||||||
booking: {
|
booking: {
|
||||||
async create({ args, query }) {
|
async create({ args, query }) {
|
||||||
const uniqueEmailJoinInput: string[] = [];
|
if (args.data.status === BookingStatus.ACCEPTED) {
|
||||||
if (args.data.attendees?.create && !Array.isArray(args.data.attendees?.create)) {
|
const idempotencyKey = uuidv5(
|
||||||
uniqueEmailJoinInput.push(args.data.attendees?.create.email);
|
`${
|
||||||
|
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);
|
return query(args);
|
||||||
},
|
},
|
||||||
async update({ args, query }) {
|
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)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
disableImpersonation Boolean @default(false)
|
disableImpersonation Boolean @default(false)
|
||||||
AttributeToUser AttributeToUser[]
|
AttributeToUser AttributeToUser[]
|
||||||
|
createdAt DateTime?
|
||||||
|
updatedAt DateTime? @updatedAt
|
||||||
|
|
||||||
@@unique([userId, teamId])
|
@@unique([userId, teamId])
|
||||||
@@index([teamId])
|
@@index([teamId])
|
||||||
|
|||||||
@@ -188,6 +188,8 @@ async function main() {
|
|||||||
userId: member.user.id,
|
userId: member.user.id,
|
||||||
role: member.role,
|
role: member.role,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -424,6 +426,8 @@ async function createPerformanceData() {
|
|||||||
userId: memberId.id,
|
userId: memberId.id,
|
||||||
role: "MEMBER",
|
role: "MEMBER",
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ export async function createTeamAndAddUsers(
|
|||||||
const { role = MembershipRole.OWNER, id, username } = user;
|
const { role = MembershipRole.OWNER, id, username } = user;
|
||||||
await prisma.membership.create({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
userId: id,
|
userId: id,
|
||||||
role: role,
|
role: role,
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ const createTeam = async (team: Prisma.TeamCreateInput) => {
|
|||||||
const associateUserAndOrg = async ({ teamId, userId, role, username }: AssociateUserAndOrgProps) => {
|
const associateUserAndOrg = async ({ teamId, userId, role, username }: AssociateUserAndOrgProps) => {
|
||||||
await prisma.membership.create({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
teamId,
|
teamId,
|
||||||
userId,
|
userId,
|
||||||
role: role as MembershipRole,
|
role: role as MembershipRole,
|
||||||
@@ -250,6 +251,7 @@ async function createTeamAndAddUsers(
|
|||||||
const { role = MembershipRole.OWNER, id, username } = user;
|
const { role = MembershipRole.OWNER, id, username } = user;
|
||||||
await prisma.membership.create({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
userId: id,
|
userId: id,
|
||||||
role: role,
|
role: role,
|
||||||
@@ -567,6 +569,7 @@ async function createOrganizationAndAddMembersAndTeams({
|
|||||||
}
|
}
|
||||||
await prisma.membership.create({
|
await prisma.membership.create({
|
||||||
data: {
|
data: {
|
||||||
|
createdAt: new Date(),
|
||||||
teamId: team.id,
|
teamId: team.id,
|
||||||
userId: member.id,
|
userId: member.id,
|
||||||
role: role,
|
role: role,
|
||||||
|
|||||||
+27
-28
@@ -8,7 +8,7 @@ import { TimeFormat } from "@calcom/lib/timeFormat";
|
|||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
const handleSendingSMS = ({
|
const handleSendingSMS = async ({
|
||||||
reminderPhone,
|
reminderPhone,
|
||||||
smsMessage,
|
smsMessage,
|
||||||
senderID,
|
senderID,
|
||||||
@@ -19,39 +19,38 @@ const handleSendingSMS = ({
|
|||||||
senderID: string;
|
senderID: string;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
}) => {
|
}) => {
|
||||||
return new Promise(async (resolve, reject) => {
|
const team = await prisma.team.findUnique({
|
||||||
try {
|
where: { id: teamId },
|
||||||
const team = await prisma.team.findUnique({
|
select: {
|
||||||
where: {
|
parent: {
|
||||||
id: teamId,
|
|
||||||
},
|
|
||||||
select: {
|
select: {
|
||||||
parent: {
|
isOrganization: true,
|
||||||
|
organizationSettings: {
|
||||||
select: {
|
select: {
|
||||||
isOrganization: true,
|
disablePhoneOnlySMSNotifications: true,
|
||||||
organizationSettings: {
|
|
||||||
select: {
|
|
||||||
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 {
|
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) {
|
let defaultScheduleId: number | null;
|
||||||
await prisma.user.update({
|
try {
|
||||||
where: {
|
defaultScheduleId = await getDefaultScheduleId(user.id, prisma);
|
||||||
id: user.id,
|
|
||||||
},
|
if (!user.defaultScheduleId) {
|
||||||
data: {
|
await prisma.user.update({
|
||||||
defaultScheduleId,
|
where: {
|
||||||
},
|
id: user.id,
|
||||||
});
|
},
|
||||||
|
data: {
|
||||||
|
defaultScheduleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
defaultScheduleId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
|||||||
const teamFilter = filters.find((filter) => filter.id === "teams") as
|
const teamFilter = filters.find((filter) => filter.id === "teams") as
|
||||||
| TypedColumnFilter<ColumnFilterType.MULTI_SELECT>
|
| TypedColumnFilter<ColumnFilterType.MULTI_SELECT>
|
||||||
| undefined;
|
| 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 = {
|
const whereClause: Prisma.MembershipWhereInput = {
|
||||||
user: {
|
user: {
|
||||||
@@ -97,6 +106,11 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
...(lastActiveAtFilter &&
|
||||||
|
makeWhereClause({
|
||||||
|
columnName: "lastActiveAt",
|
||||||
|
filterValue: lastActiveAtFilter.value,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
teamId: organizationId,
|
teamId: organizationId,
|
||||||
...(searchTerm && {
|
...(searchTerm && {
|
||||||
@@ -112,10 +126,27 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
|||||||
columnName: "role",
|
columnName: "role",
|
||||||
filterValue: roleFilter.value,
|
filterValue: roleFilter.value,
|
||||||
})),
|
})),
|
||||||
|
...(createdAtFilter &&
|
||||||
|
makeWhereClause({
|
||||||
|
columnName: "createdAt",
|
||||||
|
filterValue: createdAtFilter.value,
|
||||||
|
})),
|
||||||
|
...(updatedAtFilter &&
|
||||||
|
makeWhereClause({
|
||||||
|
columnName: "updatedAt",
|
||||||
|
filterValue: updatedAtFilter.value,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
const attributeFilters: Prisma.MembershipWhereInput["AttributeToUser"][] = filters
|
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) => {
|
.map((filter) => {
|
||||||
if (filter.value.type === ColumnFilterType.MULTI_SELECT && isAllString(filter.value.data)) {
|
if (filter.value.type === ColumnFilterType.MULTI_SELECT && isAllString(filter.value.data)) {
|
||||||
const attributeOptionValues: string[] = [];
|
const attributeOptionValues: string[] = [];
|
||||||
@@ -163,6 +194,8 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
|||||||
id: true,
|
id: true,
|
||||||
role: true,
|
role: true,
|
||||||
accepted: true,
|
accepted: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
user: {
|
user: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -249,6 +282,20 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
|
|||||||
.format(membership.user.lastActiveAt)
|
.format(membership.user.lastActiveAt)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
: null,
|
: 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,
|
avatarUrl: user.avatarUrl,
|
||||||
teams: user.teams
|
teams: user.teams
|
||||||
.filter((team) => team.team.id !== organizationId) // In this context we dont want to return the org team
|
.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 userMembership = usersInOrganization.find((membership) => membership.userId === userId);
|
||||||
const accepted = userMembership && userMembership.accepted;
|
const accepted = userMembership && userMembership.accepted;
|
||||||
return {
|
return {
|
||||||
|
createdAt: new Date(),
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
teamId,
|
||||||
role: MembershipRole.MEMBER,
|
role: MembershipRole.MEMBER,
|
||||||
|
|||||||
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user