Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
343e2ac9d7 | ||
|
|
670b82fd23 | ||
|
|
e6f81d09c8 | ||
|
|
8f91ffe692 | ||
|
|
f5dbdae4a9 | ||
|
|
e99f6048d6 | ||
|
|
654a204878 | ||
|
|
7928933852 | ||
|
|
c4cdba1015 | ||
|
|
5ed36a7fe1 | ||
|
|
e77a2f6963 | ||
|
|
a10d3599e2 | ||
|
|
d99f171597 | ||
|
|
da55306a5a | ||
|
|
66378160df | ||
|
|
8aee8d9d3b | ||
|
|
79e33a4d26 | ||
|
|
b5fa43f086 | ||
|
|
0d757039f7 | ||
|
|
44f6f2e320 | ||
|
|
d8eb1df61d | ||
|
|
ef7cac6c50 | ||
|
|
21dd44733f | ||
|
|
b0bfec7247 | ||
|
|
ba5b9e951a | ||
|
|
5e3865b645 | ||
|
|
a02240b579 | ||
|
|
376caa412d | ||
|
|
8722745185 | ||
|
+12 |
a4a3eb5ea9 | ||
|
|
b560475d89 | ||
|
|
4872daef60 | ||
|
|
dfa92f5cb6 | ||
|
|
babb1a3bf7 | ||
|
|
7b5a2726ab | ||
|
|
7d4653296e | ||
|
|
644fb38f87 | ||
|
|
743b155a88 | ||
|
|
664a92a1d0 | ||
|
|
8ae71f6160 | ||
|
|
ab1eab9c54 | ||
|
|
a64b7752c9 | ||
|
|
be728e9c01 | ||
|
|
2146c04444 | ||
|
|
0b94473c7f | ||
|
|
2a1dc619ee | ||
|
|
48a2fc2f67 | ||
|
|
4553455098 | ||
|
|
b4f1b5aebe | ||
|
|
a99e285d76 | ||
|
|
bdea436f8b | ||
|
|
182d0fa228 | ||
|
|
0d7f560c43 | ||
|
|
1377f1a214 | ||
|
|
e8dfa50119 | ||
|
|
706e8e4c1f | ||
|
|
a95729f4a1 | ||
|
|
4b6a389212 | ||
|
|
9d3be8de03 | ||
|
|
b283f1f8cd | ||
|
|
5ff52e1239 | ||
|
|
2b1cb9f878 | ||
|
|
74a46e8ba3 | ||
|
|
5b5f7c1c48 | ||
|
|
9375efffe3 | ||
|
|
da59c9de12 | ||
|
|
2a734faf45 | ||
|
|
934cefecce | ||
|
|
c88811aeee | ||
|
|
f46c3c65fb | ||
|
|
1d203dc566 | ||
|
|
26a1c749f7 | ||
|
|
ad347cb59f | ||
|
|
ac300ff670 | ||
|
|
c834b927b6 | ||
|
|
34ba27407b | ||
|
|
8431cd2ef8 | ||
|
|
cd09a56491 | ||
|
|
5c05a422a8 | ||
|
|
e805c0f00a | ||
|
|
2fa7f7931d | ||
|
|
20729b3a4e | ||
|
|
4d9777af8b | ||
|
|
1534588b34 | ||
|
|
7183ec7f1b | ||
|
|
d836497d56 | ||
|
|
38fa0fae1d | ||
|
|
2872b263fd |
+2
-2
@@ -168,13 +168,13 @@ NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE=
|
||||
NEXT_PUBLIC_IS_PREMIUM_NEW_PLAN=0
|
||||
NEXT_PUBLIC_STRIPE_PREMIUM_NEW_PLAN_PRICE=
|
||||
STRIPE_TEAM_MONTHLY_PRICE_ID=
|
||||
STRIPE_TEAM_PRODUCT_ID=
|
||||
STRIPE_ORG_MONTHLY_PRICE_ID=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_WEBHOOK_SECRET_APPS=
|
||||
STRIPE_PRIVATE_KEY=
|
||||
STRIPE_CLIENT_ID=
|
||||
|
||||
|
||||
# Use for internal Public API Keys and optional
|
||||
API_KEY_PREFIX=cal_
|
||||
# ***********************************************************************************************************
|
||||
@@ -334,8 +334,8 @@ AB_TEST_BUCKET_PROBABILITY=50
|
||||
# whether we redirect to the future/event-types from event-types or not
|
||||
APP_ROUTER_EVENT_TYPES_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_SECURITY_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
|
||||
APP_ROUTER_SETTINGS_ORG_ENABLED=0
|
||||
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
## Mandatory Tasks (DO NOT REMOVE)
|
||||
|
||||
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
|
||||
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com/docs). If N/A, write N/A here and check the checkbox.
|
||||
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
|
||||
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
|
||||
|
||||
## How should this be tested?
|
||||
|
||||
@@ -11,6 +11,7 @@ export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true })
|
||||
smsLockState: true,
|
||||
smsLockReviewedByAdmin: true,
|
||||
bookingLimits: true,
|
||||
includeManagedEventsInLimits: true,
|
||||
});
|
||||
|
||||
const schemaTeamRequiredParams = z.object({
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { NextApiRequest } from "next";
|
||||
|
||||
import handleCancelBooking from "@calcom/features/bookings/lib/handleCancelBooking";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { schemaBookingCancelParams } from "@calcom/prisma/zod-utils";
|
||||
import { bookingCancelSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
|
||||
|
||||
@@ -65,7 +65,7 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
*/
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { id, allRemainingBookings, cancellationReason } = schemaQueryIdParseInt
|
||||
.merge(schemaBookingCancelParams.pick({ allRemainingBookings: true, cancellationReason: true }))
|
||||
.merge(bookingCancelSchema.pick({ allRemainingBookings: true, cancellationReason: true }))
|
||||
.parse({
|
||||
...req.query,
|
||||
allRemainingBookings: req.query.allRemainingBookings === "true",
|
||||
|
||||
@@ -28,13 +28,14 @@
|
||||
"dependencies": {
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.43",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.44",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
"@golevelup/ts-jest": "^0.4.0",
|
||||
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
|
||||
"@nest-lab/throttler-storage-redis": "1.0.0",
|
||||
"@nestjs/bull": "^10.1.1",
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.1.1",
|
||||
@@ -43,7 +44,7 @@
|
||||
"@nestjs/passport": "^10.0.2",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/swagger": "^7.3.0",
|
||||
"@nestjs/throttler": "^5.1.2",
|
||||
"@nestjs/throttler": "6.2.1",
|
||||
"@sentry/node": "^8.8.0",
|
||||
"body-parser": "^1.20.2",
|
||||
"bull": "^4.12.4",
|
||||
@@ -57,10 +58,10 @@
|
||||
"ioredis": "^5.3.2",
|
||||
"luxon": "^3.4.4",
|
||||
"nest-winston": "^1.9.4",
|
||||
"nestjs-throttler-storage-redis": "^0.4.1",
|
||||
"next-auth": "^4.22.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"qs-stringify": "^1.2.1",
|
||||
"querystring": "^0.2.1",
|
||||
"reflect-metadata": "^0.1.13",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
+373
-14
@@ -1,26 +1,385 @@
|
||||
import { AppModule } from "@/app.module";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { CustomThrottlerGuard } from "@/lib/throttler-guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { RateLimitRepositoryFixture } from "test/fixtures/repository/rate-limit.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
|
||||
describe("AppController (e2e)", () => {
|
||||
let app: INestApplication;
|
||||
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
import { User, PlatformOAuthClient, Team, RateLimit } from "@calcom/prisma/client";
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule],
|
||||
}).compile();
|
||||
describe("AppController", () => {
|
||||
describe("Rate limiting", () => {
|
||||
let app: INestApplication;
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let rateLimitRepositoryFixture: RateLimitRepositoryFixture;
|
||||
const userEmail = "app-rate-limits-e2e@api.com";
|
||||
let user: User;
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
let organization: Team;
|
||||
let oAuthClient: PlatformOAuthClient;
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
|
||||
it("/ (GET)", () => {
|
||||
return request(app.getHttpServer()).get("/health").expect("OK");
|
||||
});
|
||||
let apiKeyString: string;
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
let rateLimit: RateLimit;
|
||||
let apiKeyStringWithRateLimit: string;
|
||||
|
||||
let apiKeyStringWithMultipleLimits: string;
|
||||
let firstRateLimitWithMultipleLimits: RateLimit;
|
||||
let secondRateLimitWithMultipleLimits: RateLimit;
|
||||
|
||||
const mockDefaultLimit = 5;
|
||||
const mockDefaultTtl = 2500;
|
||||
const mockDefaultBlockDuration = 5000;
|
||||
|
||||
beforeEach(async () => {
|
||||
const moduleRef: TestingModule = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule, SchedulesModule_2024_04_15],
|
||||
}).compile();
|
||||
|
||||
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultLimit").mockReturnValue(mockDefaultLimit);
|
||||
jest.spyOn(CustomThrottlerGuard.prototype, "getDefaultTtl").mockReturnValue(mockDefaultTtl);
|
||||
jest
|
||||
.spyOn(CustomThrottlerGuard.prototype, "getDefaultBlockDuration")
|
||||
.mockReturnValue(mockDefaultBlockDuration);
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyString = `cal_test_${keyString}`;
|
||||
|
||||
rateLimitRepositoryFixture = new RateLimitRepositoryFixture(moduleRef);
|
||||
const { apiKey, keyString: keyStringWithRateLimit } = await apiKeysRepositoryFixture.createApiKey(
|
||||
user.id,
|
||||
null
|
||||
);
|
||||
apiKeyStringWithRateLimit = `cal_test_${keyStringWithRateLimit}`;
|
||||
rateLimit = await rateLimitRepositoryFixture.createRateLimit("long", apiKey.id, 2000, 3, 4000);
|
||||
|
||||
const { apiKey: apiKeyWithMultipleLimits, keyString: keyStringWithMultipleLimits } =
|
||||
await apiKeysRepositoryFixture.createApiKey(user.id, null);
|
||||
apiKeyStringWithMultipleLimits = `cal_test_${keyStringWithMultipleLimits}`;
|
||||
firstRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
|
||||
"short",
|
||||
apiKeyWithMultipleLimits.id,
|
||||
1000,
|
||||
2,
|
||||
2000
|
||||
);
|
||||
secondRateLimitWithMultipleLimits = await rateLimitRepositoryFixture.createRateLimit(
|
||||
"long",
|
||||
apiKeyWithMultipleLimits.id,
|
||||
2000,
|
||||
3,
|
||||
4000
|
||||
);
|
||||
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
organization = await organizationsRepositoryFixture.create({ name: "ecorp" });
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
await profilesRepositoryFixture.create({
|
||||
uid: "asd-asd",
|
||||
username: userEmail,
|
||||
user: { connect: { id: user.id } },
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 1023,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
it(
|
||||
"api key with default rate limit - should enforce rate limits and reset after block duration",
|
||||
async () => {
|
||||
const limit = mockDefaultLimit;
|
||||
const blockDuration = mockDefaultBlockDuration;
|
||||
|
||||
for (let i = 1; i <= limit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyString}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
|
||||
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const blockedResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
|
||||
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
|
||||
blockDuration / 1000
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||
|
||||
const afterBlockResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyString}`)
|
||||
.expect(200);
|
||||
|
||||
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
|
||||
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
},
|
||||
15 * 1000
|
||||
);
|
||||
|
||||
it(
|
||||
"api key with custom rate limit - should enforce rate limits and reset after block duration",
|
||||
async () => {
|
||||
const limit = rateLimit.limit;
|
||||
const blockDuration = rateLimit.blockDuration;
|
||||
const name = rateLimit.name;
|
||||
|
||||
for (let i = 1; i <= limit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithRateLimit}` })
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||
expect(response.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - i).toString());
|
||||
expect(Number(response.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const blockedResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||
expect(blockedResponse.headers[`x-ratelimit-remaining-${name}`]).toBe("0");
|
||||
expect(Number(blockedResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThanOrEqual(
|
||||
blockDuration / 1000
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||
|
||||
const afterBlockResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set("Authorization", `Bearer ${apiKeyStringWithRateLimit}`)
|
||||
.expect(200);
|
||||
|
||||
expect(afterBlockResponse.headers[`x-ratelimit-limit-${name}`]).toBe(limit.toString());
|
||||
expect(afterBlockResponse.headers[`x-ratelimit-remaining-${name}`]).toBe((limit - 1).toString());
|
||||
expect(Number(afterBlockResponse.headers[`x-ratelimit-reset-${name}`])).toBeGreaterThan(0);
|
||||
},
|
||||
15 * 1000
|
||||
);
|
||||
|
||||
it(
|
||||
"api key with multiple rate limits - should enforce both short and long rate limits",
|
||||
async () => {
|
||||
const shortLimit = firstRateLimitWithMultipleLimits.limit;
|
||||
const longLimit = secondRateLimitWithMultipleLimits.limit;
|
||||
const shortName = firstRateLimitWithMultipleLimits.name;
|
||||
const longName = secondRateLimitWithMultipleLimits.name;
|
||||
const shortBlock = firstRateLimitWithMultipleLimits.blockDuration;
|
||||
const longBlock = secondRateLimitWithMultipleLimits.blockDuration;
|
||||
|
||||
let requestsMade = 0;
|
||||
// note(Lauris): exhaust short limit to have remaining 0 for it
|
||||
for (let i = 1; i <= shortLimit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
|
||||
requestsMade++;
|
||||
|
||||
expect(response.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
|
||||
expect(response.headers[`x-ratelimit-remaining-${shortName}`]).toBe((shortLimit - i).toString());
|
||||
expect(Number(response.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(0);
|
||||
|
||||
expect(response.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||
expect(response.headers[`x-ratelimit-remaining-${longName}`]).toBe((longLimit - i).toString());
|
||||
expect(Number(response.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// note(Lauris): short limit exhausted, now exhaust long limit to have remaining 0 for it
|
||||
for (let i = requestsMade; i < longLimit; i++) {
|
||||
const responseAfterShortLimit = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
|
||||
requestsMade++;
|
||||
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||
shortLimit.toString()
|
||||
);
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
|
||||
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||
expect(responseAfterShortLimit.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||
(longLimit - requestsMade).toString()
|
||||
);
|
||||
expect(Number(responseAfterShortLimit.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
// note(Lauris): both have remaining 0 so now exceed both
|
||||
const blockedResponseLong = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-limit-${shortName}`]).toBe(shortLimit.toString());
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${shortName}`]).toBe("0");
|
||||
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${shortName}`])).toBeGreaterThanOrEqual(
|
||||
firstRateLimitWithMultipleLimits.blockDuration / 1000
|
||||
);
|
||||
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-limit-${longName}`]).toBe(longLimit.toString());
|
||||
expect(blockedResponseLong.headers[`x-ratelimit-remaining-${longName}`]).toBe("0");
|
||||
expect(Number(blockedResponseLong.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThanOrEqual(
|
||||
secondRateLimitWithMultipleLimits.blockDuration / 1000
|
||||
);
|
||||
|
||||
// note(Lauris): wait for short limit to reset
|
||||
await new Promise((resolve) => setTimeout(resolve, shortBlock));
|
||||
const responseAfterShortLimitReload = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||
shortLimit.toString()
|
||||
);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
|
||||
(shortLimit - 1).toString()
|
||||
);
|
||||
expect(
|
||||
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${shortName}`])
|
||||
).toBeGreaterThan(0);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
|
||||
longLimit.toString()
|
||||
);
|
||||
expect(responseAfterShortLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||
(longLimit - requestsMade).toString()
|
||||
);
|
||||
expect(
|
||||
Number(responseAfterShortLimitReload.headers[`x-ratelimit-reset-${longName}`])
|
||||
).toBeGreaterThan(0);
|
||||
|
||||
// note(Lauris): wait for long limit to reset
|
||||
await new Promise((resolve) => setTimeout(resolve, longBlock));
|
||||
const responseAfterLongLimitReload = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set({ Authorization: `Bearer ${apiKeyStringWithMultipleLimits}` })
|
||||
.expect(200);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${shortName}`]).toBe(
|
||||
shortLimit.toString()
|
||||
);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${shortName}`]).toBe(
|
||||
(shortLimit - 1).toString()
|
||||
);
|
||||
expect(
|
||||
Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${shortName}`])
|
||||
).toBeGreaterThan(0);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-limit-${longName}`]).toBe(
|
||||
longLimit.toString()
|
||||
);
|
||||
expect(responseAfterLongLimitReload.headers[`x-ratelimit-remaining-${longName}`]).toBe(
|
||||
(longLimit - 1).toString()
|
||||
);
|
||||
expect(Number(responseAfterLongLimitReload.headers[`x-ratelimit-reset-${longName}`])).toBeGreaterThan(
|
||||
0
|
||||
);
|
||||
},
|
||||
30 * 1000
|
||||
);
|
||||
|
||||
it(
|
||||
"non api key with default rate limit - should enforce rate limits and reset after block duration",
|
||||
async () => {
|
||||
const limit = mockDefaultLimit;
|
||||
const blockDuration = mockDefaultBlockDuration;
|
||||
|
||||
for (let i = 1; i <= limit; i++) {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200);
|
||||
|
||||
expect(response.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(response.headers["x-ratelimit-remaining-default"]).toBe((limit - i).toString());
|
||||
expect(Number(response.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
const blockedResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(429);
|
||||
|
||||
expect(blockedResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(blockedResponse.headers["x-ratelimit-remaining-default"]).toBe("0");
|
||||
expect(Number(blockedResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThanOrEqual(
|
||||
blockDuration / 1000
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, blockDuration));
|
||||
|
||||
const afterBlockResponse = await request(app.getHttpServer())
|
||||
.get("/v2/me")
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200);
|
||||
|
||||
expect(afterBlockResponse.headers["x-ratelimit-limit-default"]).toBe(limit.toString());
|
||||
expect(afterBlockResponse.headers["x-ratelimit-remaining-default"]).toBe((limit - 1).toString());
|
||||
expect(Number(afterBlockResponse.headers["x-ratelimit-reset-default"])).toBeGreaterThan(0);
|
||||
},
|
||||
15 * 1000
|
||||
);
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(userEmail);
|
||||
await organizationsRepositoryFixture.delete(organization.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,12 +13,12 @@ import { JwtModule } from "@/modules/jwt/jwt.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { BullModule } from "@nestjs/bull";
|
||||
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
|
||||
import { seconds, ThrottlerModule } from "@nestjs/throttler";
|
||||
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
|
||||
|
||||
import { AppController } from "./app.controller";
|
||||
|
||||
@@ -34,16 +34,19 @@ import { AppController } from "./app.controller";
|
||||
BullModule.forRoot({
|
||||
redis: `${process.env.REDIS_URL}${process.env.NODE_ENV === "production" ? "?tls=true" : ""}`,
|
||||
}),
|
||||
// Rate limiting here is handled by the CustomThrottlerGuard
|
||||
ThrottlerModule.forRootAsync({
|
||||
imports: [RedisModule],
|
||||
inject: [RedisService],
|
||||
useFactory: (redisService: RedisService) => ({
|
||||
// note(Lauris): IMPORTANT: rate limiting is enforced by CustomThrottlerGuard, but we need to have at least one
|
||||
// entry in the throttlers array otherwise CustomThrottlerGuard is not invoked at all. If we specify only ThrottlerModule
|
||||
// without .forRootAsync then throttler options are not passed to CustomThrottlerGuard containing redis connection etc.
|
||||
// So we need to specify at least one dummy throttler here and CustomThrottlerGuard is actually handling the default and custom rate limits.
|
||||
throttlers: [
|
||||
{
|
||||
name: "long",
|
||||
ttl: seconds(60), // Time to live for the long period in seconds
|
||||
limit: 120, // Maximum number of requests within the long ttl
|
||||
name: "dummy",
|
||||
ttl: seconds(60),
|
||||
limit: 120,
|
||||
},
|
||||
],
|
||||
storage: new ThrottlerStorageRedisService(redisService.redis),
|
||||
@@ -56,6 +59,13 @@ import { AppController } from "./app.controller";
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
{
|
||||
provide: ThrottlerStorageRedisService,
|
||||
useFactory: (redisService: RedisService) => {
|
||||
return new ThrottlerStorageRedisService(redisService.redis);
|
||||
},
|
||||
inject: [RedisService],
|
||||
},
|
||||
{
|
||||
provide: APP_INTERCEPTOR,
|
||||
useClass: ResponseInterceptor,
|
||||
|
||||
@@ -14,7 +14,7 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByIdsWithAttendeesAndUser(ids: number[]) {
|
||||
async getByIdsWithAttendeesAndUserAndEvent(ids: number[]) {
|
||||
return this.dbRead.prisma.booking.findMany({
|
||||
where: {
|
||||
id: {
|
||||
@@ -24,6 +24,7 @@ export class BookingsRepository_2024_08_13 {
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -36,7 +37,7 @@ export class BookingsRepository_2024_08_13 {
|
||||
});
|
||||
}
|
||||
|
||||
async getByIdWithAttendeesAndUser(id: number) {
|
||||
async getByIdWithAttendeesAndUserAndEvent(id: number) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
id,
|
||||
@@ -44,11 +45,12 @@ export class BookingsRepository_2024_08_13 {
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getByUidWithAttendeesAndUser(uid: string) {
|
||||
async getByUidWithAttendeesAndUserAndEvent(uid: string) {
|
||||
return this.dbRead.prisma.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
@@ -56,11 +58,12 @@ export class BookingsRepository_2024_08_13 {
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRecurringByUidWithAttendeesAndUser(uid: string) {
|
||||
async getRecurringByUidWithAttendeesAndUserAndEvent(uid: string) {
|
||||
return this.dbRead.prisma.booking.findMany({
|
||||
where: {
|
||||
recurringEventId: uid,
|
||||
@@ -68,6 +71,7 @@ export class BookingsRepository_2024_08_13 {
|
||||
include: {
|
||||
attendees: true,
|
||||
user: true,
|
||||
eventType: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { GetBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-booking.output";
|
||||
import { GetBookingsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-bookings.output";
|
||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
@@ -36,6 +34,7 @@ import { User } from "@prisma/client";
|
||||
import { Request } from "express";
|
||||
|
||||
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInputPipe,
|
||||
CreateBookingInput,
|
||||
|
||||
+1
-1
@@ -1,7 +1,6 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { GetBookingsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-bookings.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
@@ -29,6 +28,7 @@ import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
|
||||
+32
-19
@@ -2,8 +2,6 @@ import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { GetBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-booking.output";
|
||||
import { GetBookingsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/get-bookings.output";
|
||||
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
|
||||
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
@@ -30,6 +28,7 @@ import {
|
||||
VERSION_2024_08_13,
|
||||
X_CAL_CLIENT_ID,
|
||||
} from "@calcom/platform-constants";
|
||||
import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
@@ -58,6 +57,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let user: User;
|
||||
|
||||
let eventTypeId: number;
|
||||
const eventTypeSlug = "peer-coding";
|
||||
let recurringEventTypeId: number;
|
||||
|
||||
let createdBooking: BookingOutput_2024_08_13;
|
||||
@@ -100,7 +100,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
};
|
||||
await schedulesService.createUserSchedule(user.id, userSchedule);
|
||||
const event = await eventTypesRepositoryFixture.create(
|
||||
{ title: "peer coding", slug: "peer-coding", length: 60 },
|
||||
{ title: "peer coding", slug: eventTypeSlug, length: 60 },
|
||||
user.id
|
||||
);
|
||||
eventTypeId = event.id;
|
||||
@@ -184,7 +184,10 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -208,14 +211,24 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
|
||||
expect(data.duration).toEqual(60);
|
||||
expect(data.eventTypeId).toEqual(eventTypeId);
|
||||
expect(data.eventType).toEqual({
|
||||
id: eventTypeId,
|
||||
slug: eventTypeSlug,
|
||||
});
|
||||
expect(data.attendees[0]).toEqual({
|
||||
name: body.attendee.name,
|
||||
timeZone: body.attendee.timeZone,
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(data.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(data.location).toEqual(body.location);
|
||||
expect(data.meetingUrl).toEqual(body.location);
|
||||
expect(data.absentHost).toEqual(false);
|
||||
expect(data.bookingFieldsResponses).toEqual({
|
||||
name: body.attendee.name,
|
||||
email: body.attendee.email,
|
||||
...body.bookingFieldsResponses,
|
||||
});
|
||||
createdBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -235,7 +248,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
meetingUrl: "https://meet.google.com/abc-def-ghi",
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -268,7 +281,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(firstBooking.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.recurringBookingUid).toBeDefined();
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
|
||||
@@ -288,7 +301,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(secondBooking.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
|
||||
const thirdBooking = data[2];
|
||||
@@ -307,7 +320,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
language: body.attendee.language,
|
||||
absent: false,
|
||||
});
|
||||
expect(thirdBooking.meetingUrl).toEqual(body.meetingUrl);
|
||||
expect(thirdBooking.location).toEqual(body.location);
|
||||
expect(thirdBooking.absentHost).toEqual(false);
|
||||
|
||||
createdRecurringBooking = data;
|
||||
@@ -343,7 +356,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.duration).toEqual(createdBooking.duration);
|
||||
expect(data.eventTypeId).toEqual(createdBooking.eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
|
||||
expect(data.meetingUrl).toEqual(createdBooking.meetingUrl);
|
||||
expect(data.location).toEqual(createdBooking.location);
|
||||
expect(data.absentHost).toEqual(createdBooking.absentHost);
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -377,7 +390,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
|
||||
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
|
||||
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
|
||||
expect(data.meetingUrl).toEqual(createdRecurringBooking[0].meetingUrl);
|
||||
expect(data.location).toEqual(createdRecurringBooking[0].location);
|
||||
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -414,7 +427,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(firstBooking.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
|
||||
expect(firstBooking.recurringBookingUid).toEqual(recurringBookingUid);
|
||||
expect(firstBooking.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
|
||||
expect(firstBooking.meetingUrl).toEqual(createdRecurringBooking[0].meetingUrl);
|
||||
expect(firstBooking.location).toEqual(createdRecurringBooking[0].location);
|
||||
expect(firstBooking.absentHost).toEqual(createdRecurringBooking[0].absentHost);
|
||||
|
||||
const secondBooking = data[1];
|
||||
@@ -428,7 +441,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(secondBooking.eventTypeId).toEqual(createdRecurringBooking[1].eventTypeId);
|
||||
expect(secondBooking.recurringBookingUid).toEqual(recurringBookingUid);
|
||||
expect(secondBooking.attendees[0]).toEqual(createdRecurringBooking[1].attendees[0]);
|
||||
expect(secondBooking.meetingUrl).toEqual(createdRecurringBooking[1].meetingUrl);
|
||||
expect(secondBooking.location).toEqual(createdRecurringBooking[1].location);
|
||||
expect(secondBooking.absentHost).toEqual(createdRecurringBooking[1].absentHost);
|
||||
|
||||
const thirdBooking = data[2];
|
||||
@@ -442,7 +455,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(thirdBooking.eventTypeId).toEqual(createdRecurringBooking[2].eventTypeId);
|
||||
expect(thirdBooking.recurringBookingUid).toEqual(recurringBookingUid);
|
||||
expect(thirdBooking.attendees[0]).toEqual(createdRecurringBooking[2].attendees[0]);
|
||||
expect(thirdBooking.meetingUrl).toEqual(createdRecurringBooking[2].meetingUrl);
|
||||
expect(thirdBooking.location).toEqual(createdRecurringBooking[2].location);
|
||||
expect(thirdBooking.absentHost).toEqual(createdRecurringBooking[2].absentHost);
|
||||
|
||||
createdRecurringBooking = data;
|
||||
@@ -764,7 +777,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.duration).toEqual(createdBooking.duration);
|
||||
expect(data.eventTypeId).toEqual(createdBooking.eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
|
||||
expect(data.meetingUrl).toEqual(createdBooking.meetingUrl);
|
||||
expect(data.location).toEqual(createdBooking.location);
|
||||
expect(data.absentHost).toEqual(createdBooking.absentHost);
|
||||
|
||||
rescheduledBooking = data;
|
||||
@@ -815,7 +828,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.recurringBookingUid).toEqual(createdRecurringBooking[0].recurringBookingUid);
|
||||
expect(data.eventTypeId).toEqual(createdRecurringBooking[0].eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
|
||||
expect(data.meetingUrl).toEqual(createdRecurringBooking[0].meetingUrl);
|
||||
expect(data.location).toEqual(createdRecurringBooking[0].location);
|
||||
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
|
||||
|
||||
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
|
||||
@@ -885,7 +898,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.duration).toEqual(booking.duration);
|
||||
expect(data.eventTypeId).toEqual(booking.eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(booking.attendees[0]);
|
||||
expect(data.meetingUrl).toEqual(booking.meetingUrl);
|
||||
expect(data.location).toEqual(booking.location);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -918,7 +931,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.eventTypeId).toEqual(booking.eventTypeId);
|
||||
expect(data.attendees[0].absent).toEqual(true);
|
||||
expect(data.absentHost).toEqual(booking.absentHost);
|
||||
expect(data.meetingUrl).toEqual(booking.meetingUrl);
|
||||
expect(data.location).toEqual(booking.location);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -955,7 +968,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.duration).toEqual(rescheduledBooking.duration);
|
||||
expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId);
|
||||
expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]);
|
||||
expect(data.meetingUrl).toEqual(rescheduledBooking.meetingUrl);
|
||||
expect(data.location).toEqual(rescheduledBooking.location);
|
||||
expect(data.absentHost).toEqual(rescheduledBooking.absentHost);
|
||||
|
||||
const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
|
||||
|
||||
@@ -73,7 +73,9 @@ export class BookingsService_2024_08_13 {
|
||||
const bookingRequest = await this.inputService.createBookingRequest(request, body);
|
||||
const booking = await handleInstantMeeting(bookingRequest);
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUser(booking.bookingId);
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
|
||||
booking.bookingId
|
||||
);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.bookingId} was not found in the database`);
|
||||
}
|
||||
@@ -94,7 +96,7 @@ export class BookingsService_2024_08_13 {
|
||||
throw new Error("Recurring booking was not created");
|
||||
}
|
||||
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUser(uid);
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid);
|
||||
return this.outputService.getOutputRecurringBookings(recurringBooking);
|
||||
}
|
||||
|
||||
@@ -106,7 +108,7 @@ export class BookingsService_2024_08_13 {
|
||||
throw new Error("Booking was not created");
|
||||
}
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUser(booking.id);
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.id} was not found in the database`);
|
||||
}
|
||||
@@ -115,7 +117,7 @@ export class BookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
async getBooking(uid: string) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUser(uid);
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(uid);
|
||||
|
||||
if (booking) {
|
||||
const isRecurring = !!booking.recurringEventId;
|
||||
@@ -125,7 +127,7 @@ export class BookingsService_2024_08_13 {
|
||||
return this.outputService.getOutputBooking(booking);
|
||||
}
|
||||
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUser(uid);
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid);
|
||||
if (!recurringBooking.length) {
|
||||
throw new NotFoundException(`Booking with uid=${uid} was not found in the database`);
|
||||
}
|
||||
@@ -149,7 +151,7 @@ export class BookingsService_2024_08_13 {
|
||||
// note(Lauris): fetchedBookings don't have attendees information and responses and i don't want to add them to the handler query,
|
||||
// because its used elsewhere in code that does not need that information, so i get ids, fetch bookings and then return them formatted in same order as ids.
|
||||
const ids = fetchedBookings.bookings.map((booking) => booking.id);
|
||||
const bookings = await this.bookingsRepository.getByIdsWithAttendeesAndUser(ids);
|
||||
const bookings = await this.bookingsRepository.getByIdsWithAttendeesAndUserAndEvent(ids);
|
||||
|
||||
const bookingMap = new Map(bookings.map((booking) => [booking.id, booking]));
|
||||
const orderedBookings = ids.map((id) => bookingMap.get(id));
|
||||
@@ -162,6 +164,7 @@ export class BookingsService_2024_08_13 {
|
||||
|
||||
const formatted = {
|
||||
...booking,
|
||||
eventType: booking.eventType,
|
||||
eventTypeId: booking.eventTypeId,
|
||||
startTime: new Date(booking.startTime),
|
||||
endTime: new Date(booking.endTime),
|
||||
@@ -191,7 +194,7 @@ export class BookingsService_2024_08_13 {
|
||||
throw new Error("Booking was not created");
|
||||
}
|
||||
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUser(booking.id);
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.id} was not found in the database`);
|
||||
}
|
||||
@@ -226,7 +229,7 @@ export class BookingsService_2024_08_13 {
|
||||
userId: bookingOwnerId,
|
||||
});
|
||||
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUser(bookingUid);
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
|
||||
if (!booking) {
|
||||
throw new Error(`Booking with uid=${bookingUid} was not found in the database`);
|
||||
|
||||
@@ -89,7 +89,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
|
||||
const location = request.body.meetingUrl;
|
||||
const location = request.body.location || request.body.meetingUrl;
|
||||
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
|
||||
|
||||
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
|
||||
@@ -147,7 +147,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
|
||||
: DEFAULT_PLATFORM_PARAMS;
|
||||
|
||||
const location = request.body.meetingUrl;
|
||||
const location = request.body.location || request.body.meetingUrl;
|
||||
Object.assign(newRequest, {
|
||||
userId,
|
||||
...oAuthParams,
|
||||
@@ -257,7 +257,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUser(bookingUid);
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
|
||||
}
|
||||
@@ -390,7 +390,9 @@ export class InputBookingsService_2024_08_13 {
|
||||
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
||||
let allRemainingBookings = false;
|
||||
let uid = bookingUid;
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUser(bookingUid);
|
||||
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
|
||||
bookingUid
|
||||
);
|
||||
|
||||
if (recurringBooking.length) {
|
||||
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
|
||||
|
||||
@@ -7,14 +7,20 @@ import { z } from "zod";
|
||||
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { Booking } from "@calcom/prisma/client";
|
||||
|
||||
export const bookingResponsesSchema = z.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
guests: z.array(z.string()).optional(),
|
||||
rescheduledReason: z.string().optional(),
|
||||
});
|
||||
export const bookingResponsesSchema = z
|
||||
.object({
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
guests: z.array(z.string()).optional(),
|
||||
rescheduledReason: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
type DatabaseBooking = Booking & {
|
||||
eventType: {
|
||||
id: number;
|
||||
slug: string;
|
||||
} | null;
|
||||
attendees: {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -38,6 +44,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
uid: databaseBooking.uid,
|
||||
title: databaseBooking.title,
|
||||
description: databaseBooking.description,
|
||||
hosts: [databaseBooking.user],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
@@ -46,6 +54,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
duration,
|
||||
eventType: databaseBooking.eventType,
|
||||
// note(Lauris): eventTypeId is deprecated
|
||||
eventTypeId: databaseBooking.eventTypeId,
|
||||
attendees: databaseBooking.attendees.map((attendee) => ({
|
||||
name: attendee.name,
|
||||
@@ -55,18 +65,22 @@ export class OutputBookingsService_2024_08_13 {
|
||||
absent: !!attendee.noShow,
|
||||
})),
|
||||
guests: bookingResponses.guests,
|
||||
location: databaseBooking.location,
|
||||
// note(Lauris): meetingUrl is deprecated
|
||||
meetingUrl: databaseBooking.location,
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
};
|
||||
|
||||
return plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
bookingTransformed.bookingFieldsResponses = bookingResponses;
|
||||
return bookingTransformed;
|
||||
}
|
||||
|
||||
async getOutputRecurringBookings(databaseBookings: DatabaseBooking[]) {
|
||||
const transformed = [];
|
||||
|
||||
for (const booking of databaseBookings) {
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUser(booking.id);
|
||||
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
|
||||
if (!databaseBooking) {
|
||||
throw new Error(`Booking with id=${booking.id} was not found in the database`);
|
||||
}
|
||||
@@ -87,6 +101,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
uid: databaseBooking.uid,
|
||||
title: databaseBooking.title,
|
||||
description: databaseBooking.description,
|
||||
hosts: [databaseBooking.user],
|
||||
status: databaseBooking.status.toLowerCase(),
|
||||
cancellationReason: databaseBooking.cancellationReason || undefined,
|
||||
@@ -95,6 +111,8 @@ export class OutputBookingsService_2024_08_13 {
|
||||
start: databaseBooking.startTime,
|
||||
end: databaseBooking.endTime,
|
||||
duration,
|
||||
eventType: databaseBooking.eventType,
|
||||
// note(Lauris): eventTypeId is deprecated
|
||||
eventTypeId: databaseBooking.eventTypeId,
|
||||
attendees: databaseBooking.attendees.map((attendee) => ({
|
||||
name: attendee.name,
|
||||
@@ -104,9 +122,12 @@ export class OutputBookingsService_2024_08_13 {
|
||||
absent: !!attendee.noShow,
|
||||
})),
|
||||
guests: bookingResponses.guests,
|
||||
location: databaseBooking.location,
|
||||
// note(Lauris): meetingUrl is deprecated
|
||||
meetingUrl: databaseBooking.location,
|
||||
recurringBookingUid: databaseBooking.recurringEventId,
|
||||
absentHost: !!databaseBooking.noShowHost,
|
||||
bookingFieldsResponses: databaseBooking.responses,
|
||||
};
|
||||
|
||||
return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { getEventTypeById } from "@calcom/platform-libraries";
|
||||
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesRepository_2024_06_14 {
|
||||
@@ -69,21 +66,6 @@ export class EventTypesRepository_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(
|
||||
user: UserWithProfile,
|
||||
isUserOrganizationAdmin: boolean,
|
||||
eventTypeId: number
|
||||
) {
|
||||
return await getEventTypeById({
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
isUserOrganizationAdmin,
|
||||
isTrpcCall: true,
|
||||
});
|
||||
}
|
||||
|
||||
async getEventTypeById(eventTypeId: number) {
|
||||
return this.dbRead.prisma.eventType.findUnique({
|
||||
where: { id: eventTypeId },
|
||||
|
||||
@@ -142,27 +142,6 @@ export class EventTypesService_2024_06_14 {
|
||||
});
|
||||
}
|
||||
|
||||
async getUserEventTypeForAtom(user: UserWithProfile, eventTypeId: number) {
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
|
||||
const eventType = await this.eventTypesRepository.getUserEventTypeForAtom(
|
||||
user,
|
||||
isUserOrganizationAdmin,
|
||||
eventTypeId
|
||||
);
|
||||
|
||||
if (!eventType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
|
||||
const user = await this.usersRepository.findByUsername(username);
|
||||
if (!user) {
|
||||
|
||||
+4
@@ -242,6 +242,10 @@ export class InputEventTypesService_2024_06_14 {
|
||||
return transformConfirmationPolicyApiToInternal(requiresConfirmation);
|
||||
}
|
||||
transformInputRecurrignEvent(recurrence: CreateEventTypeInput_2024_06_14["recurrence"]) {
|
||||
if (!recurrence || recurrence.disabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return transformRecurrenceApiToInternal(recurrence);
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -23,8 +23,7 @@ import {
|
||||
Patch,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiResponse, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import { ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { UpdateScheduleInput_2024_04_15 } from "@calcom/platform-types";
|
||||
@@ -73,7 +72,6 @@ export class SchedulesController_2024_04_15 {
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("scheduleId") scheduleId: number
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiResponse, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
|
||||
import { SCHEDULE_READ, SCHEDULE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import {
|
||||
@@ -105,7 +104,6 @@ export class SchedulesController_2024_06_11 {
|
||||
|
||||
@Get("/:scheduleId")
|
||||
@Permissions([SCHEDULE_READ])
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } }) // allow 10 requests per minute (for :scheduleId)
|
||||
@ApiOperation({ summary: "Get a schedule" })
|
||||
async getSchedule(
|
||||
@GetUser() user: UserWithProfile,
|
||||
|
||||
@@ -20,6 +20,12 @@ export type Environment = {
|
||||
GET_LICENSE_KEY_URL: string;
|
||||
API_KEY_PREFIX: string;
|
||||
DOCS_URL: string;
|
||||
RATE_LIMIT_DEFAULT_TTL_MS: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT_API_KEY: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT_OAUTH_CLIENT: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN: number;
|
||||
RATE_LIMIT_DEFAULT_LIMIT: number;
|
||||
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
|
||||
};
|
||||
|
||||
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
|
||||
|
||||
@@ -1,42 +1,230 @@
|
||||
import { isApiKey } from "@/lib/api-key";
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { getEnv } from "@/env";
|
||||
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
|
||||
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { ThrottlerGuard, ThrottlerModuleOptions, ThrottlerStorage } from "@nestjs/throttler";
|
||||
import { Request } from "express";
|
||||
import {
|
||||
ThrottlerGuard,
|
||||
ThrottlerException,
|
||||
ThrottlerRequest,
|
||||
ThrottlerModuleOptions,
|
||||
seconds,
|
||||
} from "@nestjs/throttler";
|
||||
import { Request, Response } from "express";
|
||||
import { z } from "zod";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
|
||||
const rateLimitSchema = z.object({
|
||||
name: z.string(),
|
||||
limit: z.number(),
|
||||
ttl: z.number(),
|
||||
blockDuration: z.number(),
|
||||
});
|
||||
type RateLimitType = z.infer<typeof rateLimitSchema>;
|
||||
const rateLimitsSchema = z.array(rateLimitSchema);
|
||||
|
||||
const sixtySecondsMs = 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
private logger = new Logger("CustomThrottlerGuard");
|
||||
|
||||
private defaultTttl = Number(getEnv("RATE_LIMIT_DEFAULT_TTL_MS", sixtySecondsMs));
|
||||
|
||||
private defaultLimitApiKey = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_API_KEY", 120));
|
||||
private defaultLimitOAuthClient = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_OAUTH_CLIENT", 500));
|
||||
private defaultLimitAccessToken = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN", 500));
|
||||
private defaultLimit = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT", 120));
|
||||
|
||||
private defaultBlockDuration = Number(getEnv("RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS", sixtySecondsMs));
|
||||
|
||||
constructor(
|
||||
options: ThrottlerModuleOptions,
|
||||
storageService: ThrottlerStorage,
|
||||
@Inject(ThrottlerStorageRedisService) protected readonly storageService: ThrottlerStorageRedisService,
|
||||
reflector: Reflector,
|
||||
private readonly config: ConfigService
|
||||
private readonly dbRead: PrismaReadService
|
||||
) {
|
||||
super(options, storageService, reflector);
|
||||
this.storageService = storageService;
|
||||
}
|
||||
|
||||
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
|
||||
const { context } = requestProps;
|
||||
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const tracker = await this.getTracker(request);
|
||||
this.logger.log(
|
||||
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
|
||||
"Authorization"
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${request.ip}"`
|
||||
);
|
||||
|
||||
if (tracker.startsWith("api_key_")) {
|
||||
return this.handleApiKeyRequest(tracker, response);
|
||||
} else {
|
||||
return this.handleNonApiKeyRequest(tracker, response);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
|
||||
|
||||
let allLimitsBlocked = true;
|
||||
for (const rateLimit of rateLimits) {
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (!isBlocked) {
|
||||
allLimitsBlocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allLimitsBlocked) {
|
||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimit = this.getDefaultRateLimit(tracker);
|
||||
this.logger.log(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
${JSON.stringify(rateLimit, null, 2)}
|
||||
`);
|
||||
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (isBlocked) {
|
||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getDefaultRateLimit(tracker: string) {
|
||||
return {
|
||||
name: "default",
|
||||
limit: this.getDefaultLimit(tracker),
|
||||
ttl: this.getDefaultTtl(),
|
||||
blockDuration: this.getDefaultBlockDuration(),
|
||||
};
|
||||
}
|
||||
|
||||
getDefaultLimit(tracker: string) {
|
||||
if (tracker.startsWith("api_key_")) {
|
||||
return this.defaultLimitApiKey;
|
||||
} else if (tracker.startsWith("oauth_client_")) {
|
||||
return this.defaultLimitOAuthClient;
|
||||
} else if (tracker.startsWith("access_token_")) {
|
||||
return this.defaultLimitAccessToken;
|
||||
} else {
|
||||
return this.defaultLimit;
|
||||
}
|
||||
}
|
||||
|
||||
getDefaultTtl() {
|
||||
return this.defaultTttl;
|
||||
}
|
||||
|
||||
getDefaultBlockDuration() {
|
||||
return this.defaultBlockDuration;
|
||||
}
|
||||
|
||||
private async getRateLimitsForApiKeyTracker(tracker: string) {
|
||||
const cacheKey = `rate_limit:${tracker}`;
|
||||
|
||||
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
||||
if (cachedRateLimits) {
|
||||
this.logger.log(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
${cachedRateLimits}
|
||||
`);
|
||||
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
|
||||
}
|
||||
|
||||
const apiKey = tracker.replace("api_key_", "");
|
||||
let rateLimits: RateLimitType[];
|
||||
const apiKeyRecord = await this.dbRead.prisma.apiKey.findUnique({
|
||||
where: { hashedKey: apiKey },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!apiKeyRecord) {
|
||||
throw new UnauthorizedException("Invalid API Key");
|
||||
}
|
||||
|
||||
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
|
||||
where: { apiKeyId: apiKeyRecord.id },
|
||||
select: { name: true, limit: true, ttl: true, blockDuration: true },
|
||||
});
|
||||
|
||||
if (rateLimits) {
|
||||
this.logger.log(`Tracker "${tracker}" rate limits retrieved from database:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!rateLimits || rateLimits.length === 0) {
|
||||
rateLimits = [this.getDefaultRateLimit(tracker)];
|
||||
this.logger.log(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
}
|
||||
|
||||
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
|
||||
|
||||
return rateLimits;
|
||||
}
|
||||
|
||||
private async incrementRateLimit(tracker: string, rateLimit: RateLimitType, response: Response) {
|
||||
const { name, limit, ttl, blockDuration } = rateLimit;
|
||||
|
||||
const key = `${tracker}:${limit}:${ttl}`;
|
||||
|
||||
const { isBlocked, totalHits, timeToExpire, timeToBlockExpire } = await this.storageService.increment(
|
||||
key,
|
||||
ttl,
|
||||
limit,
|
||||
blockDuration,
|
||||
name
|
||||
);
|
||||
|
||||
const nameFirstUpper = name.charAt(0).toUpperCase() + name.slice(1);
|
||||
response.setHeader(`X-RateLimit-Limit-${nameFirstUpper}`, limit);
|
||||
response.setHeader(
|
||||
`X-RateLimit-Remaining-${nameFirstUpper}`,
|
||||
timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)
|
||||
);
|
||||
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
|
||||
|
||||
this.logger.log(
|
||||
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
|
||||
);
|
||||
this.logger.log(
|
||||
`Tracker "${tracker}" rate limit "${name}" response headers:
|
||||
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
||||
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
||||
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
||||
);
|
||||
|
||||
return { isBlocked };
|
||||
}
|
||||
|
||||
protected async getTracker(request: Request): Promise<string> {
|
||||
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
|
||||
|
||||
if (authorizationHeader) {
|
||||
return isApiKey(authorizationHeader, this.config.get<string>("api.apiKeyPrefix") ?? "cal_")
|
||||
? `api_key_${authorizationHeader}`
|
||||
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
|
||||
return isApiKey(authorizationHeader, apiKeyPrefix)
|
||||
? `api_key_${hashAPIKey(stripApiKey(authorizationHeader, apiKeyPrefix))}`
|
||||
: `access_token_${authorizationHeader}`;
|
||||
}
|
||||
|
||||
const oauthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
if (oauthClientId) {
|
||||
return oauthClientId;
|
||||
return `oauth_client_${oauthClientId}`;
|
||||
}
|
||||
|
||||
if (request.ip) {
|
||||
return request.ip;
|
||||
return `ip_${request.ip}`;
|
||||
}
|
||||
|
||||
this.logger.log(`no tracker found: ${request.url}`);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { App } from "@prisma/client";
|
||||
import { App, Prisma } from "@prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class AppsRepository {
|
||||
@@ -10,4 +10,15 @@ export class AppsRepository {
|
||||
async getAppBySlug(slug: string): Promise<App | null> {
|
||||
return await this.dbRead.prisma.app.findUnique({ where: { slug } });
|
||||
}
|
||||
|
||||
async createAppCredential(type: string, key: Prisma.InputJsonValue, userId: number, appId: string) {
|
||||
return this.dbWrite.prisma.credential.create({
|
||||
data: {
|
||||
type: type,
|
||||
key: key,
|
||||
userId: userId,
|
||||
appId: appId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, EventTypesModule_2024_06_14],
|
||||
providers: [
|
||||
EventTypesAtomService,
|
||||
MembershipsRepository,
|
||||
UsersRepository,
|
||||
UsersService,
|
||||
SchedulesRepository_2024_06_11,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
controllers: [AtomsController],
|
||||
})
|
||||
export class AtomsModule {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Version,
|
||||
VERSION_NEUTRAL,
|
||||
Patch,
|
||||
Body,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
/*
|
||||
|
||||
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
|
||||
these endpoints should not be recommended for use by third party and are excluded from docs
|
||||
|
||||
*/
|
||||
|
||||
@Controller({
|
||||
path: "/v2/atoms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Atoms - endpoints for atoms")
|
||||
@DocsExcludeController(true)
|
||||
export class AtomsController {
|
||||
constructor(private readonly eventTypesService: EventTypesAtomService) {}
|
||||
|
||||
@Get("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async getAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number
|
||||
): Promise<ApiResponse<unknown>> {
|
||||
const eventType = await this.eventTypesService.getUserEventType(user, eventTypeId);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
|
||||
@Patch("event-types/:eventTypeId")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
async updateAtomEventType(
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
|
||||
@Body() body: UpdateEventTypeReturn
|
||||
): Promise<ApiResponse<UpdateEventTypeReturn>> {
|
||||
const eventType = await this.eventTypesService.updateEventType(eventTypeId, body, user);
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: eventType,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
updateEventType,
|
||||
TUpdateEventTypeInputSchema,
|
||||
systemBeforeFieldEmail,
|
||||
getEventTypeById,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { PrismaClient } from "@calcom/prisma/client";
|
||||
|
||||
@Injectable()
|
||||
export class EventTypesAtomService {
|
||||
constructor(
|
||||
private readonly membershipsRepository: MembershipsRepository,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly dbWrite: PrismaWriteService,
|
||||
private readonly dbRead: PrismaReadService,
|
||||
private readonly eventTypeService: EventTypesService_2024_06_14
|
||||
) {}
|
||||
|
||||
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, { id: eventTypeId, userId: user.id });
|
||||
const organizationId = this.usersService.getUserMainOrgId(user);
|
||||
|
||||
const isUserOrganizationAdmin = organizationId
|
||||
? await this.membershipsRepository.isUserOrganizationAdmin(user.id, organizationId)
|
||||
: false;
|
||||
|
||||
const eventType = await getEventTypeById({
|
||||
currentOrganizationId: this.usersService.getUserMainOrgId(user),
|
||||
eventTypeId,
|
||||
userId: user.id,
|
||||
prisma: this.dbRead.prisma as unknown as PrismaClient,
|
||||
isUserOrganizationAdmin,
|
||||
isTrpcCall: true,
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
|
||||
return eventType;
|
||||
}
|
||||
|
||||
async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) {
|
||||
this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
|
||||
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
|
||||
const bookingFields = [...(body.bookingFields || [])];
|
||||
|
||||
if (
|
||||
!bookingFields.find((field) => field.type === "email") &&
|
||||
!bookingFields.find((field) => field.type === "phone")
|
||||
) {
|
||||
bookingFields.push(systemBeforeFieldEmail);
|
||||
}
|
||||
|
||||
const eventType = await updateEventType({
|
||||
input: { id: eventTypeId, ...body, bookingFields },
|
||||
ctx: {
|
||||
user: eventTypeUser,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
}
|
||||
|
||||
return eventType.eventType;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { createMock } from "@golevelup/ts-jest";
|
||||
import { ExecutionContext } from "@nestjs/common";
|
||||
@@ -26,7 +27,8 @@ describe("PermissionsGuard", () => {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
})
|
||||
}),
|
||||
createMock<OAuthClientRepository>()
|
||||
);
|
||||
});
|
||||
|
||||
@@ -38,7 +40,7 @@ describe("PermissionsGuard", () => {
|
||||
it("should return false", async () => {
|
||||
const mockContext = createMockExecutionContext({});
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(0);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
@@ -51,7 +53,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -62,7 +64,7 @@ describe("PermissionsGuard", () => {
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
oAuthClientPermissions |= SCHEDULE_READ;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
@@ -73,7 +75,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
@@ -83,7 +85,7 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= APPS_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
@@ -94,7 +96,63 @@ describe("PermissionsGuard", () => {
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissions").mockResolvedValue(oAuthClientPermissions);
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when oauth id is provided", () => {
|
||||
it("should return true for valid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for multiple valid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
oAuthClientPermissions |= SCHEDULE_READ;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for empty Permissions decorator", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid permissions", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= APPS_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a missing permission", async () => {
|
||||
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
|
||||
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE, SCHEDULE_READ]);
|
||||
|
||||
let oAuthClientPermissions = 0;
|
||||
oAuthClientPermissions |= SCHEDULE_WRITE;
|
||||
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
|
||||
|
||||
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { isApiKey } from "@/lib/api-key";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { getToken } from "next-auth/jwt";
|
||||
|
||||
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
|
||||
import { hasPermissions } from "@calcom/platform-utils";
|
||||
|
||||
@Injectable()
|
||||
@@ -13,7 +15,8 @@ export class PermissionsGuard implements CanActivate {
|
||||
constructor(
|
||||
private reflector: Reflector,
|
||||
private tokensRepository: TokensRepository,
|
||||
private readonly config: ConfigService
|
||||
private readonly config: ConfigService,
|
||||
private readonly oAuthClientRepository: OAuthClientRepository
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
@@ -27,12 +30,13 @@ export class PermissionsGuard implements CanActivate {
|
||||
const authString = request.get("Authorization")?.replace("Bearer ", "");
|
||||
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
|
||||
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
|
||||
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
if (nextAuthToken) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!authString) {
|
||||
if (!authString && !oAuthClientId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -41,7 +45,9 @@ export class PermissionsGuard implements CanActivate {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oAuthClientPermissions = await this.getOAuthClientPermissions(authString);
|
||||
const oAuthClientPermissions = authString
|
||||
? await this.getOAuthClientPermissionsByAccessToken(authString)
|
||||
: await this.getOAuthClientPermissionsById(oAuthClientId);
|
||||
|
||||
if (!oAuthClientPermissions) {
|
||||
return false;
|
||||
@@ -50,8 +56,13 @@ export class PermissionsGuard implements CanActivate {
|
||||
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
|
||||
}
|
||||
|
||||
async getOAuthClientPermissions(accessToken: string) {
|
||||
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
|
||||
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
|
||||
return oAuthClient?.permissions;
|
||||
}
|
||||
|
||||
async getOAuthClientPermissionsById(id: string) {
|
||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
|
||||
return oAuthClient?.permissions;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,20 +68,14 @@ export class BillingController {
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
|
||||
const { action, url } = await this.billingService.createSubscriptionForTeam(teamId, input.plan);
|
||||
|
||||
if (action === "redirect") {
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
action: "redirect",
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
const customerId = await this.billingService.createTeamBilling(teamId);
|
||||
const url = await this.billingService.redirectToSubscribeCheckout(teamId, input.plan, customerId);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -92,20 +86,13 @@ export class BillingController {
|
||||
@Param("teamId") teamId: number,
|
||||
@Body() input: SubscribeToPlanInput
|
||||
): Promise<ApiResponse<SubscribeTeamToBillingResponseDto | undefined>> {
|
||||
const { action, url } = await this.billingService.updateSubscriptionForTeam(teamId, input.plan);
|
||||
|
||||
if (action === "redirect") {
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
action: "redirect",
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
const url = await this.billingService.updateSubscriptionForTeam(teamId, input.plan);
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
data: {
|
||||
url,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
async createSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
async createTeamBilling(teamId: number) {
|
||||
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
|
||||
let customerId = teamWithBilling?.platformBilling?.customerId;
|
||||
|
||||
@@ -60,6 +60,10 @@ export class BillingService implements OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
return customerId;
|
||||
}
|
||||
|
||||
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
|
||||
const { url } = await this.stripeService.stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
line_items: [
|
||||
@@ -90,7 +94,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
|
||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||
|
||||
return { action: "redirect", url };
|
||||
return url;
|
||||
}
|
||||
|
||||
async updateSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
|
||||
@@ -111,7 +115,7 @@ export class BillingService implements OnModuleDestroy {
|
||||
|
||||
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
|
||||
|
||||
return { action: "redirect", url };
|
||||
return url;
|
||||
}
|
||||
|
||||
async setSubscriptionForTeam(teamId: number, subscriptionId: string, plan: PlatformPlan) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { PlatformEndpointsModule } from "@/ee/platform-endpoints-module";
|
||||
import { AtomsModule } from "@/modules/atoms/atoms.module";
|
||||
import { BillingModule } from "@/modules/billing/billing.module";
|
||||
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
import { Module } from "@nestjs/common";
|
||||
@@ -18,6 +20,8 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
UsersModule,
|
||||
WebhooksModule,
|
||||
DestinationCalendarsModule,
|
||||
AtomsModule,
|
||||
StripeModule,
|
||||
],
|
||||
})
|
||||
export class EndpointsModule implements NestModule {
|
||||
|
||||
+11
-2
@@ -25,9 +25,11 @@ import {
|
||||
import {
|
||||
ApiTags as DocsTags,
|
||||
ApiExcludeController as DocsExcludeController,
|
||||
ApiExcludeEndpoint as DocsExcludeEndpoint,
|
||||
ApiOperation as DocsOperation,
|
||||
ApiOkResponse as DocsOkResponse,
|
||||
ApiBadRequestResponse as DocsBadRequestResponse,
|
||||
ApiHeader as DocsHeader,
|
||||
} from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse } from "express";
|
||||
|
||||
@@ -37,8 +39,7 @@ import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
|
||||
path: "/v2/oauth/:clientId",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsExcludeController(getEnv("NODE_ENV") === "production")
|
||||
@DocsTags("OAuth - development only")
|
||||
@DocsTags("OAuth")
|
||||
export class OAuthFlowController {
|
||||
constructor(
|
||||
private readonly oauthClientRepository: OAuthClientRepository,
|
||||
@@ -62,6 +63,7 @@ export class OAuthFlowController {
|
||||
description:
|
||||
"Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client.",
|
||||
})
|
||||
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
|
||||
async authorize(
|
||||
@Param("clientId") clientId: string,
|
||||
@Body() body: OAuthAuthorizeInput,
|
||||
@@ -108,6 +110,7 @@ export class OAuthFlowController {
|
||||
description:
|
||||
"Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match.",
|
||||
})
|
||||
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
|
||||
async exchange(
|
||||
@Headers("Authorization") authorization: string,
|
||||
@Param("clientId") clientId: string,
|
||||
@@ -138,6 +141,12 @@ export class OAuthFlowController {
|
||||
@Post("/refresh")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@DocsTags("Managed users")
|
||||
@DocsHeader({
|
||||
name: X_CAL_SECRET_KEY,
|
||||
description: "OAuth client secret key.",
|
||||
required: true,
|
||||
})
|
||||
async refreshAccessToken(
|
||||
@Param("clientId") clientId: string,
|
||||
@Headers(X_CAL_SECRET_KEY) secretKey: string,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ApiProperty as DocsProperty } from "@nestjs/swagger";
|
||||
import { IsString } from "class-validator";
|
||||
|
||||
export class RefreshTokenInput {
|
||||
@IsString()
|
||||
@DocsProperty({ description: "Managed user's refresh token." })
|
||||
refreshToken!: string;
|
||||
}
|
||||
|
||||
@@ -76,4 +76,8 @@ export class UpdateOrgTeamDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
readonly bookingLimits?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
readonly includeManagedEventsInLimits?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { SlotsService } from "@/modules/slots/services/slots.service";
|
||||
import { Query, Body, Controller, Get, Delete, Post, Req, Res } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
import { Throttle, seconds } from "@nestjs/throttler";
|
||||
import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation } from "@nestjs/swagger";
|
||||
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
@@ -20,6 +19,21 @@ export class SlotsController {
|
||||
constructor(private readonly slotsService: SlotsService) {}
|
||||
|
||||
@Post("/reserve")
|
||||
@ApiCreatedResponse({
|
||||
description: "Successful response returning uid of reserved slot.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", example: "success" },
|
||||
data: {
|
||||
type: "object",
|
||||
properties: {
|
||||
uid: { type: "string", example: "e2a7bcf9-cc7b-40a0-80d3-657d391775a6" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: "Reserve a slot" })
|
||||
async reserveSlot(
|
||||
@Body() body: ReserveSlotInput,
|
||||
@@ -36,6 +50,15 @@ export class SlotsController {
|
||||
}
|
||||
|
||||
@Delete("/selected-slot")
|
||||
@ApiOkResponse({
|
||||
description: "Response deleting reserved slot by uid.",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", example: "success" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: "Delete a selected slot" })
|
||||
async deleteSelectedSlot(
|
||||
@Query() params: RemoveSelectedSlotInput,
|
||||
@@ -51,8 +74,46 @@ export class SlotsController {
|
||||
}
|
||||
|
||||
@Get("/available")
|
||||
@ApiOkResponse({
|
||||
description: "Available time slots retrieved successfully",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", example: "success" },
|
||||
data: {
|
||||
type: "object",
|
||||
properties: {
|
||||
slots: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
time: { type: "string", format: "date-time", example: "2024-09-25T08:00:00.000Z" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
example: {
|
||||
status: "success",
|
||||
data: {
|
||||
slots: {
|
||||
"2024-09-25": [{ time: "2024-09-25T08:00:00.000Z" }, { time: "2024-09-25T08:15:00.000Z" }],
|
||||
"2024-09-26": [
|
||||
{ time: "2024-09-26T08:00:00.000Z" },
|
||||
{ time: "2024-09-26T08:15:00.000Z" },
|
||||
{ time: "2024-09-26T08:30:00.000Z" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiOperation({ summary: "Get available slots" })
|
||||
@Throttle({ default: { limit: 300, ttl: seconds(60) } }) // allow 300 requests per minute
|
||||
async getAvailableSlots(
|
||||
@Query() query: GetAvailableSlotsInput,
|
||||
@Req() req: ExpressRequest
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import {
|
||||
StripConnectOutputDto,
|
||||
StripConnectOutputResponseDto,
|
||||
StripCredentialsCheckOutputResponseDto,
|
||||
StripCredentialsSaveOutputResponseDto,
|
||||
} from "@/modules/stripe/outputs/stripe.output";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { getOnErrorReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Query,
|
||||
UseGuards,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Redirect,
|
||||
Req,
|
||||
BadRequestException,
|
||||
Headers,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiOperation } from "@nestjs/swagger";
|
||||
import { plainToClass } from "class-transformer";
|
||||
import { Request } from "express";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/stripe",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Stripe")
|
||||
export class StripeController {
|
||||
constructor(private readonly stripeService: StripeService) {}
|
||||
|
||||
@Get("/connect")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Get stripe connect URL" })
|
||||
async redirect(
|
||||
@Req() req: Request,
|
||||
@Headers("Authorization") authorization: string,
|
||||
@GetUser() user: UserWithProfile,
|
||||
@Query("redir") redir?: string | null,
|
||||
@Query("errorRedir") errorRedir?: string | null
|
||||
): Promise<StripConnectOutputResponseDto> {
|
||||
const origin = req.headers.origin;
|
||||
const accessToken = authorization.replace("Bearer ", "");
|
||||
|
||||
const state = {
|
||||
onErrorReturnTo: !!errorRedir ? errorRedir : origin,
|
||||
fromApp: false,
|
||||
returnTo: !!redir ? redir : origin,
|
||||
accessToken,
|
||||
};
|
||||
|
||||
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
|
||||
JSON.stringify(state),
|
||||
user.email,
|
||||
user.name
|
||||
);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: plainToClass(StripConnectOutputDto, { authUrl: stripeRedirectUrl }, { strategy: "excludeAll" }),
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/save")
|
||||
@UseGuards()
|
||||
@Redirect(undefined, 301)
|
||||
@ApiOperation({ summary: "Save stripe credentials" })
|
||||
async save(
|
||||
@Query("state") state: string,
|
||||
@Query("code") code: string,
|
||||
@Query("error") error: string | undefined,
|
||||
@Query("error_description") error_description: string | undefined
|
||||
): Promise<StripCredentialsSaveOutputResponseDto> {
|
||||
const accessToken = JSON.parse(state).accessToken;
|
||||
|
||||
// user cancels flow
|
||||
if (error === "access_denied") {
|
||||
return { url: getOnErrorReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new BadRequestException(stringify({ error, error_description }));
|
||||
}
|
||||
|
||||
return await this.stripeService.saveStripeAccount(state, code, accessToken);
|
||||
}
|
||||
|
||||
@Get("/check")
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: "Check stripe connection" })
|
||||
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
|
||||
return await this.stripeService.checkIfStripeAccountConnected(user.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, IsOptional } from "class-validator";
|
||||
|
||||
export class StripeConnectQueryParamsInputDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
readonly redir?: string;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested, IsEnum } from "class-validator";
|
||||
|
||||
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class StripConnectOutputDto {
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly authUrl!: string;
|
||||
}
|
||||
|
||||
export class StripConnectOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
|
||||
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@Expose()
|
||||
@ValidateNested()
|
||||
@Type(() => StripConnectOutputDto)
|
||||
data!: StripConnectOutputDto;
|
||||
}
|
||||
|
||||
export class StripCredentialsCheckOutputResponseDto {
|
||||
@ApiProperty({ example: SUCCESS_STATUS })
|
||||
status!: typeof SUCCESS_STATUS;
|
||||
}
|
||||
|
||||
export class StripCredentialsSaveOutputResponseDto {
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly url!: string;
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
imports: [ConfigModule, PrismaModule],
|
||||
exports: [StripeService],
|
||||
providers: [StripeService],
|
||||
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository],
|
||||
controllers: [StripeController],
|
||||
})
|
||||
export class StripeModule {}
|
||||
|
||||
@@ -1,15 +1,130 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
|
||||
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
|
||||
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
|
||||
|
||||
import stringify = require("qs-stringify");
|
||||
|
||||
@Injectable()
|
||||
export class StripeService {
|
||||
public stripe: Stripe;
|
||||
private redirectUri = `${this.config.get("api.url")}/stripe/save`;
|
||||
|
||||
constructor(configService: ConfigService<AppConfig>) {
|
||||
constructor(
|
||||
configService: ConfigService<AppConfig>,
|
||||
private readonly config: ConfigService,
|
||||
private readonly appsRepository: AppsRepository,
|
||||
private readonly credentialRepository: CredentialsRepository,
|
||||
private readonly tokensRepository: TokensRepository
|
||||
) {
|
||||
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
}
|
||||
|
||||
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
|
||||
const { client_id } = await this.getStripeAppKeys();
|
||||
|
||||
const stripeConnectParams: Stripe.OAuthAuthorizeUrlParams = {
|
||||
client_id,
|
||||
scope: "read_write",
|
||||
response_type: "code",
|
||||
stripe_user: {
|
||||
email: userEmail,
|
||||
first_name: userName || undefined,
|
||||
/** We need this so E2E don't fail for international users */
|
||||
country: process.env.NEXT_PUBLIC_IS_E2E ? "US" : undefined,
|
||||
},
|
||||
redirect_uri: this.redirectUri,
|
||||
state: state,
|
||||
};
|
||||
|
||||
const params = z.record(z.any()).parse(stripeConnectParams);
|
||||
const query = stringify(params);
|
||||
const url = `https://connect.stripe.com/oauth/authorize?${query}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
async getStripeAppKeys() {
|
||||
const app = await this.appsRepository.getAppBySlug("stripe");
|
||||
|
||||
const { client_id, client_secret } = stripeKeysResponseSchema.parse(app?.keys);
|
||||
|
||||
if (!client_id) {
|
||||
throw new NotFoundException("Stripe app not found");
|
||||
}
|
||||
|
||||
if (!client_secret) {
|
||||
throw new NotFoundException("Stripe app not found");
|
||||
}
|
||||
|
||||
return { client_id, client_secret };
|
||||
}
|
||||
|
||||
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
|
||||
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Invalid Access token.");
|
||||
}
|
||||
|
||||
const response = await stripeInstance.oauth.token({
|
||||
grant_type: "authorization_code",
|
||||
code: code?.toString(),
|
||||
});
|
||||
|
||||
const data: StripeData = { ...response, default_currency: "" };
|
||||
if (response["stripe_user_id"]) {
|
||||
const account = await stripeInstance.accounts.retrieve(response["stripe_user_id"]);
|
||||
data["default_currency"] = account.default_currency;
|
||||
}
|
||||
|
||||
await this.appsRepository.createAppCredential(
|
||||
"stripe_payment",
|
||||
data as unknown as Prisma.InputJsonObject,
|
||||
userId,
|
||||
"stripe"
|
||||
);
|
||||
|
||||
return { url: getReturnToValueFromQueryState(state) };
|
||||
}
|
||||
|
||||
async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
|
||||
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
|
||||
|
||||
if (!stripeCredentials) {
|
||||
throw new NotFoundException("Credentials for stripe not found.");
|
||||
}
|
||||
|
||||
if (stripeCredentials.invalid) {
|
||||
throw new BadRequestException("Invalid stripe credentials.");
|
||||
}
|
||||
|
||||
const stripeKey = JSON.stringify(stripeCredentials.key);
|
||||
const stripeKeyObject = JSON.parse(stripeKey);
|
||||
|
||||
const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id);
|
||||
|
||||
// both of these should be true for an account to be fully active
|
||||
if (!stripeAccount.payouts_enabled || !stripeAccount.charges_enabled) {
|
||||
throw new BadRequestException("Stripe account is not an active account");
|
||||
}
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export const getReturnToValueFromQueryState = (queryState: string | string[] | undefined) => {
|
||||
let returnTo = "";
|
||||
try {
|
||||
returnTo = JSON.parse(`${queryState}`).returnTo;
|
||||
} catch (error) {
|
||||
console.info("No 'returnTo' in req.query.state");
|
||||
}
|
||||
return returnTo;
|
||||
};
|
||||
|
||||
export const getOnErrorReturnToValueFromQueryState = (queryState: string | string[] | undefined) => {
|
||||
let returnTo = "";
|
||||
try {
|
||||
returnTo = JSON.parse(`${queryState}`).onErrorReturnTo;
|
||||
} catch (error) {
|
||||
console.info("No 'onErrorReturnTo' in req.query.state");
|
||||
}
|
||||
return returnTo;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
const stripeApiKey = process.env.STRIPE_API_KEY || "";
|
||||
export const stripeInstance = new Stripe(stripeApiKey, {
|
||||
apiVersion: "2020-08-27",
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const stripeOAuthTokenSchema = z.object({
|
||||
access_token: z.string().optional(),
|
||||
scope: z.string().optional(),
|
||||
livemode: z.boolean().optional(),
|
||||
token_type: z.literal("bearer").optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
stripe_user_id: z.string().optional(),
|
||||
stripe_publishable_key: z.string().optional(),
|
||||
});
|
||||
|
||||
export const stripeDataSchema = stripeOAuthTokenSchema.extend({
|
||||
default_currency: z.string(),
|
||||
});
|
||||
|
||||
export type StripeData = z.infer<typeof stripeDataSchema>;
|
||||
|
||||
export const stripeKeysResponseSchema = z.object({
|
||||
client_id: z.string().startsWith("ca_").min(1),
|
||||
client_secret: z.string().startsWith("sk_").min(1),
|
||||
public_key: z.string().startsWith("pk_").min(1),
|
||||
webhook_secret: z.string().startsWith("whsec_").min(1),
|
||||
});
|
||||
@@ -3,12 +3,12 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
import { WebhooksService } from "@/modules/webhooks/services/webhooks.service";
|
||||
import {
|
||||
BadRequestException,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { Request } from "express";
|
||||
|
||||
@@ -39,14 +39,10 @@ export class IsOAuthClientWebhookGuard implements CanActivate {
|
||||
throw new BadRequestException("webhookId parameter not specified in the request");
|
||||
}
|
||||
|
||||
if (!webhookId) {
|
||||
if (!oAuthClientId) {
|
||||
throw new BadRequestException("oAuthClientId parameter not specified in the request");
|
||||
}
|
||||
|
||||
if (!user || !webhookId || !oAuthClientId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(oAuthClientId);
|
||||
|
||||
if (!oAuthClient) {
|
||||
|
||||
+1207
-466
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { TestingModule } from "@nestjs/testing";
|
||||
|
||||
export class RateLimitRepositoryFixture {
|
||||
private dbWrite: PrismaWriteService["prisma"];
|
||||
|
||||
constructor(private readonly module: TestingModule) {
|
||||
this.dbWrite = module.get(PrismaWriteService).prisma;
|
||||
}
|
||||
|
||||
async createRateLimit(name: string, apiKeyId: string, ttl: number, limit: number, blockDuration: number) {
|
||||
return await this.dbWrite.rateLimit.create({
|
||||
data: {
|
||||
name,
|
||||
apiKeyId,
|
||||
ttl,
|
||||
limit,
|
||||
blockDuration,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
|
||||
API_KEY_PREFIX: "cal_test_",
|
||||
GET_LICENSE_KEY_URL: " https://console.cal.com/api/license",
|
||||
CALCOM_LICENSE_KEY: "c4234812-12ab-42s6-a1e3-55bedd4a5bb7",
|
||||
RATE_LIMIT_DEFAULT_TTL_MS: 60000,
|
||||
// note(Lauris): setting high limit so that e2e tests themselves are not rate limited
|
||||
RATE_LIMIT_DEFAULT_LIMIT: 10000,
|
||||
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: 60000,
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
|
||||
@@ -8,8 +8,8 @@ import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME }
|
||||
const ROUTES: [URLPattern, boolean][] = [
|
||||
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
|
||||
["/settings/developer/:path*", process.env.APP_ROUTER_SETTINGS_DEVELOPER_ENABLED === "1"] as const,
|
||||
["/settings/security/:path*", process.env.APP_ROUTER_SETTINGS_SECURITY_ENABLED === "1"] as const,
|
||||
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
|
||||
["/settings/my-account/:path*", process.env.APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED === "1"] as const,
|
||||
["/settings/platform/:path*", process.env.APP_ROUTER_SETTINGS_PLATFORM_ENABLED === "1"] as const,
|
||||
["/settings/organizations/:path*", process.env.APP_ROUTER_SETTINGS_ORG_ENABLED === "1"] as const,
|
||||
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
|
||||
|
||||
@@ -21,8 +21,5 @@ export const withAppDirSsr =
|
||||
...("trpcState" in props && {
|
||||
dehydratedState: props.trpcState,
|
||||
}),
|
||||
...("isInfiniteScrollEnabled" in props && {
|
||||
isInfiniteScrollEnabled: props.isInfiniteScrollEnabled,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { PageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
import type { GetServerSidePropsResult } from "next";
|
||||
@@ -27,12 +27,12 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const legacyContext = buildLegacyCtx(headers(), cookies(), params, searchParams);
|
||||
const data = await getData(legacyContext);
|
||||
const form = "form" in data ? (data.form as { name?: string; description?: string }) : null;
|
||||
const formName = form?.name ?? "Routing Forms";
|
||||
const formDescription = form?.description ?? "";
|
||||
const formName = form?.name;
|
||||
const formDescription = form?.description;
|
||||
|
||||
return await _generateMetadata(
|
||||
() => formName,
|
||||
() => formDescription
|
||||
(t) => formName ?? t("routing_forms"),
|
||||
(t) => formDescription ?? t("routing_forms_description")
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import CategoryPage, { type PageProps } from "~/apps/categories/[category]/categ
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => "Apps Store",
|
||||
() => "Connecting people, technology and the workplace."
|
||||
(t) => t("app_store"),
|
||||
(t) => t("app_store_description")
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import Page from "~/apps/categories/categories-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => "Apps Store",
|
||||
() => "Connecting people, technology and the workplace."
|
||||
(t) => t("app_store"),
|
||||
(t) => t("app_store_description")
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ const getData = withAppDirSsg<Y>(getStaticProps);
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Bookings",
|
||||
() => "Create events to share for people to book on your calendar."
|
||||
(t) => t("bookings"),
|
||||
(t) => t("bookings_description")
|
||||
);
|
||||
|
||||
export const generateStaticParams = async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import InsightsPage from "~/insights/insights-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
() => "Insights",
|
||||
(t) => t("insights"),
|
||||
(t) => t("insights_subtitle")
|
||||
);
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import PlatformView from "~/settings/platform/platform-view";
|
||||
|
||||
export const generateMetadata = async () => {
|
||||
return await _generateMetadata(
|
||||
() => "Platform",
|
||||
() => "Manage everything related to platform."
|
||||
(t) => t("platform"),
|
||||
(t) => t("platform_description")
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
lang={locale}
|
||||
dir={direction}
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}
|
||||
suppressHydrationWarning
|
||||
data-nextjs-router="app">
|
||||
<head nonce={nonce}>
|
||||
{!!process.env.NEXT_PUBLIC_HEAD_SCRIPTS && (
|
||||
|
||||
@@ -51,7 +51,7 @@ export function WithLayout<T extends Record<string, any>>({
|
||||
requiresLicense={requiresLicense || !!(Page && "requiresLicense" in Page && Page.requiresLicense)}
|
||||
nonce={nonce}
|
||||
themeBasis={null}
|
||||
isThemeSupported={!!(Page && "isThemeSupported" in Page && Page.isThemeSupported)}
|
||||
isThemeSupported={Page && "isThemeSupported" in Page ? (Page.isThemeSupported as boolean) : undefined}
|
||||
isBookingPage={isBookingPage || !!(Page && "isBookingPage" in Page && Page.isBookingPage)}
|
||||
{...props}>
|
||||
{pageWithServerLayout}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getLayout } from "@calcom/features/settings/appDir/SettingsLayoutAppDir";
|
||||
|
||||
export default WithLayout({ getServerLayout: getLayout })<"L">;
|
||||
@@ -42,7 +42,7 @@ export default function EnterprisePage() {
|
||||
];
|
||||
return (
|
||||
<div>
|
||||
<ShellMain heading="Enterprise" subtitle={t("enterprise_description")}>
|
||||
<ShellMain heading={t("enterprise")} subtitle={t("enterprise_description")}>
|
||||
<UpgradeTip
|
||||
plan="enterprise"
|
||||
title={t("create_your_org")}
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function CancelBooking(props: Props) {
|
||||
|
||||
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
|
||||
if (node !== null) {
|
||||
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
|
||||
node.scrollIntoView({ behavior: "smooth" });
|
||||
node.focus();
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps)
|
||||
const { calendar } = props;
|
||||
const { t } = useLocale();
|
||||
const mutation = trpc.viewer.setDestinationCalendar.useMutation();
|
||||
const connectedCalendarsQuery = trpc.viewer.connectedCalendars.useQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -26,6 +27,7 @@ const CreateEventsOnCalendarSelect = (props: ICreateEventsOnCalendarSelectProps)
|
||||
mutation.mutate(calendar);
|
||||
}}
|
||||
hidePlaceholder
|
||||
calendarsQueryData={connectedCalendarsQuery.data}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
});
|
||||
export async function register() {
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_RUNTIME === "edge") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
});
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +71,12 @@ const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSR
|
||||
**/
|
||||
|
||||
const session = useSession();
|
||||
const locale =
|
||||
session?.data?.user.locale ?? typeof window !== "undefined"
|
||||
? window.document.documentElement.lang || "en"
|
||||
const fallbackLocale =
|
||||
typeof window !== "undefined" && window.document.documentElement.lang
|
||||
? window.document.documentElement.lang
|
||||
: "en";
|
||||
const newLocale = typeof window !== "undefined" && window.calNewLocale ? window.calNewLocale : null;
|
||||
const locale = session?.data?.user.locale ?? newLocale ?? fallbackLocale;
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
@@ -127,8 +129,8 @@ type CalcomThemeProps = Readonly<{
|
||||
isBookingPage: boolean;
|
||||
themeBasis: string | null;
|
||||
nonce: string | undefined;
|
||||
isThemeSupported: boolean;
|
||||
children: React.ReactNode;
|
||||
isThemeSupported?: boolean;
|
||||
}>;
|
||||
|
||||
const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
@@ -139,10 +141,10 @@ const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
const embedNamespace = searchParams ? getEmbedNamespace(searchParams) : null;
|
||||
const isEmbedMode = typeof embedNamespace === "string";
|
||||
|
||||
const themeProviderProps = getThemeProviderProps({ props, isEmbedMode, embedNamespace });
|
||||
const { key, ...themeProviderProps } = getThemeProviderProps({ props, isEmbedMode, embedNamespace });
|
||||
|
||||
return (
|
||||
<ThemeProvider {...themeProviderProps}>
|
||||
<ThemeProvider key={key} {...themeProviderProps}>
|
||||
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
|
||||
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
|
||||
{typeof window !== "undefined" && !isEmbedMode && (
|
||||
@@ -273,7 +275,7 @@ const AppProviders = (props: PageWrapperProps) => {
|
||||
<CalcomThemeProvider
|
||||
themeBasis={props.themeBasis}
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported ?? true}
|
||||
isThemeSupported={/* undefined gets treated as true */ props.isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>
|
||||
|
||||
@@ -145,10 +145,10 @@ const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
const embedNamespace = getEmbedNamespace(props.router.query);
|
||||
const isEmbedMode = typeof embedNamespace === "string";
|
||||
|
||||
const themeProviderProps = getThemeProviderProps({ props, isEmbedMode, embedNamespace });
|
||||
const { key, ...themeProviderProps } = getThemeProviderProps({ props, isEmbedMode, embedNamespace });
|
||||
|
||||
return (
|
||||
<ThemeProvider {...themeProviderProps}>
|
||||
<ThemeProvider key={key} {...themeProviderProps}>
|
||||
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
|
||||
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
|
||||
{typeof window !== "undefined" && !isEmbedMode && (
|
||||
|
||||
@@ -127,6 +127,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
durationConfig: eventData.metadata?.multipleDuration ?? [],
|
||||
booking,
|
||||
user: name,
|
||||
slug,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { ENABLE_INFINITE_EVENT_TYPES_FOR_ORG } from "@calcom/lib/constants";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
@@ -18,9 +17,5 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
};
|
||||
}
|
||||
|
||||
const isInfiniteScrollEnabled = session.user?.org?.slug
|
||||
? ENABLE_INFINITE_EVENT_TYPES_FOR_ORG.includes(session.user.org.slug)
|
||||
: false;
|
||||
|
||||
return { props: { trpcState: ssr.dehydrate(), isInfiniteScrollEnabled } };
|
||||
return { props: { trpcState: ssr.dehydrate() } };
|
||||
};
|
||||
|
||||
@@ -98,10 +98,12 @@ export default function Apps({ categories, appStore, userAdminTeams }: PageProps
|
||||
}
|
||||
|
||||
export const LayoutWrapper = (page: React.ReactElement) => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Apps Store"
|
||||
description="Create forms to direct attendees to the correct destinations."
|
||||
title={t("app_store")}
|
||||
description={t("app_store_description")}
|
||||
withoutMain={true}
|
||||
hideHeadingOnMobile>
|
||||
{page}
|
||||
|
||||
@@ -21,8 +21,8 @@ export default function Apps({ apps }: PageProps) {
|
||||
<Shell
|
||||
isPublic
|
||||
backPath="/apps"
|
||||
title="Apps Store"
|
||||
description="Connecting people, technology and the workplace."
|
||||
title={t("app_store")}
|
||||
description={t("app_store_description")}
|
||||
smallHeading
|
||||
heading={
|
||||
<>
|
||||
|
||||
@@ -15,12 +15,7 @@ export default function Apps({ categories }: PageProps) {
|
||||
const { t, isLocaleReady } = useLocale();
|
||||
|
||||
return (
|
||||
<Shell
|
||||
isPublic
|
||||
large
|
||||
hideHeadingOnMobile
|
||||
title="Apps Store"
|
||||
description="Connecting people, technology and the workplace.">
|
||||
<Shell isPublic large hideHeadingOnMobile title={t("app_store")} description={t("app_store_description")}>
|
||||
<div className="text-md flex items-center gap-1 px-4 pb-3 pt-3 font-normal md:px-8 lg:px-0 lg:pt-0">
|
||||
<Link
|
||||
href="/apps"
|
||||
|
||||
@@ -281,9 +281,7 @@ PageProps & WithNonceProps<{}>) {
|
||||
disabled={formState.isSubmitting}
|
||||
className="w-full justify-center">
|
||||
<span>{twoFactorRequired ? t("submit") : t("sign_in")}</span>
|
||||
{lastUsed === "credentials" && (
|
||||
<span className="absolute right-3 text-xs text-gray-600">{t("last_used")}</span>
|
||||
)}
|
||||
{lastUsed === "credentials" && <LastUsed className="text-gray-600" />}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -213,11 +213,11 @@ export default function AvailabilityPage({ currentOrg }: PageProps) {
|
||||
<div>
|
||||
<Shell
|
||||
heading={t("availability")}
|
||||
title="Availability"
|
||||
description="Configure times when you are available for bookings."
|
||||
subtitle={t("configure_availability")}
|
||||
title={t("availability")}
|
||||
description={t("configure_availability")}
|
||||
hideHeadingOnMobile
|
||||
withoutMain={false}
|
||||
subtitle={t("configure_availability")}
|
||||
CTA={
|
||||
<div className="flex gap-2">
|
||||
<ToggleGroup
|
||||
|
||||
@@ -151,8 +151,8 @@ export default function Bookings() {
|
||||
hideHeadingOnMobile
|
||||
heading={t("bookings")}
|
||||
subtitle={t("bookings_description")}
|
||||
title="Bookings"
|
||||
description="Create events to share for people to book on your calendar.">
|
||||
title={t("bookings")}
|
||||
description={t("bookings_description")}>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex flex-row flex-wrap justify-between">
|
||||
<HorizontalTabs tabs={tabs} />
|
||||
|
||||
@@ -161,7 +161,11 @@ export default function Success(props: PageProps) {
|
||||
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||
const [comment, setComment] = useState("");
|
||||
const parsedRating = rating ? parseInt(rating, 10) : 3;
|
||||
const currentUserEmail = searchParams?.get("cancelledBy") ?? session?.user?.email ?? undefined;
|
||||
const currentUserEmail =
|
||||
searchParams?.get("rescheduledBy") ??
|
||||
searchParams?.get("cancelledBy") ??
|
||||
session?.user?.email ??
|
||||
undefined;
|
||||
|
||||
const defaultRating = isNaN(parsedRating) ? 3 : parsedRating > 5 ? 5 : parsedRating < 1 ? 1 : parsedRating;
|
||||
const [rateValue, setRateValue] = useState<number>(defaultRating);
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function Type({
|
||||
entity,
|
||||
duration,
|
||||
hashedLink,
|
||||
durationConfig,
|
||||
}: PageProps) {
|
||||
return (
|
||||
<main className={getBookerWrapperClasses({ isEmbed: !!isEmbed })}>
|
||||
@@ -35,6 +36,7 @@ export default function Type({
|
||||
entity={entity}
|
||||
duration={duration}
|
||||
hashedLink={hashedLink}
|
||||
durationConfig={durationConfig}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,10 +53,10 @@ export default function InsightsPage() {
|
||||
<div>
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading="Insights"
|
||||
heading={t("insights")}
|
||||
subtitle={t("insights_subtitle")}
|
||||
title="Insights"
|
||||
description="View booking insights across your events.">
|
||||
title={t("insights")}
|
||||
description={t("insights_subtitle")}>
|
||||
<UpgradeTip
|
||||
plan="team"
|
||||
title={t("make_informed_decisions")}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import Shell, { MobileNavigationMoreItems } from "@calcom/features/shell/Shell";
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { MobileNavigationMoreItems } from "@calcom/features/shell/navigation/Navigation";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export default function MorePage() {
|
||||
|
||||
@@ -192,15 +192,21 @@ const AppearanceView = ({
|
||||
</div>
|
||||
<Form
|
||||
form={userAppThemeFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
handleSubmit={({ appTheme }) => {
|
||||
if (appTheme === "light" || appTheme === "dark") {
|
||||
mutation.mutate({
|
||||
appTheme,
|
||||
});
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
appTheme: values.appTheme === "" ? null : values.appTheme,
|
||||
appTheme: null,
|
||||
});
|
||||
}}>
|
||||
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={undefined}
|
||||
value="system"
|
||||
label={t("theme_system")}
|
||||
defaultChecked={user.appTheme === null}
|
||||
register={userAppThemeFormMethods.register}
|
||||
@@ -244,17 +250,21 @@ const AppearanceView = ({
|
||||
</div>
|
||||
<Form
|
||||
form={userThemeFormMethods}
|
||||
handleSubmit={(values) => {
|
||||
handleSubmit={({ theme }) => {
|
||||
if (theme === "light" || theme === "dark") {
|
||||
mutation.mutate({
|
||||
theme,
|
||||
});
|
||||
return;
|
||||
}
|
||||
mutation.mutate({
|
||||
// Radio values don't support null as values, therefore we convert an empty string
|
||||
// back to null here.
|
||||
theme: values.theme === "" ? null : values.theme,
|
||||
theme: null,
|
||||
});
|
||||
}}>
|
||||
<div className="border-subtle flex flex-col justify-between border-x px-6 py-8 sm:flex-row">
|
||||
<ThemeLabel
|
||||
variant="system"
|
||||
value={undefined}
|
||||
value="system"
|
||||
label={t("theme_system")}
|
||||
defaultChecked={user.theme === null}
|
||||
register={userThemeFormMethods.register}
|
||||
@@ -304,6 +314,7 @@ const AppearanceView = ({
|
||||
description={t("bookerlayout_user_settings_description")}
|
||||
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
|
||||
isLoading={mutation.isPending}
|
||||
user={user}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -58,7 +58,6 @@ const ConferencingView = () => {
|
||||
query={query}
|
||||
customLoader={<SkeletonLoader />}
|
||||
success={({ data }) => {
|
||||
console.log(data);
|
||||
if (!data.items.length) {
|
||||
return (
|
||||
<EmptyScreen
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import Shell from "@calcom/features/shell/Shell";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import {
|
||||
@@ -22,6 +23,7 @@ import { PlatformPricing } from "@components/settings/platform/pricing/platform-
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export default function Platform() {
|
||||
const { t } = useLocale();
|
||||
const [initialClientId, setInitialClientId] = useState("");
|
||||
const [initialClientName, setInitialClientName] = useState("");
|
||||
|
||||
@@ -75,11 +77,12 @@ export default function Platform() {
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div>
|
||||
<Shell
|
||||
heading="Platform"
|
||||
title="Platform"
|
||||
heading={t("platform")}
|
||||
subtitle={t("platform_description")}
|
||||
title={t("platform")}
|
||||
description={t("platform_description")}
|
||||
hideHeadingOnMobile
|
||||
withoutMain={false}
|
||||
subtitle="Manage everything related to platform."
|
||||
isPlatformUser={true}>
|
||||
<HelpCards />
|
||||
<OAuthClientsList oauthClients={data} isDeleting={isDeleting} handleDelete={handleDelete} />
|
||||
|
||||
@@ -42,7 +42,6 @@ export const CreateTeamEventType = () => {
|
||||
SubmitButton={SubmitButton}
|
||||
onSuccessMutation={onSuccessMutation}
|
||||
onErrorMutation={onErrorMutation}
|
||||
isInfiniteScrollEnabled={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -206,10 +206,10 @@ export default function Signup({
|
||||
}
|
||||
}, [redirectUrl]);
|
||||
|
||||
const [COOKIE_CONSENT, setCOOKIE_CONSENT] = useState(false);
|
||||
const [userConsentToCookie, setUserConsentToCookie] = useState(false); // No need to be checked for user to proceed
|
||||
|
||||
function handleConsentChange(consent: boolean) {
|
||||
setCOOKIE_CONSENT(!consent);
|
||||
setUserConsentToCookie(!consent);
|
||||
}
|
||||
|
||||
const loadingSubmitState = isSubmitSuccessful || isSubmitting;
|
||||
@@ -297,7 +297,7 @@ export default function Signup({
|
||||
|
||||
return (
|
||||
<>
|
||||
{IS_CALCOM && (!IS_EUROPE || COOKIE_CONSENT) ? (
|
||||
{IS_CALCOM && (!IS_EUROPE || userConsentToCookie) ? (
|
||||
<>
|
||||
{process.env.NEXT_PUBLIC_GTM_ID && (
|
||||
<>
|
||||
@@ -335,13 +335,14 @@ export default function Signup({
|
||||
<div className="bg-muted 2xl:border-subtle grid w-full max-w-[1440px] grid-cols-1 grid-rows-1 overflow-hidden lg:grid-cols-2 2xl:rounded-[20px] 2xl:border 2xl:py-6">
|
||||
<HeadSeo title={t("sign_up")} description={t("sign_up")} />
|
||||
{/* Left side */}
|
||||
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-12 2xl:px-28">
|
||||
<div className="ml-auto mr-auto mt-0 flex w-full max-w-xl flex-col px-4 pt-6 sm:px-16 md:px-20 lg:mt-24 2xl:px-28">
|
||||
{displayBackButton && (
|
||||
<div className="flex w-fit lg:-mt-12">
|
||||
<Button
|
||||
color="minimal"
|
||||
className="hover:bg-subtle todesktop:mt-10 mb-6 flex h-6 max-h-6 w-full items-center rounded-md px-3 py-2"
|
||||
StartIcon="arrow-left"
|
||||
data-testid="signup-back-button"
|
||||
onClick={() => {
|
||||
setDisplayEmailForm(false);
|
||||
setIsSamlSignup(false);
|
||||
@@ -432,7 +433,8 @@ export default function Signup({
|
||||
) : null}
|
||||
|
||||
<CheckboxField
|
||||
onChange={() => handleConsentChange(COOKIE_CONSENT)}
|
||||
data-testid="signup-cookie-content-checkbox"
|
||||
onChange={() => handleConsentChange(userConsentToCookie)}
|
||||
description={t("cookie_consent_checkbox")}
|
||||
/>
|
||||
{errors.apiError && (
|
||||
@@ -445,6 +447,7 @@ export default function Signup({
|
||||
)}
|
||||
{isSamlSignup ? (
|
||||
<Button
|
||||
data-testid="saml-submit-button"
|
||||
color="primary"
|
||||
disabled={
|
||||
!!formMethods.formState.errors.username ||
|
||||
@@ -457,6 +460,7 @@ export default function Signup({
|
||||
onClick={() => {
|
||||
const username = formMethods.getValues("username");
|
||||
if (!username) {
|
||||
// should not be reached but needed to bypass type errors
|
||||
showToast("error", t("username_required"));
|
||||
return;
|
||||
}
|
||||
@@ -504,7 +508,7 @@ export default function Signup({
|
||||
</div>
|
||||
)}
|
||||
{!displayEmailForm && (
|
||||
<div className="mt-24">
|
||||
<div className="mt-12">
|
||||
{/* Upper Row */}
|
||||
<div className="mt-6 flex flex-col gap-2 md:flex-row">
|
||||
{isGoogleLoginEnabled ? (
|
||||
@@ -572,6 +576,7 @@ export default function Signup({
|
||||
</Button>
|
||||
{isSAMLLoginEnabled && (
|
||||
<Button
|
||||
data-testid="continue-with-saml-button"
|
||||
color="minimal"
|
||||
disabled={isGoogleLoading}
|
||||
className={classNames("w-full justify-center rounded-md text-center")}
|
||||
@@ -595,7 +600,7 @@ export default function Signup({
|
||||
{t("sign_in")}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-subtle ">
|
||||
<div className="text-subtle">
|
||||
<Trans
|
||||
i18nKey="signing_up_terms"
|
||||
components={[
|
||||
|
||||
@@ -19,10 +19,10 @@ function Teams(props: PageProps) {
|
||||
<Shell
|
||||
withoutMain={false}
|
||||
heading={t("teams")}
|
||||
title="Teams"
|
||||
description="Create and manage teams to use collaborative features."
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("create_manage_teams_collaborative")}
|
||||
title={t("teams")}
|
||||
description={t("create_manage_teams_collaborative")}
|
||||
hideHeadingOnMobile
|
||||
CTA={
|
||||
(!user.organizationId || user.organization.isOrgAdmin) && (
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.5.7",
|
||||
"version": "4.5.10",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -136,7 +136,7 @@
|
||||
"stripe": "^9.16.0",
|
||||
"superjson": "1.9.1",
|
||||
"tailwindcss-radix": "^2.6.0",
|
||||
"turndown": "^7.1.1",
|
||||
"turndown": "^7.1.3",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
|
||||
@@ -33,7 +33,7 @@ export default function Custom404() {
|
||||
|
||||
const links = [
|
||||
{
|
||||
title: "Enterprise",
|
||||
title: t("enterprise"),
|
||||
description: "Learn more about organizations and subdomains in our enterprise plan.",
|
||||
icon: "shield" as const,
|
||||
href: `${WEBSITE_URL}/enterprise`,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { updateQuantitySubscriptionFromStripe } from "@calcom/features/ee/teams/lib/payments";
|
||||
import { TeamBilling } from "@calcom/ee/billing/teams";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -32,6 +32,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
metadata: true,
|
||||
isOrganization: true,
|
||||
parentId: true,
|
||||
},
|
||||
skip: pageNumber * pageSize,
|
||||
take: pageSize,
|
||||
@@ -41,10 +44,9 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
break;
|
||||
}
|
||||
|
||||
for (const team of teams) {
|
||||
await updateQuantitySubscriptionFromStripe(team.id);
|
||||
await delay(100); // Adjust the delay as needed to avoid rate limiting
|
||||
}
|
||||
const teamsBilling = TeamBilling.initMany(teams);
|
||||
const teamBillingPromises = teamsBilling.map((teamBilling) => teamBilling.updateQuantity());
|
||||
await Promise.allSettled(teamBillingPromises);
|
||||
|
||||
pageNumber++;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
|
||||
export { default } from "@calcom/features/ee/billing/api/webhook";
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getServerSideProps } from "@server/lib/setup/getServerSideProps";
|
||||
|
||||
import type { PageProps } from "~/auth/setup-view";
|
||||
@@ -17,6 +19,8 @@ const Page = (props: PageProps) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.PageWrapper = PageWrapper;
|
||||
export default Page;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import ProfileImpersonationViewWrapper from "~/settings/security/impersonation-view";
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("impersonation")}
|
||||
description={t("impersonation_description")}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<ProfileImpersonationViewWrapper />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = getLayout;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
export default Page;
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import PasswordViewWrapper from "~/settings/security/password-view";
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
||||
<PasswordViewWrapper />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = getLayout;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
export default Page;
|
||||
@@ -1,25 +0,0 @@
|
||||
import UserSSOView from "@calcom/features/ee/sso/page/user-sso-view";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Meta
|
||||
title={t("sso_configuration")}
|
||||
description={t("sso_configuration_description")}
|
||||
borderInShellHeader={true}
|
||||
/>
|
||||
<UserSSOView />
|
||||
</>
|
||||
);
|
||||
};
|
||||
Page.PageWrapper = PageWrapper;
|
||||
Page.getLayout = getLayout;
|
||||
|
||||
export default Page;
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Meta } from "@calcom/ui";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import TwoFactorAuthView from "~/settings/security/two-factor-auth-view";
|
||||
|
||||
const Page = () => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<>
|
||||
<Meta title={t("2fa")} description={t("set_up_two_factor_authentication")} borderInShellHeader={true} />
|
||||
<TwoFactorAuthView />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.getLayout = getLayout;
|
||||
Page.PageWrapper = PageWrapper;
|
||||
|
||||
export default Page;
|
||||
@@ -2,8 +2,69 @@ import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe("Change Theme Test", () => {
|
||||
test("change theme to dark", async ({ page, users }) => {
|
||||
test.describe("Change App Theme Test", () => {
|
||||
test("change app theme to dark", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
await page.goto("/settings/my-account/appearance");
|
||||
await page.click('[data-testid="appTheme-dark"]');
|
||||
await page.click('[data-testid="update-app-theme-btn"]');
|
||||
|
||||
const toast = await page.waitForSelector('[data-testid="toast-success"]');
|
||||
expect(toast).toBeTruthy();
|
||||
|
||||
const darkModeClass = await page.getAttribute("html", "class");
|
||||
expect(darkModeClass).toContain("dark");
|
||||
|
||||
const themeValue = await page.evaluate(() => localStorage.getItem("app-theme"));
|
||||
expect(themeValue).toBe("dark");
|
||||
});
|
||||
|
||||
test("change app theme to light", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
await page.goto("/settings/my-account/appearance");
|
||||
await page.click('[data-testid="appTheme-light"]');
|
||||
await page.click('[data-testid="update-app-theme-btn"]');
|
||||
|
||||
const toast = await page.waitForSelector('[data-testid="toast-success"]');
|
||||
expect(toast).toBeTruthy();
|
||||
|
||||
const darkModeClass = await page.getAttribute("html", "class");
|
||||
expect(darkModeClass).toContain("light");
|
||||
|
||||
const themeValue = await page.evaluate(() => localStorage.getItem("app-theme"));
|
||||
expect(themeValue).toBe("light");
|
||||
});
|
||||
|
||||
test("change app theme to system", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
await page.goto("/settings/my-account/appearance");
|
||||
await page.click('[data-testid="appTheme-light"]');
|
||||
await page.click('[data-testid="update-app-theme-btn"]');
|
||||
const toast1 = await page.waitForSelector('[data-testid="toast-success"]');
|
||||
expect(toast1).toBeTruthy();
|
||||
|
||||
await page.click('[data-testid="appTheme-system"]');
|
||||
await page.click('[data-testid="update-app-theme-btn"]');
|
||||
const toast2 = await page.waitForSelector('[data-testid="toast-success"]');
|
||||
expect(toast2).toBeTruthy();
|
||||
|
||||
await page.waitForTimeout(3000);
|
||||
const themeValue = await page.evaluate(() => localStorage.getItem("app-theme"));
|
||||
expect(themeValue).toBe("system");
|
||||
|
||||
const systemTheme = await page.evaluate(() => {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
});
|
||||
expect(await page.getAttribute("html", "class")).toContain(systemTheme);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Change Booking Page Theme Test", () => {
|
||||
test("change booking page theme to dark", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
@@ -18,11 +79,11 @@ test.describe("Change Theme Test", () => {
|
||||
expect(toast).toBeTruthy();
|
||||
//Go to the profile page and check if the theme is dark
|
||||
await page.goto(`/${pro.username}`);
|
||||
const darkModeClass = await page.getAttribute("html", "class");
|
||||
expect(darkModeClass).toContain("dark");
|
||||
const htmlClass = await page.getAttribute("html", "class");
|
||||
expect(htmlClass).toContain("dark");
|
||||
});
|
||||
|
||||
test("change theme to light", async ({ page, users }) => {
|
||||
test("change booking page theme to light", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
@@ -37,7 +98,31 @@ test.describe("Change Theme Test", () => {
|
||||
expect(toast).toBeTruthy();
|
||||
//Go to the profile page and check if the theme is light
|
||||
await page.goto(`/${pro.username}`);
|
||||
const darkModeClass = await page.getAttribute("html", "class");
|
||||
expect(darkModeClass).toContain("light");
|
||||
const htmlClass = await page.getAttribute("html", "class");
|
||||
expect(htmlClass).toContain("light");
|
||||
});
|
||||
|
||||
test("change booking page theme to system", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
await page.goto("/settings/my-account/appearance");
|
||||
|
||||
await page.click('[data-testid="theme-light"]');
|
||||
await page.click('[data-testid="update-theme-btn"]');
|
||||
const toast1 = await page.waitForSelector('[data-testid="toast-success"]');
|
||||
expect(toast1).toBeTruthy();
|
||||
|
||||
await page.click('[data-testid="theme-system"]');
|
||||
await page.click('[data-testid="update-theme-btn"]');
|
||||
const toast2 = await page.waitForSelector('[data-testid="toast-success"]');
|
||||
expect(toast2).toBeTruthy();
|
||||
await page.goto(`/${pro.username}`);
|
||||
|
||||
const systemTheme = await page.evaluate(() => {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
});
|
||||
|
||||
expect(await page.getAttribute("html", "class")).toContain(systemTheme);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -292,6 +292,12 @@ export const createUsersFixture = (
|
||||
{ title: "Paid", slug: "paid", length: 30, price: 1000 },
|
||||
{ title: "Opt in", slug: "opt-in", requiresConfirmation: true, length: 30 },
|
||||
{ title: "Seated", slug: "seated", seatsPerTimeSlot: 2, length: 30 },
|
||||
{
|
||||
title: "Multiple duration",
|
||||
slug: "multiple-duration",
|
||||
length: 30,
|
||||
metadata: { multipleDuration: [30, 60, 90] },
|
||||
},
|
||||
];
|
||||
|
||||
if (opts?.eventTypes) defaultEventTypes = defaultEventTypes.concat(opts.eventTypes);
|
||||
@@ -573,8 +579,6 @@ export const createUsersFixture = (
|
||||
await updateChildrenEventTypes({
|
||||
eventTypeId: teamEvent.id,
|
||||
currentUserId: user.id,
|
||||
hashedLink: "",
|
||||
connectedLink: null,
|
||||
oldEventType: {
|
||||
team: null,
|
||||
},
|
||||
|
||||
@@ -26,12 +26,12 @@ test.describe("hash my url", () => {
|
||||
// We wait for the page to load
|
||||
await page.locator(".primary-navigation >> text=Advanced").click();
|
||||
// ignore if it is already checked, and click if unchecked
|
||||
const hashedLinkCheck = await page.locator('[data-testid="hashedLinkCheck"]');
|
||||
const hashedLinkCheck = await page.locator('[data-testid="multiplePrivateLinksCheck"]');
|
||||
|
||||
await hashedLinkCheck.click();
|
||||
|
||||
// we wait for the hashedLink setting to load
|
||||
const $url = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||
const $url = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
|
||||
|
||||
// click update
|
||||
await page.locator('[data-testid="update-eventtype"]').press("Enter");
|
||||
@@ -50,8 +50,12 @@ test.describe("hash my url", () => {
|
||||
await page.locator("ul[data-testid=event-types] > li a").first().click();
|
||||
// We wait for the page to load
|
||||
await page.locator(".primary-navigation >> text=Advanced").click();
|
||||
|
||||
const hashedLinkCheck2 = await page.locator('[data-testid="multiplePrivateLinksCheck"]');
|
||||
await hashedLinkCheck2.click();
|
||||
|
||||
// we wait for the hashedLink setting to load
|
||||
const $newUrl = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||
const $newUrl = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
|
||||
expect($url !== $newUrl).toBeTruthy();
|
||||
|
||||
// Ensure that private URL is enabled after modifying the event type.
|
||||
@@ -63,7 +67,7 @@ test.describe("hash my url", () => {
|
||||
action: () => page.locator("[data-testid=update-eventtype]").click(),
|
||||
});
|
||||
await page.locator(".primary-navigation >> text=Advanced").click();
|
||||
const $url2 = await page.locator('//*[@data-testid="generated-hash-url"]').inputValue();
|
||||
const $url2 = await page.locator('//*[@data-testid="generated-hash-url-0"]').inputValue();
|
||||
expect($url2.includes("somethingrandom")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,8 +233,8 @@ test.describe("Stripe integration skip true", () => {
|
||||
|
||||
// Select currency in dropdown
|
||||
await page.getByTestId("stripe-currency-select").click();
|
||||
await page.locator("#react-select-2-input").fill("mexi");
|
||||
await page.locator("#react-select-2-option-81").click();
|
||||
await page.getByTestId("stripe-currency-input").fill("mexi");
|
||||
await page.getByTestId("select-option-mxn").click();
|
||||
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
|
||||
@@ -467,8 +467,8 @@ test.describe("Stripe integration with the new app install flow skip false", ()
|
||||
|
||||
// Select currency in dropdown
|
||||
await page.getByTestId("stripe-currency-select").click();
|
||||
await page.locator("#react-select-2-input").fill("mexi");
|
||||
await page.locator("#react-select-2-option-81").click();
|
||||
await page.getByTestId("stripe-currency-input").fill("mexi");
|
||||
await page.getByTestId("select-option-mxn").click();
|
||||
|
||||
await page.click(`[data-testid="configure-step-save"]`);
|
||||
await page.waitForURL(`event-types`);
|
||||
|
||||
@@ -437,7 +437,7 @@ test.describe("authorized user sees changed translations (de->ar)", async () =>
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
|
||||
await page.locator(".bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.locator("#react-select-2-option-0").click();
|
||||
await page.getByTestId("select-option-ar").click();
|
||||
|
||||
await page.getByRole("button", { name: "Aktualisieren" }).click();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user