Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
+1
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,9 @@ 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: 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,191 @@
|
||||
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);
|
||||
|
||||
@Injectable()
|
||||
export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
private logger = new Logger("CustomThrottlerGuard");
|
||||
|
||||
private defaultTttl = Number(getEnv("RATE_LIMIT_DEFAULT_TTL_MS", 60 * 1000));
|
||||
private defaultLimit = Number(getEnv("RATE_LIMIT_DEFAULT_LIMIT", 120));
|
||||
private defaultBlockDuration = Number(getEnv("RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS", 60 * 1000));
|
||||
|
||||
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);
|
||||
|
||||
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();
|
||||
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (isBlocked) {
|
||||
throw new ThrottlerException("Too many requests. Please try again later.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private getDefaultRateLimit() {
|
||||
return {
|
||||
name: "default",
|
||||
limit: this.getDefaultLimit(),
|
||||
ttl: this.getDefaultTtl(),
|
||||
blockDuration: this.getDefaultBlockDuration(),
|
||||
};
|
||||
}
|
||||
|
||||
getDefaultLimit() {
|
||||
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) {
|
||||
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 || rateLimits.length === 0) {
|
||||
rateLimits = [this.getDefaultRateLimit()];
|
||||
}
|
||||
|
||||
await this.storageService.redis.setex(cacheKey, 3600, JSON.stringify(rateLimits));
|
||||
|
||||
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(`Rate limit for ${tracker} incremented. Remaining: ${limit - totalHits}`);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,101 @@
|
||||
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 } 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)
|
||||
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)
|
||||
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)
|
||||
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) {
|
||||
|
||||
@@ -3248,6 +3248,7 @@
|
||||
"/v2/calendars/ics-feed/save": {
|
||||
"post": {
|
||||
"operationId": "CalendarsController_createIcsFeed",
|
||||
"summary": "Save an ICS feed",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
@@ -3279,6 +3280,7 @@
|
||||
"/v2/calendars/ics-feed/check": {
|
||||
"get": {
|
||||
"operationId": "CalendarsController_checkIcsFeed",
|
||||
"summary": "Check an ICS feed",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -4106,6 +4108,147 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/oauth/{clientId}/authorize": {
|
||||
"post": {
|
||||
"operationId": "OAuthFlowController_authorize",
|
||||
"summary": "Authorize an OAuth client",
|
||||
"description": "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OAuthAuthorizeInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`"
|
||||
},
|
||||
"400": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"oauth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/oauth/{clientId}/exchange": {
|
||||
"post": {
|
||||
"operationId": "OAuthFlowController_exchange",
|
||||
"summary": "Exchange authorization code for access tokens",
|
||||
"description": "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "clientId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExchangeAuthorizationCodeInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successfully exchanged authorization code for access and refresh tokens.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/KeysResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match."
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"oauth"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/oauth/{clientId}/refresh": {
|
||||
"post": {
|
||||
"operationId": "OAuthFlowController_refreshAccessToken",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-cal-secret-key",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"description": "OAuth client secret key.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RefreshTokenInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/KeysResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"oauth",
|
||||
"Managed users"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/oauth-clients": {
|
||||
"post": {
|
||||
"operationId": "OAuthClientsController_createOAuthClient",
|
||||
@@ -4304,150 +4447,11 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/oauth/{clientId}/authorize": {
|
||||
"post": {
|
||||
"operationId": "OAuthFlowController_authorize",
|
||||
"summary": "Authorize an OAuth client",
|
||||
"description": "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/OAuthAuthorizeInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`"
|
||||
},
|
||||
"400": {
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"OAuth - development only"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/oauth/{clientId}/exchange": {
|
||||
"post": {
|
||||
"operationId": "OAuthFlowController_exchange",
|
||||
"summary": "Exchange authorization code for access tokens",
|
||||
"description": "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "clientId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExchangeAuthorizationCodeInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successfully exchanged authorization code for access and refresh tokens.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/KeysResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match."
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"OAuth - development only"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/oauth/{clientId}/refresh": {
|
||||
"post": {
|
||||
"operationId": "OAuthFlowController_refreshAccessToken",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "clientId",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "x-cal-secret-key",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RefreshTokenInput"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/KeysResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"OAuth - development only"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/schedules": {
|
||||
"post": {
|
||||
"operationId": "SchedulesController_2024_06_11_createSchedule",
|
||||
"summary": "Create a schedule",
|
||||
"description": "\n The point of creating schedules is for event types to be available at specific times.\n\n First goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.\n 1. If you passed `timeZone` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. Managed user can then change the default schedule via `AvailabilitySettings` atom.\n 2. If you did not, then we assume you want that user has specific schedule right away. You should create default schedule by specifying\n `\"isDefault\": true` in the request body. Until the user has a default schedule that user can't be booked or manage his / her schedule via the AvailabilitySettings atom.\n\n Second goal is to create other schedules that event types can point to, so that when that event is booked availability is not checked against the default schedule but against that specific schedule.\n After creating a non default schedule you can update event type to point to that schedule via the PATCH `event-types/{eventTypeId}` endpoint.\n\n When specifying start time and end time for each day use 24 hour format e.g. 08:00, 15:00 etc.\n ",
|
||||
"description": "\n Create a schedule for the authenticated user.\n\n The point of creating schedules is for event types to be available at specific times.\n\n The first goal of schedules is to have a default schedule. If you are platform customer and created managed users, then it is important to note that each managed user should have a default schedule.\n 1. If you passed `timeZone` when creating managed user, then the default schedule from Monday to Friday from 9AM to 5PM will be created with that timezone. The managed user can then change the default schedule via the `AvailabilitySettings` atom.\n 2. If you did not, then we assume you want the user to have this specific schedule right away. You should create a default schedule by specifying\n `\"isDefault\": true` in the request body. Until the user has a default schedule the user can't be booked nor manage their schedule via the AvailabilitySettings atom.\n\n The second goal of schedules is to create another schedule that event types can point to. This is useful for when an event is booked because availability is not checked against the default schedule but instead against that specific schedule.\n After creating a non-default schedule, you can update an event type to point to that schedule via the PATCH `event-types/{eventTypeId}` endpoint.\n\n When specifying start time and end time for each day use the 24 hour format e.g. 08:00, 15:00 etc.\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
@@ -4496,7 +4500,8 @@
|
||||
},
|
||||
"get": {
|
||||
"operationId": "SchedulesController_2024_06_11_getSchedules",
|
||||
"summary": "Get all schedules (of the authenticated user)",
|
||||
"summary": "Get all schedules",
|
||||
"description": "Get all schedules of the authenticated user.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
@@ -4537,7 +4542,8 @@
|
||||
"/v2/schedules/default": {
|
||||
"get": {
|
||||
"operationId": "SchedulesController_2024_06_11_getDefaultSchedule",
|
||||
"summary": "Get default schedule (of the authenticated user)",
|
||||
"summary": "Get default schedule",
|
||||
"description": "Get the default schedule of the authenticated user.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
@@ -4560,7 +4566,7 @@
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Returns the default schedule of the authenticated user",
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -4822,11 +4828,26 @@
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "",
|
||||
"description": "Successful response returning uid of reserved slot.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success"
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"uid": {
|
||||
"type": "string",
|
||||
"example": "e2a7bcf9-cc7b-40a0-80d3-657d391775a6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4841,14 +4862,31 @@
|
||||
"delete": {
|
||||
"operationId": "SlotsController_deleteSelectedSlot",
|
||||
"summary": "Delete a selected slot",
|
||||
"parameters": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uid",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Unique identifier for the slot to be removed.",
|
||||
"example": "e2a7bcf9-cc7b-40a0-80d3-657d391775a6",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"description": "Response deleting reserved slot by uid.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4863,14 +4901,128 @@
|
||||
"get": {
|
||||
"operationId": "SlotsController_getAvailableSlots",
|
||||
"summary": "Get available slots",
|
||||
"parameters": [],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "startTime",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Start date string starting from which to fetch slots in UTC timezone.",
|
||||
"example": "2022-06-14T00:00:00.000Z",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endTime",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "End date string until which to fetch slots in UTC timezone.",
|
||||
"example": "2022-06-14T23:59:59.999Z",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventTypeId",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Event Type ID for which slots are being fetched.",
|
||||
"example": 100,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "eventTypeSlug",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Slug of the event type for which slots are being fetched.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "usernameList",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Only for dynamic events - list of usernames for which slots are being fetched.",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "duration",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Only for dynamic events - length of returned slots.",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"description": "Available time slots retrieved successfully",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4881,6 +5033,95 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/stripe/connect": {
|
||||
"get": {
|
||||
"operationId": "StripeController_redirect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "Authorization",
|
||||
"required": true,
|
||||
"in": "header",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripConnectOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Stripe"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/stripe/save": {
|
||||
"get": {
|
||||
"operationId": "StripeController_save",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "state",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripCredentialsSaveOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Stripe"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/stripe/check": {
|
||||
"get": {
|
||||
"operationId": "StripeController_check",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/StripCredentialsCheckOutputResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Stripe"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/v2/timezones": {
|
||||
"get": {
|
||||
"operationId": "TimezonesController_getTimeZones",
|
||||
@@ -5689,7 +5930,8 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"refreshToken": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"description": "Managed user's refresh token."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7504,9 +7746,6 @@
|
||||
"hideCalendarNotes": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideCalendarEventDetails": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"color": {
|
||||
"$ref": "#/components/schemas/EventTypeColor_2024_06_14"
|
||||
},
|
||||
@@ -7568,7 +7807,6 @@
|
||||
"confirmationPolicy",
|
||||
"requiresBookerEmailVerification",
|
||||
"hideCalendarNotes",
|
||||
"hideCalendarEventDetails",
|
||||
"color",
|
||||
"seats",
|
||||
"offsetStart",
|
||||
@@ -9513,9 +9751,6 @@
|
||||
"hideCalendarNotes": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideCalendarEventDetails": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"color": {
|
||||
"$ref": "#/components/schemas/EventTypeColor_2024_06_14"
|
||||
},
|
||||
@@ -9535,6 +9770,9 @@
|
||||
"useDestinationCalendarEmail": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hideCalendarEventDetails": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"teamId": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
@@ -10747,6 +10985,60 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"StripConnectOutputDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"authUrl": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"authUrl"
|
||||
]
|
||||
},
|
||||
"StripConnectOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": [
|
||||
"success",
|
||||
"error"
|
||||
]
|
||||
},
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/StripConnectOutputDto"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"data"
|
||||
]
|
||||
},
|
||||
"StripCredentialsSaveOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"StripCredentialsCheckOutputResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"GetDefaultScheduleOutput_2024_06_11": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -12140,7 +12432,32 @@
|
||||
},
|
||||
"ReserveSlotInput": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
"properties": {
|
||||
"eventTypeId": {
|
||||
"type": "number",
|
||||
"description": "Event Type ID for which timeslot is being reserved.",
|
||||
"example": 100
|
||||
},
|
||||
"slotUtcStartDate": {
|
||||
"type": "string",
|
||||
"description": "Start date of the slot in UTC timezone.",
|
||||
"example": "2022-06-14T00:00:00.000Z"
|
||||
},
|
||||
"slotUtcEndDate": {
|
||||
"type": "string",
|
||||
"description": "End date of the slot in UTC timezone.",
|
||||
"example": "2022-06-14T00:30:00.000Z"
|
||||
},
|
||||
"bookingUid": {
|
||||
"type": "string",
|
||||
"description": "Optional but only for events with seats. Used to retrieve booking of a seated event."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"eventTypeId",
|
||||
"slotUtcStartDate",
|
||||
"slotUtcEndDate"
|
||||
]
|
||||
},
|
||||
"UserWebhookOutputDto": {
|
||||
"type": "object",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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">;
|
||||
@@ -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) => {
|
||||
@@ -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>
|
||||
|
||||
@@ -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() } };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -304,6 +304,7 @@ const AppearanceView = ({
|
||||
description={t("bookerlayout_user_settings_description")}
|
||||
isDisabled={isBookerLayoutFormSubmitting || !isBookerLayoutFormDirty}
|
||||
isLoading={mutation.isPending}
|
||||
user={user}
|
||||
/>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -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={[
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "4.5.7",
|
||||
"version": "4.5.9",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
@@ -1,8 +1,9 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { testBothFutureAndLegacyRoutes } from "playwright/lib/future-legacy-routes";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe("Change Theme Test", () => {
|
||||
testBothFutureAndLegacyRoutes.describe("Change Theme Test", () => {
|
||||
test("change theme to dark", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -249,6 +249,7 @@ test.describe("Manage Booking Questions", () => {
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -256,7 +257,10 @@ test.describe("Manage Booking Questions", () => {
|
||||
const webhookReceiver = await addWebhook(undefined, teamId);
|
||||
|
||||
await test.step("Go to First Team Event", async () => {
|
||||
const $eventTypes = page.locator("[data-testid=event-types]").nth(1).locator("li a");
|
||||
await page.getByTestId(`horizontal-tab-${team?.name}`).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const $eventTypes = page.locator("[data-testid=event-types]").locator("li a");
|
||||
const firstEventTypeElement = $eventTypes.first();
|
||||
|
||||
await firstEventTypeElement.click();
|
||||
|
||||
@@ -38,7 +38,7 @@ async function setupManagedEvent({
|
||||
const memberUser = users.get().find((u) => u.name === teamMateName)!;
|
||||
const { team } = await adminUser.getFirstTeamMembership();
|
||||
const managedEvent = await adminUser.getFirstTeamEvent(team.id, SchedulingType.MANAGED);
|
||||
return { adminUser, memberUser, managedEvent, teamMateName, teamEventTitle };
|
||||
return { adminUser, memberUser, managedEvent, teamMateName, teamEventTitle, teamId: team.id };
|
||||
}
|
||||
|
||||
/** Short hand to get elements by translation key */
|
||||
@@ -129,7 +129,9 @@ test.describe("Managed Event Types", () => {
|
||||
test("Provides discrete field lock/unlock state for admin", async ({ page, users }) => {
|
||||
const { adminUser, teamEventTitle } = await setupManagedEvent({ users });
|
||||
await adminUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const teamMembership = await adminUser.getFirstTeamMembership();
|
||||
|
||||
await page.goto(`/event-types?teamId=${teamMembership.team.id}`);
|
||||
|
||||
await page.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click();
|
||||
await page.waitForURL("event-types/**");
|
||||
@@ -169,7 +171,7 @@ test.describe("Managed Event Types", () => {
|
||||
users,
|
||||
browser,
|
||||
}) => {
|
||||
const { adminUser, memberUser, teamEventTitle } = await setupManagedEvent({
|
||||
const { adminUser, memberUser, teamEventTitle, teamId } = await setupManagedEvent({
|
||||
users,
|
||||
unlockedFields: {
|
||||
title: true,
|
||||
@@ -185,7 +187,7 @@ test.describe("Managed Event Types", () => {
|
||||
|
||||
// We edit the managed event as original owner
|
||||
const [adminContext, adminPage] = await adminUser.apiLoginOnNewBrowser(browser);
|
||||
await adminPage.goto("/event-types");
|
||||
await adminPage.goto(`/event-types?teamId=${teamId}`);
|
||||
await adminPage.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click();
|
||||
await adminPage.waitForURL("event-types/**");
|
||||
await adminPage.locator('input[name="length"]').fill(`45`);
|
||||
|
||||
@@ -47,19 +47,28 @@ test.describe("user1NotMemberOfOrg1 is part of team1MemberOfOrg1", () => {
|
||||
|
||||
await user1NotMemberOfOrg1.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const userEventLinksSelector = `[data-testid=slug-${user1NotMemberOfOrg1.username}] [data-testid="preview-link-button"]`;
|
||||
await page.waitForSelector(userEventLinksSelector);
|
||||
|
||||
await page.waitForSelector(`[data-testid="event-types"] [data-testid="preview-link-button"]`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
const userEventLinksLocators = await page
|
||||
.locator(`[data-testid="event-types"] [data-testid="preview-link-button"]`)
|
||||
.all();
|
||||
|
||||
// Get all the event links
|
||||
const userEventLinksLocators = await page.locator(userEventLinksSelector).all();
|
||||
expect(userEventLinksLocators.length).toBeGreaterThan(0);
|
||||
for (const userEventLinkLocator of userEventLinksLocators) {
|
||||
const href = await userEventLinkLocator.getAttribute("href");
|
||||
expect(href).toContain(WEBAPP_URL);
|
||||
}
|
||||
|
||||
await page.goto(`/event-types?teamId=${team1MemberOfOrg1.id}`);
|
||||
|
||||
await page.waitForSelector(`[data-testid="event-types"] [data-testid="preview-link-button"]`, {
|
||||
timeout: 5000,
|
||||
});
|
||||
const teamEventLinksLocators = await page
|
||||
.locator(`[data-testid=slug-${team1MemberOfOrg1.slug}] [data-testid="preview-link-button"]`)
|
||||
.locator(`[data-testid="event-types"] [data-testid="preview-link-button"]`)
|
||||
.all();
|
||||
|
||||
expect(teamEventLinksLocators.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -147,9 +147,11 @@ test.describe("Unpublished Organization Redirection", () => {
|
||||
},
|
||||
data: {
|
||||
hashedLink: {
|
||||
create: {
|
||||
link: generateHashedLink(eventType.id),
|
||||
},
|
||||
create: [
|
||||
{
|
||||
link: generateHashedLink(eventType.id),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@@ -158,7 +160,7 @@ test.describe("Unpublished Organization Redirection", () => {
|
||||
});
|
||||
|
||||
await doOnOrgDomain({ page, orgSlug }, async () => {
|
||||
await page.goto(`/d/${privateEvent.hashedLink?.link}/${privateEvent.slug}`);
|
||||
await page.goto(`/d/${privateEvent.hashedLink[0]?.link}/${privateEvent.slug}`);
|
||||
|
||||
// Expect the empty screen, indicating the event is inaccessible.
|
||||
await expect(page.getByTestId("empty-screen")).toBeVisible();
|
||||
@@ -180,9 +182,11 @@ test.describe("Unpublished Organization Redirection", () => {
|
||||
},
|
||||
data: {
|
||||
hashedLink: {
|
||||
create: {
|
||||
link: generateHashedLink(eventType.id),
|
||||
},
|
||||
create: [
|
||||
{
|
||||
link: generateHashedLink(eventType.id),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@@ -191,7 +195,7 @@ test.describe("Unpublished Organization Redirection", () => {
|
||||
});
|
||||
|
||||
await doOnOrgDomain({ page, orgSlug }, async () => {
|
||||
await page.goto(`/d/${privateEvent.hashedLink?.link}/${privateEvent.slug}?orgRedirection=true`);
|
||||
await page.goto(`/d/${privateEvent.hashedLink[0]?.link}/${privateEvent.slug}?orgRedirection=true`);
|
||||
|
||||
// Verify that the event page is visible.
|
||||
await expect(page.getByTestId("event-title")).toBeVisible();
|
||||
|
||||
@@ -1,17 +1,81 @@
|
||||
import { IS_SAML_LOGIN_ENABLED } from "../server/lib/constants";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { IS_PREMIUM_USERNAME_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import { login } from "./fixtures/users";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("SAML tests", () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!isSAMLLoginEnabled, "Skipping due to SAML login being disabled");
|
||||
|
||||
test("test SAML configuration UI with pro@example.com", async ({ page }) => {
|
||||
// TODO: Figure out a way to use the users from fixtures here, right now we cannot set
|
||||
// the SAML_ADMINS env variables dynamically
|
||||
await login({ username: "pro", email: "pro@example.com", password: "pro" }, page);
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_SAML_LOGIN_ENABLED, "It should only run if SAML is enabled");
|
||||
// Try to go Security page
|
||||
await page.goto("/settings/security/sso");
|
||||
// It should redirect you to the event-types page
|
||||
// await page.waitForSelector("[data-testid=saml_config]");
|
||||
});
|
||||
|
||||
test.describe("SAML Signup Flow Test", async () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible(); // this prevents flaky test
|
||||
await page.getByTestId("continue-with-saml-button").click();
|
||||
await page.waitForSelector('[data-testid="saml-submit-button"]');
|
||||
});
|
||||
|
||||
test("Submit button should be disabled without username", async ({ page }) => {
|
||||
await page.locator('input[name="email"]').fill("tester123@example.com");
|
||||
const submitButton = page.getByTestId("saml-submit-button");
|
||||
await expect(submitButton).toBeVisible();
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Submit button should be disabled without email", async ({ page }) => {
|
||||
await page.locator('input[name="username"]').fill("tester123");
|
||||
const submitButton = page.getByTestId("saml-submit-button");
|
||||
await expect(submitButton).toBeVisible();
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("Password input should not exist", async ({ page }) => {
|
||||
await expect(page.locator('input[name="password"]')).toBeHidden();
|
||||
});
|
||||
|
||||
test("Checkbox for cookie consent does not need to be checked", async ({ page }) => {
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill("tester123");
|
||||
await page.locator('input[name="email"]').fill("tester123@example.com");
|
||||
|
||||
const submitButton = page.getByTestId("saml-submit-button");
|
||||
const checkbox = page.getByTestId("signup-cookie-content-checkbox");
|
||||
|
||||
await checkbox.check();
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
// the cookie consent checkbox does not need to be checked for user to proceed
|
||||
await checkbox.uncheck();
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("Submit button should be disabled with a premium username", async ({ page }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_PREMIUM_USERNAME_ENABLED, "Only run on Cal.com");
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill("pro");
|
||||
await page.locator('input[name="email"]').fill("pro@example.com");
|
||||
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("saml-submit-button");
|
||||
await expect(submitButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
@@ -10,23 +11,38 @@ import { expectInvitationEmailToBeReceived } from "./team/expects";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
const preventFlakyTest = async (page: Page) => {
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
};
|
||||
test.describe("Signup Main Page Test", async () => {
|
||||
test.beforeEach(async ({ features }) => {
|
||||
features.reset();
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
await preventFlakyTest(page);
|
||||
});
|
||||
|
||||
test("Continue with email button must exist / work", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
const button = page.getByTestId("continue-with-email-button");
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(page.getByTestId("signup-back-button")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Continue with google button must exist / work", async ({ page }) => {
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator('[data-testid="continue-with-google-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-google-button"]').click();
|
||||
const button = page.getByTestId("continue-with-google-button");
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await page.waitForURL("/auth/sso/google");
|
||||
});
|
||||
|
||||
test("Continue with SAML button must exist / work", async ({ page }) => {
|
||||
const button = page.getByTestId("continue-with-saml-button");
|
||||
await expect(button).toBeVisible();
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(page.getByTestId("signup-back-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Email Signup Flow Test", async () => {
|
||||
@@ -44,10 +60,10 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
const alertMessage = "Username or email is already taken";
|
||||
|
||||
@@ -57,7 +73,8 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
await page.locator('input[name="password"]').fill("Password99!");
|
||||
|
||||
// Submit form
|
||||
await page.click('button[type="submit"]');
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
const alert = await page.waitForSelector('[data-testid="alert"]');
|
||||
const alertMessageInner = await alert.innerText();
|
||||
@@ -74,10 +91,10 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
const alertMessage = "Username or email is already taken";
|
||||
|
||||
@@ -87,8 +104,9 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
await page.locator('input[name="password"]').fill("Password99!");
|
||||
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
const alert = await page.waitForSelector('[data-testid="alert"]');
|
||||
const alertMessageInner = await alert.innerText();
|
||||
|
||||
@@ -108,17 +126,19 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
|
||||
// Signup with premium username name
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill("rock");
|
||||
await page.locator('input[name="email"]').fill(userToCreate.email);
|
||||
await page.locator('input[name="password"]').fill(userToCreate.password);
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
@@ -130,7 +150,7 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
expect(url).toContain(expectedUrl);
|
||||
// TODO: complete the stripe checkout flow
|
||||
});
|
||||
test("Signup with valid (non premium) username", async ({ page, users, features }) => {
|
||||
test("Signup with valid (non premium) username", async ({ page, users }) => {
|
||||
const userToCreate = users.buildForSignup({
|
||||
username: "rick-jones",
|
||||
password: "Password99!",
|
||||
@@ -139,17 +159,20 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill(userToCreate.username);
|
||||
await page.locator('input[name="email"]').fill(userToCreate.email);
|
||||
await page.locator('input[name="password"]').fill(userToCreate.password);
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
await page.waitForURL("/auth/verify-email**");
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
@@ -161,10 +184,11 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
test("Signup fields prefilled with query params", async ({ page, users }) => {
|
||||
const signupUrlWithParams = "/signup?username=rick-jones&email=rick-jones%40example.com";
|
||||
await page.goto(signupUrlWithParams);
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
|
||||
|
||||
// Fill form
|
||||
const usernameInput = page.locator('input[name="username"]');
|
||||
@@ -214,8 +238,8 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
|
||||
const signupUrlWithToken = `/signup?token=${token}`;
|
||||
await page.goto(signupUrlWithToken);
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
|
||||
|
||||
const usernameField = page.locator('input[name="username"]');
|
||||
const emailField = page.locator('input[name="email"]');
|
||||
@@ -246,18 +270,18 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
});
|
||||
|
||||
await page.goto("/signup");
|
||||
await expect(page.locator("text=Create your account")).toBeVisible();
|
||||
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
await preventFlakyTest(page);
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill(userToCreate.username);
|
||||
await page.locator('input[name="email"]').fill(userToCreate.email);
|
||||
await page.locator('input[name="password"]').fill(userToCreate.password);
|
||||
|
||||
const submitButton = page.locator('button[type="submit"]');
|
||||
await expect(submitButton).toBeEnabled();
|
||||
// Submit form
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
await submitButton.click();
|
||||
|
||||
await page.waitForURL((url) => url.pathname.includes("/auth/verify-email"));
|
||||
@@ -320,9 +344,10 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
|
||||
const url = new URL(newPage.url());
|
||||
expect(url.pathname).toBe("/signup");
|
||||
await expect(page.locator('[data-testid="continue-with-email-button"]')).toBeVisible();
|
||||
await page.locator('[data-testid="continue-with-email-button"]').click();
|
||||
await expect(page.locator('[data-testid="signup-submit-button"]')).toBeVisible();
|
||||
const continueWithEmailButton = page.getByTestId("continue-with-email-button");
|
||||
await expect(continueWithEmailButton).toBeVisible();
|
||||
await continueWithEmailButton.click();
|
||||
await expect(page.getByTestId("signup-submit-button")).toBeVisible();
|
||||
// Check required fields
|
||||
await newPage.locator("input[name=password]").fill(`P4ssw0rd!`);
|
||||
await newPage.locator("button[type=submit]").click();
|
||||
@@ -331,4 +356,27 @@ test.describe("Email Signup Flow Test", async () => {
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
test("Checkbox for cookie consent does not need to be checked", async ({ page, users }) => {
|
||||
await page.goto("/signup");
|
||||
await preventFlakyTest(page);
|
||||
|
||||
// Navigate to email form
|
||||
await page.getByTestId("continue-with-email-button").click();
|
||||
|
||||
// Fill form
|
||||
await page.locator('input[name="username"]').fill("pro");
|
||||
await page.locator('input[name="email"]').fill("pro@example.com");
|
||||
await page.locator('input[name="password"]').fill("Password99!");
|
||||
|
||||
const submitButton = page.getByTestId("signup-submit-button");
|
||||
const checkbox = page.getByTestId("signup-cookie-content-checkbox");
|
||||
|
||||
await checkbox.check();
|
||||
await expect(submitButton).toBeEnabled();
|
||||
|
||||
// the cookie consent checkbox does not need to be checked for user to proceed
|
||||
await checkbox.uncheck();
|
||||
await expect(submitButton).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -263,7 +263,7 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => {
|
||||
id: teamEventId,
|
||||
} = await owner.getFirstTeamEvent(team.id);
|
||||
|
||||
await page.goto("/event-types");
|
||||
await page.goto(`/event-types?teamId=${team.id}`);
|
||||
|
||||
await page.getByTestId(`event-type-options-${teamEventId}`).first().click();
|
||||
await page.getByTestId("embed").click();
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "نسخ الرابط الخاص",
|
||||
"copy_private_link_to_event": "نسخ الرابط الخاص للحدث",
|
||||
"private_link_description": "إنشاء عنوان URL خاص لمشاركته دون الكشف عن اسم المستخدم الخاص بك في {{appName}}",
|
||||
"multiple_private_links_title": "روابط خاصة",
|
||||
"multiple_private_links_description": "إنشاء روابط خاصة دون الكشف عن اسم المستخدم، والتي سيتم تدميرها بمجرد استخدامها",
|
||||
"add_a_multiple_private_link": "إضافة رابط جديد",
|
||||
"multiple_private_link_copied": "تم نسخ الرابط الخاص",
|
||||
"invitees_can_schedule": "يمكن للمدعوين الجدولة",
|
||||
"date_range": "الفترة الزمنية",
|
||||
"calendar_days": "أيام التقويم",
|
||||
"business_days": "أيام العمل",
|
||||
"set_address_place": "تعيين عنوان أو مكان",
|
||||
"set_link_meeting": "تعيين رابطًا للاجتماع",
|
||||
"managed_event_field_parent_control_disabled": "لا يمكن تبديله. يمكن فتحه فقط لأنواع الأحداث الفرعية",
|
||||
"cal_invitee_phone_number_scheduling": "سيطلب {{appName}} من المدعو لديك إدخال رقم الهاتف قبل الجدولة.",
|
||||
"cal_provide_google_meet_location": "سيوفر {{appName}} رابطًا لـ Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "سيوفر {{appName}} رابطًا للاجتماع عبر Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "تحصيل رسوم عدم الحضور",
|
||||
"no_show_fee_charged": "تم تحصيل رسوم عدم حضور",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "نماذج التوجيه",
|
||||
"testing_workflow_info_message": "عند اختبار سير العمل هذا، يجب أن تدرك أنه لا يمكن جدولة رسائل البريد الإلكتروني والرسائل القصيرة إلا قبل ساعة واحدة على الأقل",
|
||||
"insights_no_data_found_for_filter": "لم يتم العثور على بيانات لعامل التصفية المحدد أو التواريخ المحددة.",
|
||||
"acknowledge_booking_no_show_fee": "أقر بأنه إذا لم أحضر هذا الحدث فسيتم سحب رسوم عدم حضور من بطاقتي تبلغ {{amount, currency}}.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "اختيار الوقت",
|
||||
"select_date": "اختيار التاريخ",
|
||||
"connecting_you_to_someone": "نقوم بربطك بشخص ما.",
|
||||
"please_do_not_close_this_tab": "يرجى عدم إغلاق هذه النافذة",
|
||||
"please_do_not_close_this_tab": "من فضلك لا تغلق أو تحدّث هذه الصفحة",
|
||||
"see_all_available_times": "رؤية كل الأوقات المتاحة",
|
||||
"org_team_names_example_1": "مثال، فريق التسويق",
|
||||
"org_team_names_example_2": "مثال، فريق المبيعات",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "اتصال بتقويم Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "تم الاتصال بتقويم Outlook",
|
||||
"outlook_connect_atom_loading_label": "جارٍ التحقق من تقويم Outlook",
|
||||
"stripe_connect_atom_label": "الاتصال بـ Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "تم الاتصال بـ Stripe",
|
||||
"stripe_connect_atom_loading_label": "جارٍ التحقق من بيانات اعتماد Stripe",
|
||||
"booking_question_response_variables": "متغيرات استجابة أسئلة الحجز",
|
||||
"managed_by_teamAdmins": "يدار بواسطة {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} خيارات",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "أنت غير مخول لإجراء هذا التغيير على الحجز",
|
||||
"hide_calendar_event_details": "إخفاء تفاصيل الأحداث في التقويمات المشتركة",
|
||||
"description_hide_calendar_event_details": "عند مشاركة التقويم، تكون الأحداث مرئية للقراء ولكن تفاصيلها تكون مخفية عن أولئك الذين ليس لديهم صلاحية الكتابة.",
|
||||
"last_number_of_days": "آخر {{count}} يوم",
|
||||
"month_to_date": "منذ بداية الشهر",
|
||||
"year_to_date": "منذ بداية السنة",
|
||||
"custom_range": "نطاق مخصص",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Şəxsi linki kopyala",
|
||||
"copy_private_link_to_event": "Tədbirə şəxsi linki kopyala",
|
||||
"private_link_description": "Öz {{appName}} istifadəçi adınızı açıqlamadan paylaşmaq üçün şəxsi URL yaradın",
|
||||
"multiple_private_links_title": "Şəxsi Keçidlər",
|
||||
"multiple_private_links_description": "İstifadəçi adını açıqlamadan şəxsi URL-lər yaradın, istifadə edildikdən sonra məhv ediləcək",
|
||||
"add_a_multiple_private_link": "Yeni keçid əlavə edin",
|
||||
"multiple_private_link_copied": "Şəxsi keçid kopyalandı",
|
||||
"invitees_can_schedule": "Dəvətlilər cədvəl qura bilər",
|
||||
"date_range": "Tarix Aralığı",
|
||||
"calendar_days": "təqvim günləri",
|
||||
"business_days": "iş günləri",
|
||||
"set_address_place": "Ünvan və ya yer təyin edin",
|
||||
"set_link_meeting": "Görüş üçün link təyin edin",
|
||||
"managed_event_field_parent_control_disabled": "Dəyişdirilə bilməz. Yalnız uşaq tədbir növləri üçün açılabilir",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} dəvətlilərinizdən cədvəl qurmazdan əvvəl telefon nömrəsi daxil etmələrini istəyəcək.",
|
||||
"cal_provide_google_meet_location": "{{appName}} Google Meet yeri təmin edəcək.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} Zoom görüş URL-i təmin edəcək.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Gəlməmə haqqını toplayın",
|
||||
"no_show_fee_charged": "Gəlməmə haqqı tutuldu",
|
||||
"insights": "Görüşlər",
|
||||
"routing_forms": "Marşrutlaşdırma Formları",
|
||||
"testing_workflow_info_message": "Bu iş axınını sınaqdan keçirərkən, e-poçtların və SMS-lərin ən azı 1 saat əvvəl planlaşdırıla biləcəyini unutmayın",
|
||||
"insights_no_data_found_for_filter": "Seçilmiş filtr və ya tarixlər üçün məlumat tapılmadı.",
|
||||
"acknowledge_booking_no_show_fee": "Bu tədbirə qatılmasam, kartıma {{amount, currency}} gəlməmə haqqı tətbiq olunacağını qəbul edirəm.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Vaxtı Seçin",
|
||||
"select_date": "Tarixi Seçin",
|
||||
"connecting_you_to_someone": "Sizi kiməsə qoşuruq.",
|
||||
"please_do_not_close_this_tab": "Zəhmət olmasa, bu nişanı bağlamayın",
|
||||
"please_do_not_close_this_tab": "Zəhmət olmasa, bu səhifəni bağlamayın və ya yeniləməyin",
|
||||
"see_all_available_times": "Bütün mövcud vaxtları görün",
|
||||
"org_team_names_example_1": "məs. Marketinq Komandası",
|
||||
"org_team_names_example_2": "məs. Satış Komandası",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Təqvimini Bağla",
|
||||
"outlook_connect_atom_already_connected_label": "Bağlı Outlook Təqvimi",
|
||||
"outlook_connect_atom_loading_label": "Outlook Təqvimi Yoxlanılır",
|
||||
"stripe_connect_atom_label": "Stripe-ə qoşulun",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe qoşulub",
|
||||
"stripe_connect_atom_loading_label": "Stripe məlumatları yoxlanılır",
|
||||
"booking_question_response_variables": "Rezervasiya sual cavab dəyişənləri",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}} tərəfindən idarə olunur",
|
||||
"number_of_options": "{{count}} seçim",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bu dəyişikliyi etmək üçün səlahiyyətiniz yoxdur",
|
||||
"hide_calendar_event_details": "Təqvim hadisə detallarını gizlət",
|
||||
"description_hide_calendar_event_details": "Təqvim paylaşıldıqda, hadisələr oxucular üçün görünür, lakin yazma icazəsi olmayanlar üçün detallar gizlədilir.",
|
||||
"last_number_of_days": "son {{count}} gün",
|
||||
"month_to_date": "bu ayın tarixinə qədər",
|
||||
"year_to_date": "bu ilin tarixinə qədər",
|
||||
"custom_range": "xüsusi aralıq",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Zkopírovat soukromý odkaz",
|
||||
"copy_private_link_to_event": "Zkopírovat soukromý odkaz na událost",
|
||||
"private_link_description": "Vygenerujte soukromou adresu URL, kterou budete sdílet, aniž byste odhalili své uživatelské jméno ve službě {{appName}}",
|
||||
"multiple_private_links_title": "Soukromé odkazy",
|
||||
"multiple_private_links_description": "Vytvořte soukromé URL adresy bez odhalení uživatelského jména, které budou po použití zničeny",
|
||||
"add_a_multiple_private_link": "Přidat nový odkaz",
|
||||
"multiple_private_link_copied": "Soukromý odkaz zkopírován",
|
||||
"invitees_can_schedule": "Pozvaní mohou plánovat",
|
||||
"date_range": "Časové rozmezí",
|
||||
"calendar_days": "kalendářních dní",
|
||||
"business_days": "pracovních dní",
|
||||
"set_address_place": "Zadejte adresu nebo místo",
|
||||
"set_link_meeting": "Poslat odkaz na schůzku",
|
||||
"managed_event_field_parent_control_disabled": "Nelze přepnout. Lze odemknout pouze pro podtypy událostí",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} vyzve pozvaného, aby před naplánováním zadal telefonní číslo.",
|
||||
"cal_provide_google_meet_location": "{{appName}} poskytne místo Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} poskytne URL Zoom meetingu.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Vybrat poplatek za nedostavení se",
|
||||
"no_show_fee_charged": "Poplatek za nedostavení se stržen",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Směrovací formuláře",
|
||||
"testing_workflow_info_message": "Při testování tohoto pracovního postupu mějte na paměti, že e-maily a SMS lze naplánovat pouze nejméně 1 hodinu předem.",
|
||||
"insights_no_data_found_for_filter": "Pro vybraný filtr nebo vybraná data nebyla nalezena žádná data.",
|
||||
"acknowledge_booking_no_show_fee": "Beru na vědomí, že pokud se této události nezúčastním, bude z mé karty stržen poplatek za nedostavení se ve výši {{amount, currency}}.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Vyberte čas",
|
||||
"select_date": "Vyberte datum",
|
||||
"connecting_you_to_someone": "Spojujeme vás s někým.",
|
||||
"please_do_not_close_this_tab": "Prosím, nezavírejte tuto kartu.",
|
||||
"please_do_not_close_this_tab": "Prosím, nezavírejte ani neobnovujte tuto stránku",
|
||||
"see_all_available_times": "Zobrazit všechny dostupné časy",
|
||||
"org_team_names_example_1": "Např. marketingový tým",
|
||||
"org_team_names_example_2": "Např. obchodní tým",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Připojit Outlook Kalendář",
|
||||
"outlook_connect_atom_already_connected_label": "Připojený Outlook Kalendář",
|
||||
"outlook_connect_atom_loading_label": "Kontrola kalendáře Outlook",
|
||||
"stripe_connect_atom_label": "Připojit ke Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe připojeno",
|
||||
"stripe_connect_atom_loading_label": "Kontrola přihlašovacích údajů Stripe",
|
||||
"booking_question_response_variables": "Proměnné odpovědí na otázky při rezervaci",
|
||||
"managed_by_teamAdmins": "Spravováno {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} možností",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nemáte oprávnění provést tuto změnu v rezervaci",
|
||||
"hide_calendar_event_details": "Skrýt podrobnosti událostí v kalendáři na sdílených kalendářích",
|
||||
"description_hide_calendar_event_details": "Když je kalendář sdílen, události jsou viditelné pro čtenáře, ale jejich podrobnosti jsou skryty pro ty, kteří nemají přístup k úpravám.",
|
||||
"last_number_of_days": "posledních {{count}} dní",
|
||||
"month_to_date": "od začátku měsíce",
|
||||
"year_to_date": "od začátku roku",
|
||||
"custom_range": "vlastní rozsah",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Kopiér privat link",
|
||||
"copy_private_link_to_event": "Kopiér privat link til begivenhed",
|
||||
"private_link_description": "Generer en privat URL til deling uden at afsløre dit {{appName}} brugernavn",
|
||||
"multiple_private_links_title": "Private links",
|
||||
"multiple_private_links_description": "Generer private URL'er uden at afsløre brugernavnet, som vil blive slettet efter brug",
|
||||
"add_a_multiple_private_link": "Tilføj nyt link",
|
||||
"multiple_private_link_copied": "Privat link kopieret",
|
||||
"invitees_can_schedule": "Inviterede kan planlægge",
|
||||
"date_range": "Datointerval",
|
||||
"calendar_days": "kalenderdage",
|
||||
"business_days": "hverdage",
|
||||
"set_address_place": "Angiv en adresse eller et sted",
|
||||
"set_link_meeting": "Angiv et link til mødet",
|
||||
"managed_event_field_parent_control_disabled": "Kan ikke aktiveres. Det kan kun låses op for underbegivenhedstyper",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} vil bede din inviterede om at indtaste et telefonnummer før planlægning.",
|
||||
"cal_provide_google_meet_location": "{{appName}} vil angive en Google Meet placering.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} angiver en URL-adresse for Zoom-mødet.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Opkræv gebyr for udeblivelse",
|
||||
"no_show_fee_charged": "Gebyr for udeblivelse opkrævet",
|
||||
"insights": "Indsigter",
|
||||
"routing_forms": "Ruteformularer",
|
||||
"testing_workflow_info_message": "Når du tester denne arbejdsgang, skal du være opmærksom på, at e-mails og SMS'er kun kan planlægges mindst 1 time i forvejen",
|
||||
"insights_no_data_found_for_filter": "Ingen data fundet for det valgte filter eller de valgte datoer.",
|
||||
"acknowledge_booking_no_show_fee": "Jeg anerkender, at hvis jeg ikke deltager i denne begivenhed, vil et gebyr for udeblivelse på {{amount, currency}} blive opkrævet på mit kort.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Vælg tid",
|
||||
"select_date": "Vælg dato",
|
||||
"connecting_you_to_someone": "Vi forbinder dig med en person.",
|
||||
"please_do_not_close_this_tab": "Luk venligst ikke denne fane",
|
||||
"please_do_not_close_this_tab": "Luk eller opdater venligst ikke denne side",
|
||||
"see_all_available_times": "Se alle tilgængelige tidspunkter",
|
||||
"org_team_names_example_1": "f.eks. Marketingteam",
|
||||
"org_team_names_example_2": "f.eks. Salgsteam",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Forbind Outlook Kalender",
|
||||
"outlook_connect_atom_already_connected_label": "Forbundet Outlook Kalender",
|
||||
"outlook_connect_atom_loading_label": "Kontrollerer Outlook Kalender",
|
||||
"stripe_connect_atom_label": "Forbind til Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe forbundet",
|
||||
"stripe_connect_atom_loading_label": "Kontrollerer Stripe legitimationsoplysninger",
|
||||
"booking_question_response_variables": "Booking spørgsmål svar variabler",
|
||||
"managed_by_teamAdmins": "Administreret af {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} muligheder",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tilladelse til at foretage denne ændring i bookingen",
|
||||
"hide_calendar_event_details": "Skjul kalenderbegivenhedsdetaljer på delte kalendere",
|
||||
"description_hide_calendar_event_details": "Når en kalender deles, er begivenheder synlige for læsere, men deres detaljer er skjult for dem uden skriveadgang.",
|
||||
"last_number_of_days": "sidste {{count}} dage",
|
||||
"month_to_date": "måned til dato",
|
||||
"year_to_date": "år til dato",
|
||||
"custom_range": "tilpasset interval",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Tilføj dine nye strenge ovenfor her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Privaten Link kopieren",
|
||||
"copy_private_link_to_event": "Privaten Link zum Ereignis kopieren",
|
||||
"private_link_description": "Erzeugen Sie einen privaten Link, den Sie teilen können, ohne Ihren {{appName}}-Benutzername preiszugeben",
|
||||
"multiple_private_links_title": "Private Links",
|
||||
"multiple_private_links_description": "Generieren Sie private URLs ohne den Benutzernamen preiszugeben. Diese werden nach einmaliger Verwendung gelöscht",
|
||||
"add_a_multiple_private_link": "Neuen Link hinzufügen",
|
||||
"multiple_private_link_copied": "Privater Link kopiert",
|
||||
"invitees_can_schedule": "Teilnehmende können folgendes buchen",
|
||||
"date_range": "Datumszeitraum",
|
||||
"calendar_days": "Kalendertage",
|
||||
"business_days": "Werktage",
|
||||
"set_address_place": "Adresse oder Ort festlegen",
|
||||
"set_link_meeting": "Einen Link zum Termin setzen",
|
||||
"managed_event_field_parent_control_disabled": "Kann nicht umgeschaltet werden. Kann nur für untergeordnete Ereignistypen freigeschaltet werden",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} wird Ihren Teilnehmer bitten, vor der Planung eine Telefonnummer einzugeben.",
|
||||
"cal_provide_google_meet_location": "{{appName}} wird einen Google Meet Ort zur Verfügung stellen.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} wird eine Zoom Meeting URL zur Verfügung stellen.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Gebühr für das Nichterscheinen anfordern",
|
||||
"no_show_fee_charged": "Gebühr für das Nichterscheinen berechnet",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Weiterleitungsformulare",
|
||||
"testing_workflow_info_message": "Bitte beachten Sie beim Testen dieses Workflows, dass E-Mails und SMS spätestens 1 Stunde im Voraus geplant werden können",
|
||||
"insights_no_data_found_for_filter": "Keine Daten für den ausgewählten Filter oder das ausgewählte Datum gefunden.",
|
||||
"acknowledge_booking_no_show_fee": "Ich bestätige, dass meiner Karte eine Gebühr von {{amount, currency}} berechnet wird, sofern ich nicht an diesem Ereignis teilnehme.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Zeit auswählen",
|
||||
"select_date": "Datum auswählen",
|
||||
"connecting_you_to_someone": "Wir verbinden Sie mit jemandem.",
|
||||
"please_do_not_close_this_tab": "Diesen Tab bitte nicht schließen",
|
||||
"please_do_not_close_this_tab": "Bitte schließen oder aktualisieren Sie diese Seite nicht",
|
||||
"see_all_available_times": "Alle verfügbaren Zeiten ansehen",
|
||||
"org_team_names_example_1": "z.B. Marketing-Team",
|
||||
"org_team_names_example_2": "z.B. Vertriebsteam",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Kalender verbinden",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook Kalender verbunden",
|
||||
"outlook_connect_atom_loading_label": "Outlook Kalender wird überprüft",
|
||||
"stripe_connect_atom_label": "Mit Stripe verbinden",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe verbunden",
|
||||
"stripe_connect_atom_loading_label": "Überprüfe Stripe-Anmeldedaten",
|
||||
"booking_question_response_variables": "Variablen für Buchungsfragen-Antworten",
|
||||
"managed_by_teamAdmins": "Verwaltet von {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} Optionen",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Sie sind nicht berechtigt, diese Änderung an der Buchung vorzunehmen",
|
||||
"hide_calendar_event_details": "Kalenderereignisdetails in geteilten Kalendern verbergen",
|
||||
"description_hide_calendar_event_details": "Wenn ein Kalender geteilt wird, sind Ereignisse für Leser sichtbar, aber ihre Details bleiben für Personen ohne Schreibzugriff verborgen.",
|
||||
"last_number_of_days": "letzte {{count}} Tage",
|
||||
"month_to_date": "aktueller Monat",
|
||||
"year_to_date": "aktuelles Jahr",
|
||||
"custom_range": "benutzerdefinierter Zeitraum",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Copy private link",
|
||||
"copy_private_link_to_event": "Copy private link to event",
|
||||
"private_link_description": "Generate a private URL to share without exposing your {{appName}} username",
|
||||
"multiple_private_links_title": "Private Links",
|
||||
"multiple_private_links_description": "Generate private URLs without exposing the username, which will be destroyed once used",
|
||||
"add_a_multiple_private_link": "Add new link",
|
||||
"multiple_private_link_copied": "Private link copied",
|
||||
"invitees_can_schedule": "Invitees can schedule",
|
||||
"date_range": "Date Range",
|
||||
"calendar_days": "calendar days",
|
||||
"business_days": "business days",
|
||||
"set_address_place": "Set an address or place",
|
||||
"set_link_meeting": "Set a link to the meeting",
|
||||
"managed_event_field_parent_control_disabled": "Can't be toggled. It can only be unlocked for child event types",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} will ask your invitee to enter a phone number before scheduling.",
|
||||
"cal_provide_google_meet_location": "{{appName}} will provide a Google Meet location.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} will provide a Zoom meeting URL.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Collect no-show fee",
|
||||
"no_show_fee_charged": "No-show fee charged",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Routing Forms",
|
||||
"testing_workflow_info_message": "When testing this workflow, be aware that Emails and SMS can only be scheduled at least 1 hour in advance",
|
||||
"insights_no_data_found_for_filter": "No data found for the selected filter or selected dates.",
|
||||
"acknowledge_booking_no_show_fee": "I acknowledge that if I do not attend this event that a {{amount, currency}} no show fee will be applied to my card.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Select Time",
|
||||
"select_date": "Select Date",
|
||||
"connecting_you_to_someone": "We are connecting you to someone.",
|
||||
"please_do_not_close_this_tab": "Please do not close this tab",
|
||||
"please_do_not_close_this_tab": "Please do not close or refresh this page",
|
||||
"see_all_available_times": "See all available times",
|
||||
"org_team_names_example_1": "e.g. Marketing Team",
|
||||
"org_team_names_example_2": "e.g. Sales Team",
|
||||
@@ -2605,6 +2611,9 @@
|
||||
"outlook_connect_atom_label": "Connect Outlook Calendar",
|
||||
"outlook_connect_atom_already_connected_label": "Connected Outlook Calendar",
|
||||
"outlook_connect_atom_loading_label": "Checking Outlook Calendar",
|
||||
"stripe_connect_atom_label": "Connect to Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Connected Stripe",
|
||||
"stripe_connect_atom_loading_label": "Checking Stripe credentials",
|
||||
"booking_question_response_variables": "Booking question response variables",
|
||||
"managed_by_teamAdmins": "Managed by {{teamAdmins}}",
|
||||
"number_of_options":"{{count}} options",
|
||||
@@ -2618,5 +2627,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
|
||||
"hide_calendar_event_details": "Hide calendar event details on shared calendars",
|
||||
"description_hide_calendar_event_details": "When a calendar is shared, events are visible to readers but their details are hidden from those without write access.",
|
||||
"last_number_of_days": "last {{count}} days",
|
||||
"month_to_date": "month to date",
|
||||
"year_to_date": "year to date",
|
||||
"custom_range": "custom range",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Copiar enlace privado",
|
||||
"copy_private_link_to_event": "Copiar enlace privado al evento",
|
||||
"private_link_description": "Genera una URL privada para compartir sin exponer tu nombre de usuario de {{appName}}",
|
||||
"multiple_private_links_title": "Enlaces Privados",
|
||||
"multiple_private_links_description": "Genera URLs privadas sin exponer el nombre de usuario, las cuales se destruirán una vez utilizadas",
|
||||
"add_a_multiple_private_link": "Agregar nuevo enlace",
|
||||
"multiple_private_link_copied": "Enlace privado copiado",
|
||||
"invitees_can_schedule": "Los invitados pueden programar",
|
||||
"date_range": "Rango de fechas",
|
||||
"calendar_days": "días calendario",
|
||||
"business_days": "días hábiles",
|
||||
"set_address_place": "Establecer una dirección o lugar",
|
||||
"set_link_meeting": "Establecer un enlace para la reunión",
|
||||
"managed_event_field_parent_control_disabled": "No se puede alternar. Solo se puede desbloquear para tipos de eventos secundarios",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} pedirá a tu invitado que ingrese un número de teléfono antes de programar.",
|
||||
"cal_provide_google_meet_location": "{{appName}} proporcionará una ubicación de Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} proporcionará una URL de reunión de Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Cobrar tarifa de no presentación",
|
||||
"no_show_fee_charged": "Tarifa de no presentación cobrada",
|
||||
"insights": "Perspectivas",
|
||||
"routing_forms": "Formularios de Enrutamiento",
|
||||
"testing_workflow_info_message": "Al probar este flujo de trabajo, tenga en cuenta que los correos electrónicos y SMS solo se pueden programar con al menos 1 hora de anticipación",
|
||||
"insights_no_data_found_for_filter": "No se encontraron datos para el filtro o las fechas seleccionadas.",
|
||||
"acknowledge_booking_no_show_fee": "Reconozco que si no asisto a este evento, se aplicará una tarifa de no presentación de {{amount, currency}} a mi tarjeta.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Seleccionar hora",
|
||||
"select_date": "Seleccionar fecha",
|
||||
"connecting_you_to_someone": "Te estamos conectando con alguien.",
|
||||
"please_do_not_close_this_tab": "Por favor, no cierres esta pestaña",
|
||||
"please_do_not_close_this_tab": "Por favor, no cierres ni actualices esta página",
|
||||
"see_all_available_times": "Ver todos los horarios disponibles",
|
||||
"org_team_names_example_1": "p. ej. Equipo de Marketing",
|
||||
"org_team_names_example_2": "p. ej. Equipo de Ventas",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Conectar Calendario de Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendario de Outlook conectado",
|
||||
"outlook_connect_atom_loading_label": "Verificando Calendario de Outlook",
|
||||
"stripe_connect_atom_label": "Conectar con Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe conectado",
|
||||
"stripe_connect_atom_loading_label": "Verificando credenciales de Stripe",
|
||||
"booking_question_response_variables": "Variables de respuesta de preguntas de reserva",
|
||||
"managed_by_teamAdmins": "Gestionado por {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opciones",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
|
||||
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
|
||||
"description_hide_calendar_event_details": "Cuando se comparte un calendario, los eventos son visibles para los lectores, pero sus detalles están ocultos para aquellos sin acceso de escritura.",
|
||||
"last_number_of_days": "últimos {{count}} días",
|
||||
"month_to_date": "mes hasta la fecha",
|
||||
"year_to_date": "año hasta la fecha",
|
||||
"custom_range": "rango personalizado",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agrega tus nuevas cadenas arriba de esta línea ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Copiar enlace privado",
|
||||
"copy_private_link_to_event": "Copiar enlace privado al evento",
|
||||
"private_link_description": "Generar una URL privada para compartir sin exponer su nombre de usuario de {{appName}}",
|
||||
"multiple_private_links_title": "Enlaces privados",
|
||||
"multiple_private_links_description": "Genera URLs privadas sin exponer el nombre de usuario, que se destruirán una vez utilizadas",
|
||||
"add_a_multiple_private_link": "Agregar nuevo enlace",
|
||||
"multiple_private_link_copied": "Enlace privado copiado",
|
||||
"invitees_can_schedule": "Los invitados pueden programar",
|
||||
"date_range": "Rango de Fechas",
|
||||
"calendar_days": "Días del Calendário",
|
||||
"business_days": "Días Laborales",
|
||||
"set_address_place": "Establecer una dirección o un lugar",
|
||||
"set_link_meeting": "Establecer un enlace a la reunión",
|
||||
"managed_event_field_parent_control_disabled": "No se puede alternar. Solo se puede desbloquear para tipos de eventos secundarios",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} le pedirá a su invitado que introduzca un número de teléfono antes de agendar.",
|
||||
"cal_provide_google_meet_location": "{{appName}} proporcionará una URL de reunión de Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} proporcionará una URL de reunión de Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Cobrar comisión por no presentarse",
|
||||
"no_show_fee_charged": "Comisión por no presentarse cobrada",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Formularios de enrutamiento",
|
||||
"testing_workflow_info_message": "Al probar este flujo de trabajo, tenga en cuenta que los correos electrónicos y los SMS solo se pueden programar con al menos 1 hora de anticipación",
|
||||
"insights_no_data_found_for_filter": "No se encontraron datos para el filtro seleccionado o las fechas seleccionadas.",
|
||||
"acknowledge_booking_no_show_fee": "Reconozco que si no asisto a este evento, se aplicará a mi tarjeta una tarifa de {{amount, currency}} por no presentarme.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Seleccione la hora",
|
||||
"select_date": "Seleccione la fecha",
|
||||
"connecting_you_to_someone": "Te estamos conectando con alguien.",
|
||||
"please_do_not_close_this_tab": "Por favor, no cierres esta pestaña",
|
||||
"please_do_not_close_this_tab": "Por favor, no cierres ni actualices esta página",
|
||||
"see_all_available_times": "Ver todas las horas disponibles",
|
||||
"org_team_names_example_1": "ej. Equipo de marketing",
|
||||
"org_team_names_example_2": "ej. Equipo de ventas",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Conectar Calendario de Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendario de Outlook conectado",
|
||||
"outlook_connect_atom_loading_label": "Comprobando el calendario de Outlook",
|
||||
"stripe_connect_atom_label": "Conectar con Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe conectado",
|
||||
"stripe_connect_atom_loading_label": "Verificando credenciales de Stripe",
|
||||
"booking_question_response_variables": "Variables de respuesta a preguntas de reserva",
|
||||
"managed_by_teamAdmins": "Gestionado por {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opciones",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
|
||||
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
|
||||
"description_hide_calendar_event_details": "Cuando se comparte un calendario, los eventos son visibles para los lectores, pero sus detalles están ocultos para aquellos sin acceso de escritura.",
|
||||
"last_number_of_days": "últimos {{count}} días",
|
||||
"month_to_date": "mes hasta la fecha",
|
||||
"year_to_date": "año hasta la fecha",
|
||||
"custom_range": "rango personalizado",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Kopeeri privaatne link",
|
||||
"copy_private_link_to_event": "Kopeeri sündmuse privaatne link",
|
||||
"private_link_description": "Loo privaatne URL, mida jagate ilma oma {{appName}} kasutajanime paljastamata",
|
||||
"multiple_private_links_title": "Privaatsed lingid",
|
||||
"multiple_private_links_description": "Loo privaatsed URL-id ilma kasutajanime avalikustamata, mis hävitatakse pärast kasutamist",
|
||||
"add_a_multiple_private_link": "Lisa uus link",
|
||||
"multiple_private_link_copied": "Privaatne link kopeeritud",
|
||||
"invitees_can_schedule": "Kutsutud saavad ajakava koostada",
|
||||
"date_range": "Kuupäevavahemik",
|
||||
"calendar_days": "kalendripäevad",
|
||||
"business_days": "äripäevad",
|
||||
"set_address_place": "Määra aadress või koht",
|
||||
"set_link_meeting": "Määra koosoleku link",
|
||||
"managed_event_field_parent_control_disabled": "Ei saa lülitada. Seda saab avada ainult alamürituste tüüpide jaoks",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} palub teie kutsutul enne ajastamist telefoninumbri sisestada.",
|
||||
"cal_provide_google_meet_location": "{{appName}} pakub Google Meeti asukohta.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} annab Zoomi koosoleku URL-i.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Koguge mitteilmumise tasu",
|
||||
"no_show_fee_charged": "Tasutakse mitteilmumise tasu",
|
||||
"insights": "Teadmisi",
|
||||
"routing_forms": "Suunamise vormid",
|
||||
"testing_workflow_info_message": "Selle töövoo testimisel pidage meeles, et e-kirju ja SMS-e saab ajastada ainult vähemalt 1 tund ette",
|
||||
"insights_no_data_found_for_filter": "Valitud filtri või valitud kuupäevade kohta andmeid ei leitud.",
|
||||
"acknowledge_booking_no_show_fee": "Ma tean, et kui ma sellel üritusel ei osale, rakendatakse minu kaardile {{summa, valuuta}} mitteilmumise tasu.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Vali aeg",
|
||||
"select_date": "Vali kuupäev",
|
||||
"connecting_you_to_someone": "Me ühendame teid kellegagi.",
|
||||
"please_do_not_close_this_tab": "Palun ärge sulgege seda vahekaarti",
|
||||
"please_do_not_close_this_tab": "Palun ärge sulgege ega värskendage seda lehte",
|
||||
"see_all_available_times": "Vaata kõiki saadaolevaid aegu",
|
||||
"org_team_names_example_1": "nt turundusmeeskond",
|
||||
"org_team_names_example_2": "nt müügimeeskond",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Ühenda Outlooki kalender",
|
||||
"outlook_connect_atom_already_connected_label": "Outlooki kalender on ühendatud",
|
||||
"outlook_connect_atom_loading_label": "Outlooki kalendri kontrollimine",
|
||||
"stripe_connect_atom_label": "Ühenda Stripe'iga",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe on ühendatud",
|
||||
"stripe_connect_atom_loading_label": "Stripe'i andmete kontrollimine",
|
||||
"booking_question_response_variables": "Broneerimisküsimuste vastuse muutujad",
|
||||
"managed_by_teamAdmins": "Haldab {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} valikut",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Teil puuduvad õigused selle broneeringu muutmiseks",
|
||||
"hide_calendar_event_details": "Peida kalendrisündmuste üksikasjad jagatud kalendrites",
|
||||
"description_hide_calendar_event_details": "Kui kalender on jagatud, on sündmused lugejatele nähtavad, kuid nende üksikasjad on peidetud nende eest, kellel puudub kirjutamisõigus.",
|
||||
"last_number_of_days": "viimased {{count}} päeva",
|
||||
"month_to_date": "kuu algusest tänaseni",
|
||||
"year_to_date": "aasta algusest tänaseni",
|
||||
"custom_range": "kohandatud vahemik",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Copier le lien privé",
|
||||
"copy_private_link_to_event": "Copier le lien privé de l'événement",
|
||||
"private_link_description": "Générez un lien privé à partager sans révéler votre nom d'utilisateur {{appName}}.",
|
||||
"multiple_private_links_title": "Liens privés",
|
||||
"multiple_private_links_description": "Générez des URL privées sans exposer le nom d'utilisateur, qui seront détruites une fois utilisées",
|
||||
"add_a_multiple_private_link": "Ajouter un nouveau lien",
|
||||
"multiple_private_link_copied": "Lien privé copié",
|
||||
"invitees_can_schedule": "Les invités peuvent réserver",
|
||||
"date_range": "Plage de dates",
|
||||
"calendar_days": "jours calendaires",
|
||||
"business_days": "jours ouvrés",
|
||||
"set_address_place": "Définir une adresse ou un lieu",
|
||||
"set_link_meeting": "Définir un lien vers le rendez-vous",
|
||||
"managed_event_field_parent_control_disabled": "Ne peut pas être modifié. Il ne peut être déverrouillé que pour les types d'événements enfants",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} demandera à votre invité d'entrer un numéro de téléphone avant de faire une réservation.",
|
||||
"cal_provide_google_meet_location": "{{appName}} fournira un lien Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} fournira un lien de rendez-vous Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Percevoir les frais d'absence",
|
||||
"no_show_fee_charged": "Frais d'absence facturés",
|
||||
"insights": "Statistiques",
|
||||
"routing_forms": "Formulaires de routage",
|
||||
"testing_workflow_info_message": "Lors du test de ce workflow, sachez que les e-mails et les SMS ne peuvent être programmés qu'au moins 1 heure à l'avance",
|
||||
"insights_no_data_found_for_filter": "Aucune donnée trouvée pour le filtre ou les dates sélectionnés.",
|
||||
"acknowledge_booking_no_show_fee": "Je reconnais que si je ne participe pas à cet événement, des frais d'absence de {{amount, currency}} seront appliqués à ma carte.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Sélectionner un créneau",
|
||||
"select_date": "Sélectionner une date",
|
||||
"connecting_you_to_someone": "Nous vous mettons en relation avec quelqu'un.",
|
||||
"please_do_not_close_this_tab": "Veuillez ne pas fermer cet onglet",
|
||||
"please_do_not_close_this_tab": "Veuillez ne pas fermer ou actualiser cette page",
|
||||
"see_all_available_times": "Voir tous les créneaux disponibles",
|
||||
"org_team_names_example_1": "p. ex. Équipe marketing",
|
||||
"org_team_names_example_2": "p. ex. Équipe de vente",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Connecter le calendrier Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendrier Outlook connecté",
|
||||
"outlook_connect_atom_loading_label": "Vérification du calendrier Outlook",
|
||||
"stripe_connect_atom_label": "Se connecter à Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe connecté",
|
||||
"stripe_connect_atom_loading_label": "Vérification des identifiants Stripe",
|
||||
"booking_question_response_variables": "Variables de réponse aux questions de réservation",
|
||||
"managed_by_teamAdmins": "Géré par {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} options",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Vous n'êtes pas autorisé à apporter cette modification à la réservation",
|
||||
"hide_calendar_event_details": "Masquer les détails des événements du calendrier sur les calendriers partagés",
|
||||
"description_hide_calendar_event_details": "Lorsqu'un calendrier est partagé, les événements sont visibles pour les lecteurs, mais leurs détails sont cachés pour ceux qui n'ont pas d'accès en écriture.",
|
||||
"last_number_of_days": "{{count}} derniers jours",
|
||||
"month_to_date": "mois en cours",
|
||||
"year_to_date": "année en cours",
|
||||
"custom_range": "période personnalisée",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "העתקת קישור פרטי",
|
||||
"copy_private_link_to_event": "העתקת קישור פרטי לאירוע",
|
||||
"private_link_description": "ניתן ליצור כתובת קישור פרטית מבלי לחשוף את המשתמש שלך- {{appName}}",
|
||||
"multiple_private_links_title": "קישורים פרטיים",
|
||||
"multiple_private_links_description": "צור כתובות URL פרטיות מבלי לחשוף את שם המשתמש, אשר יושמדו לאחר השימוש",
|
||||
"add_a_multiple_private_link": "הוסף קישור חדש",
|
||||
"multiple_private_link_copied": "הקישור הפרטי הועתק",
|
||||
"invitees_can_schedule": "המוזמנים יכולים לקבוע מועד",
|
||||
"date_range": "טווח תאריכים",
|
||||
"calendar_days": "ימים קלנדריים",
|
||||
"business_days": "ימי עסקים",
|
||||
"set_address_place": "הגדר כתובת או מקום",
|
||||
"set_link_meeting": "הגדר קישור לפגישה",
|
||||
"managed_event_field_parent_control_disabled": "לא ניתן להפעיל. ניתן לפתוח רק עבור סוגי אירועים משניים",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} יציג לנמען בקשה להזנת מספר טלפון לפני קביעת ביצוע התזמון.",
|
||||
"cal_provide_google_meet_location": "{{appName}} יספק מיקום Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} יספק קישור לפגישת Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "גביית דמי אי-הגעה",
|
||||
"no_show_fee_charged": "דמי אי-הגעה חויבו",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "טפסי ניתוב",
|
||||
"testing_workflow_info_message": "במהלך בדיקת תהליך העבודה הזה, קח/י בחשבון שניתן לתזמן הודעות דוא\"ל ו-SMS לפחות שעה אחת מראש",
|
||||
"insights_no_data_found_for_filter": "לא נמצאו נתונים עבור המסנן שנבחר או התאריכים שנבחרו.",
|
||||
"acknowledge_booking_no_show_fee": "מובן לי שאם לא אשתתף באירוע הזה, דמי אי-הגעה בסך {{amount, currency}} ינוכו מהכרטיס שלי.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "בחירת שעה",
|
||||
"select_date": "בחירת תאריך",
|
||||
"connecting_you_to_someone": "אנו מחברים אותך למישהו או מישהי.",
|
||||
"please_do_not_close_this_tab": "נא לא לסגור את הלשונית הזאת",
|
||||
"please_do_not_close_this_tab": "אנא אל תסגור או תרענן דף זה",
|
||||
"see_all_available_times": "לצפייה בכל המועדים הפנויים",
|
||||
"org_team_names_example_1": "לדוגמה, מחלקת שיווק",
|
||||
"org_team_names_example_2": "לדוגמה, מחלקת מכירות",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "חבר את לוח השנה של Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "לוח השנה של Outlook מחובר",
|
||||
"outlook_connect_atom_loading_label": "בודק את לוח השנה של Outlook",
|
||||
"stripe_connect_atom_label": "התחבר ל-Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "מחובר ל-Stripe",
|
||||
"stripe_connect_atom_loading_label": "בודק אישורי Stripe",
|
||||
"booking_question_response_variables": "משתני תגובה לשאלות הזמנה",
|
||||
"managed_by_teamAdmins": "מנוהל על ידי {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} אפשרויות",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "אין לך הרשאה לבצע שינוי זה בהזמנה",
|
||||
"hide_calendar_event_details": "הסתר פרטי אירועי לוח שנה בלוחות שנה משותפים",
|
||||
"description_hide_calendar_event_details": "כאשר לוח שנה משותף, האירועים נראים לקוראים אך פרטיהם מוסתרים מאלו שאין להם גישת כתיבה.",
|
||||
"last_number_of_days": "ב{{count}} הימים האחרונים",
|
||||
"month_to_date": "מתחילת החודש",
|
||||
"year_to_date": "מתחילת השנה",
|
||||
"custom_range": "טווח מותאם אישית",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Privát link másolása",
|
||||
"copy_private_link_to_event": "Privát link másolása az eseményhez",
|
||||
"private_link_description": "Hozzon létre egy privát URL-t, amelyet megoszthat anélkül, hogy felfedné {{appName}} felhasználónevét",
|
||||
"multiple_private_links_title": "Privát linkek",
|
||||
"multiple_private_links_description": "Privát URL-ek generálása a felhasználónév felfedése nélkül, amelyek használat után megsemmisülnek",
|
||||
"add_a_multiple_private_link": "Új link hozzáadása",
|
||||
"multiple_private_link_copied": "Privát link másolva",
|
||||
"invitees_can_schedule": "A meghívottak ütemezhetnek",
|
||||
"date_range": "Időintervallum",
|
||||
"calendar_days": "naptári napok",
|
||||
"business_days": "munkanapok",
|
||||
"set_address_place": "Állítson be egy címet vagy helyet",
|
||||
"set_link_meeting": "Állítson be egy hivatkozást az értekezlethez",
|
||||
"managed_event_field_parent_control_disabled": "Nem kapcsolható át. Csak a gyerek eseménytípusok számára oldható fel",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} megkéri a meghívottat, hogy adjon meg egy telefonszámot az ütemezés előtt.",
|
||||
"cal_provide_google_meet_location": "{{appName}} biztosít egy Google Meet-helyszínt.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} megadja a Zoom megbeszélés URL-jét.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Szedje be a meg nem jelenési díjat",
|
||||
"no_show_fee_charged": "Meg nem jelenés díja levonva",
|
||||
"insights": "Betekintés",
|
||||
"routing_forms": "Irányító űrlapok",
|
||||
"testing_workflow_info_message": "A munkafolyamat tesztelésekor ügyeljen arra, hogy az e-mailek és SMS-ek legalább 1 órával későbbre ütemezhetők",
|
||||
"insights_no_data_found_for_filter": "Nem található adat a kiválasztott szűrőhöz vagy a kiválasztott dátumokhoz.",
|
||||
"acknowledge_booking_no_show_fee": "Tudomásul veszem, hogy ha nem veszek részt ezen az eseményen, {{amount, currency}} meg nem jelenési díj kerül felszámításra.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Válasszon időt",
|
||||
"select_date": "Válasszon dátumot",
|
||||
"connecting_you_to_someone": "Összekapcsolunk valakivel.",
|
||||
"please_do_not_close_this_tab": "Kérjük, ne zárja be ezt a lapot",
|
||||
"please_do_not_close_this_tab": "Kérjük, ne zárja be vagy frissítse ezt az oldalt",
|
||||
"see_all_available_times": "Az összes elérhető időpont megtekintése",
|
||||
"org_team_names_example_1": "például. Marketing Csapat",
|
||||
"org_team_names_example_2": "például. Értékesítési csapat",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Naptár csatlakoztatása",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook Naptár csatlakoztatva",
|
||||
"outlook_connect_atom_loading_label": "Outlook Naptár ellenőrzése",
|
||||
"stripe_connect_atom_label": "Csatlakozás a Stripe-hoz",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe csatlakoztatva",
|
||||
"stripe_connect_atom_loading_label": "Stripe hitelesítő adatok ellenőrzése",
|
||||
"booking_question_response_variables": "Foglalási kérdés válaszváltozók",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}} által kezelve",
|
||||
"number_of_options": "{{count}} lehetőség",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nincs jogosultsága a foglalás módosításához",
|
||||
"hide_calendar_event_details": "Naptáresemény részleteinek elrejtése megosztott naptárakon",
|
||||
"description_hide_calendar_event_details": "Amikor egy naptár meg van osztva, az események láthatóak az olvasók számára, de a részletek rejtve maradnak azok elől, akiknek nincs írási hozzáférésük.",
|
||||
"last_number_of_days": "az elmúlt {{count}} nap",
|
||||
"month_to_date": "hónap elejétől",
|
||||
"year_to_date": "év elejétől",
|
||||
"custom_range": "egyéni tartomány",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adja hozzá az új karakterláncokat fent ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Copia link privato",
|
||||
"copy_private_link_to_event": "Copia link privato nell'evento",
|
||||
"private_link_description": "Genera un URL privato da condividere senza mostrare il tuo nome utente {{appName}}",
|
||||
"multiple_private_links_title": "Link Privati",
|
||||
"multiple_private_links_description": "Genera URL privati senza esporre il nome utente, che verranno distrutti una volta utilizzati",
|
||||
"add_a_multiple_private_link": "Aggiungi nuovo link",
|
||||
"multiple_private_link_copied": "Link privato copiato",
|
||||
"invitees_can_schedule": "Gli inviti possono programmare",
|
||||
"date_range": "Range di date",
|
||||
"calendar_days": "giorni del calendario",
|
||||
"business_days": "giorni lavorativi",
|
||||
"set_address_place": "Imposta un indirizzo o un luogo",
|
||||
"set_link_meeting": "Imposta un collegamento alla riunione",
|
||||
"managed_event_field_parent_control_disabled": "Non può essere attivato. Può essere sbloccato solo per i tipi di eventi figli",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} chiederà al tuo invitato di inserire un numero di telefono prima della pianificazione.",
|
||||
"cal_provide_google_meet_location": "{{appName}} fornirà un Google Meet location.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} fornirà un URL di riunione Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Riscuoti penale per mancata presentazione",
|
||||
"no_show_fee_charged": "Penale per mancata presentazione addebitata",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Moduli di Routing",
|
||||
"testing_workflow_info_message": "Durante il test di questo flusso di lavoro, tieni presente che le e-mail e i messaggi SMS devono essere programmati con almeno 1 ora di anticipo",
|
||||
"insights_no_data_found_for_filter": "Nessun dato trovato per il filtro selezionato o le date selezionate.",
|
||||
"acknowledge_booking_no_show_fee": "Confermo che in caso di mia mancata partecipazione a questo evento, una penale per mancata presentazione di {{amount, currency}} verrà addebitata sulla mia carta.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Seleziona l'ora",
|
||||
"select_date": "Seleziona la data",
|
||||
"connecting_you_to_someone": "Ti stiamo collegando con qualcuno.",
|
||||
"please_do_not_close_this_tab": "Non chiudere questa scheda",
|
||||
"please_do_not_close_this_tab": "Per favore, non chiudere o aggiornare questa pagina",
|
||||
"see_all_available_times": "Vedi tutti gli orari disponibili",
|
||||
"org_team_names_example_1": "ad es., team di marketing",
|
||||
"org_team_names_example_2": "ad es., team vendite",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Connetti Calendario Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendario Outlook connesso",
|
||||
"outlook_connect_atom_loading_label": "Verifica Calendario Outlook",
|
||||
"stripe_connect_atom_label": "Collegati a Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe collegato",
|
||||
"stripe_connect_atom_loading_label": "Verifica delle credenziali Stripe in corso",
|
||||
"booking_question_response_variables": "Variabili di risposta alle domande di prenotazione",
|
||||
"managed_by_teamAdmins": "Gestito da {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opzioni",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Non sei autorizzato a effettuare questa modifica alla prenotazione",
|
||||
"hide_calendar_event_details": "Nascondi i dettagli degli eventi del calendario nei calendari condivisi",
|
||||
"description_hide_calendar_event_details": "Quando un calendario è condiviso, gli eventi sono visibili ai lettori ma i loro dettagli sono nascosti a chi non ha accesso in scrittura.",
|
||||
"last_number_of_days": "ultimi {{count}} giorni",
|
||||
"month_to_date": "mese corrente",
|
||||
"year_to_date": "anno corrente",
|
||||
"custom_range": "intervallo personalizzato",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Aggiungi le tue nuove stringhe qui sopra ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "プライベートリンクをコピー",
|
||||
"copy_private_link_to_event": "イベントのプライベートリンクをコピー",
|
||||
"private_link_description": "{{appName}} ユーザー名を公開せずに共有するプライベートURLを生成",
|
||||
"multiple_private_links_title": "プライベートリンク",
|
||||
"multiple_private_links_description": "ユーザー名を公開せずにプライベートURLを生成し、一度使用されると破棄されます",
|
||||
"add_a_multiple_private_link": "新しいリンクを追加",
|
||||
"multiple_private_link_copied": "プライベートリンクがコピーされました",
|
||||
"invitees_can_schedule": "招待されるメンバーはスケジュールできる",
|
||||
"date_range": "日付の範囲",
|
||||
"calendar_days": "暦日",
|
||||
"business_days": "営業日",
|
||||
"set_address_place": "住所または場所を設定",
|
||||
"set_link_meeting": "ミーティングのリンクを設定",
|
||||
"managed_event_field_parent_control_disabled": "切り替えできません。子イベントタイプのためにのみ解除できます",
|
||||
"cal_invitee_phone_number_scheduling": "招待されるメンバーは予約する前に {{appName}} によって電話番号の入力を求められる。",
|
||||
"cal_provide_google_meet_location": "{{appName}} は GoogleMeet の場所を提供する。",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} は Zoom ミーティングの URLを提供する。",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "不参加費用を徴収する",
|
||||
"no_show_fee_charged": "不参加費用が請求されました",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "ルーティングフォーム",
|
||||
"testing_workflow_info_message": "このワークフローをテストする場合、少なくとも 1 時間前でなければメールと SMS のスケジュール設定ができないことにご注意ください",
|
||||
"insights_no_data_found_for_filter": "選択したフィルターまたは日付のデータが見つかりませんでした。",
|
||||
"acknowledge_booking_no_show_fee": "このイベントに出席しなかった場合、不参加費用として {{amount, currency}} が私のカードに請求されることに同意します。",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "時間帯を選ぶ",
|
||||
"select_date": "日付を選ぶ",
|
||||
"connecting_you_to_someone": "誰かに接続しています。",
|
||||
"please_do_not_close_this_tab": "このタブを閉じないでください",
|
||||
"please_do_not_close_this_tab": "このページを閉じたり、リフレッシュしたりしないでください",
|
||||
"see_all_available_times": "出席できる時間帯をすべて表示",
|
||||
"org_team_names_example_1": "例:マーケティングチーム",
|
||||
"org_team_names_example_2": "例:営業チーム",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Outlookカレンダーを接続",
|
||||
"outlook_connect_atom_already_connected_label": "Outlookカレンダーが接続されました",
|
||||
"outlook_connect_atom_loading_label": "Outlookカレンダーを確認中",
|
||||
"stripe_connect_atom_label": "Stripeに接続",
|
||||
"stripe_connect_atom_already_connected_label": "Stripeに接続済み",
|
||||
"stripe_connect_atom_loading_label": "Stripeの認証情報を確認中",
|
||||
"booking_question_response_variables": "予約質問の回答変数",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}}によって管理されています",
|
||||
"number_of_options": "{{count}}のオプション",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "この予約の変更を行う権限がありません",
|
||||
"hide_calendar_event_details": "共有カレンダーのイベント詳細を非表示にする",
|
||||
"description_hide_calendar_event_details": "カレンダーが共有されると、イベントは閲覧者に表示されますが、書き込み権限のない人には詳細が非表示になります。",
|
||||
"last_number_of_days": "過去{{count}}日間",
|
||||
"month_to_date": "月初から今日まで",
|
||||
"year_to_date": "年初から今日まで",
|
||||
"custom_range": "カスタム範囲",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ この上に新しい文字列を追加してください ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "ចម្លងតំណផ្ទាល់ខ្លួន",
|
||||
"copy_private_link_to_event": "ចម្លងតំណផ្ទាល់ខ្លួនទៅព្រឹត្តិការណ៍",
|
||||
"private_link_description": "បង្កើត URL ផ្ទាល់ខ្លួនដើម្បីចែករំលែកដោយមិនបង្ហាញឈ្មោះអ្នកប្រើ {{appName}} របស់អ្នក",
|
||||
"multiple_private_links_title": "តំណភ្ជាប់ឯកជន",
|
||||
"multiple_private_links_description": "បង្កើត URL ឯកជនដោយមិនបង្ហាញឈ្មោះអ្នកប្រើ ហើយវានឹងត្រូវបានបំផ្លាញបន្ទាប់ពីប្រើប្រាស់",
|
||||
"add_a_multiple_private_link": "បន្ថែមតំណភ្ជាប់ថ្មី",
|
||||
"multiple_private_link_copied": "បានចម្លងតំណភ្ជាប់ឯកជន",
|
||||
"invitees_can_schedule": "ភ្ញៀវអាចកំណត់ពេលវេលា",
|
||||
"date_range": "ជួរថ្ងៃទី",
|
||||
"calendar_days": "ថ្ងៃប្រតិទិន",
|
||||
"business_days": "ថ្ងៃធ្វើការ",
|
||||
"set_address_place": "កំណត់អាសយដ្ឋាន ឬទីកន្លែង",
|
||||
"set_link_meeting": "កំណត់តំណទៅកិច្ចប្រជុំ",
|
||||
"managed_event_field_parent_control_disabled": "មិនអាចបើកបិទបានទេ។ វាអាចត្រូវបានដោះសោរតែសម្រាប់ប្រភេទព្រឹត្តិការណ៍កូនប៉ុណ្ណោះ",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} នឹងស្នើឱ្យភ្ញៀវរបស់អ្នកបញ្ចូលលេខទូរស័ព្ទមុនពេលកំណត់ពេលវេលា។",
|
||||
"cal_provide_google_meet_location": "{{appName}} នឹងផ្តល់ទីតាំង Google Meet។",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} នឹងផ្តល់ URL កិច្ចប្រជុំ Zoom។",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "ប្រមូលថ្លៃសេវាកម្មមិនមកចូលរួម",
|
||||
"no_show_fee_charged": "បានគិតថ្លៃសេវាកម្មមិនមកចូលរួម",
|
||||
"insights": "ព័ត៌មានជ្រៅ",
|
||||
"routing_forms": "បែបបទបញ្ជូន",
|
||||
"testing_workflow_info_message": "នៅពេលសាកល្បងដំណើរការនេះ សូមចំណាំថា អ៊ីមែល និងសារ SMS អាចត្រូវបានកំណត់ពេលយ៉ាងហោចណាស់ 1 ម៉ោងមុន",
|
||||
"insights_no_data_found_for_filter": "មិនមានទិន្នន័យសម្រាប់តម្រង ឬកាលបរិច្ឆេទដែលបានជ្រើស។",
|
||||
"acknowledge_booking_no_show_fee": "ខ្ញុំយល់ព្រមថា ប្រសិនបើខ្ញុំមិនចូលរួមព្រឹត្តិការណ៍នេះ នោះថ្លៃសេវាកម្មមិនមកចូលរួមចំនួន {{amount, currency}} នឹងត្រូវបានគិតលើកាតរបស់ខ្ញុំ។",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "ជ្រើសរើសពេលវេលា",
|
||||
"select_date": "ជ្រើសរើសកាលបរិច្ឆេទ",
|
||||
"connecting_you_to_someone": "យើងកំពុងភ្ជាប់អ្នកទៅនរណាម្នាក់។",
|
||||
"please_do_not_close_this_tab": "សូមកុំបិទផ្ទាំងនេះ",
|
||||
"please_do_not_close_this_tab": "សូមកុំបិទ ឬធ្វើការបន្ទាន់សម័យទំព័រនេះ",
|
||||
"see_all_available_times": "មើលពេលវេលាទាំងអស់ដែលអាចប្រើបាន",
|
||||
"org_team_names_example_1": "ឧ. ក្រុមទីផ្សារ",
|
||||
"org_team_names_example_2": "ឧ. ក្រុមលក់",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "ភ្ជាប់ប្រតិទិន Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "បានភ្ជាប់ប្រតិទិន Outlook",
|
||||
"outlook_connect_atom_loading_label": "កំពុងពិនិត្យប្រតិទិន Outlook",
|
||||
"stripe_connect_atom_label": "ភ្ជាប់ទៅកាន់ Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "បានភ្ជាប់ទៅកាន់ Stripe",
|
||||
"stripe_connect_atom_loading_label": "កំពុងពិនិត្យសមាជិកភាព Stripe",
|
||||
"booking_question_response_variables": "អថេរឆ្លើយសំណួរការកក់",
|
||||
"managed_by_teamAdmins": "គ្រប់គ្រងដោយ {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} ជម្រើស",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "អ្នកមិនមានសិទ្ធិធ្វើការផ្លាស់ប្តូរនេះលើការកក់ទេ",
|
||||
"hide_calendar_event_details": "លាក់ព័ត៌មានលម្អិតនៃព្រឹត្តិការណ៍ប្រចាំប្រតិទិននៅលើប្រតិទិនដែលបានចែករំលែក",
|
||||
"description_hide_calendar_event_details": "នៅពេលដែលប្រតិទិនត្រូវបានចែករំលែក ព្រឹត្តិការណ៍នឹងមើលឃើញបានសម្រាប់អ្នកអាន ប៉ុន្តែព័ត៌មានលម្អិតនឹងត្រូវបានលាក់ពីអ្នកដែលមិនមានសិទ្ធិចូលសរសេរ។",
|
||||
"last_number_of_days": "ចុងក្រោយ {{count}} ថ្ងៃ",
|
||||
"month_to_date": "ខែនេះ",
|
||||
"year_to_date": "ឆ្នាំនេះ",
|
||||
"custom_range": "ជួរដែលកំណត់ដោយខ្លួនឯង",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ បន្ថែមខ្សែអក្សរថ្មីរបស់អ្នកនៅខាងលើនេះ ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "비공개 링크 복사",
|
||||
"copy_private_link_to_event": "이벤트의 비공개 링크 복사",
|
||||
"private_link_description": "{{appName}} 사용자 이름을 노출하지 않고 공유할 비공개 URL 생성",
|
||||
"multiple_private_links_title": "비공개 링크",
|
||||
"multiple_private_links_description": "사용자 이름을 노출하지 않고 비공개 URL을 생성하며, 사용 후에는 삭제됩니다.",
|
||||
"add_a_multiple_private_link": "새 링크 추가",
|
||||
"multiple_private_link_copied": "비공개 링크가 복사되었습니다.",
|
||||
"invitees_can_schedule": "초대받은 사람은 일정을 잡을 수 있습니다.",
|
||||
"date_range": "날짜 범위",
|
||||
"calendar_days": "일",
|
||||
"business_days": "영업일",
|
||||
"set_address_place": "주소 또는 장소 설정",
|
||||
"set_link_meeting": "회의 링크 설정",
|
||||
"managed_event_field_parent_control_disabled": "전환할 수 없습니다. 하위 이벤트 유형에 대해서만 잠금 해제할 수 있습니다.",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}}은 일정을 잡기 전에 초대받은 사람에게 전화번호를 요청합니다.",
|
||||
"cal_provide_google_meet_location": "{{appName}}은 Google Meet 위치를 제공합니다.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}}은 Zoom 회의 URL을 제공합니다.",
|
||||
@@ -2043,7 +2048,8 @@
|
||||
"error_charging_card": "노쇼 수수료를 부과하는 중 문제가 발생했습니다. 나중에 다시 시도해주세요.",
|
||||
"collect_no_show_fee": "노쇼 수수료 청구",
|
||||
"no_show_fee_charged": "노쇼 수수료 부과됨",
|
||||
"insights": "Insights",
|
||||
"insights": "인사이트",
|
||||
"routing_forms": "라우팅 양식",
|
||||
"testing_workflow_info_message": "이 워크플로를 테스트할 때 이메일 및 SMS는 최소 1시간 전에만 예약할 수 있다는 점에 유의하세요",
|
||||
"insights_no_data_found_for_filter": "선택한 필터 또는 날짜에 대한 데이터를 찾을 수 없습니다.",
|
||||
"acknowledge_booking_no_show_fee": "본인은 이 이벤트에 참석하지 않을 경우 {{amount, currency}}에 대한 노쇼 수수료가 내 카드에 부과됨을 인정합니다.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "시간 선택",
|
||||
"select_date": "날짜 선택",
|
||||
"connecting_you_to_someone": "다른 사람과 연결 중입니다.",
|
||||
"please_do_not_close_this_tab": "이 탭을 닫지 마세요",
|
||||
"please_do_not_close_this_tab": "이 페이지를 닫거나 새로 고침하지 마세요",
|
||||
"see_all_available_times": "모든 사용 가능한 시간 보기",
|
||||
"org_team_names_example_1": "예: 마케팅 팀",
|
||||
"org_team_names_example_2": "예: 세일즈 팀",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Outlook 캘린더 연결",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook 캘린더 연결됨",
|
||||
"outlook_connect_atom_loading_label": "Outlook 캘린더 확인 중",
|
||||
"stripe_connect_atom_label": "Stripe에 연결",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe에 연결됨",
|
||||
"stripe_connect_atom_loading_label": "Stripe 자격 증명 확인 중",
|
||||
"booking_question_response_variables": "예약 질문 응답 변수",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}}에 의해 관리됨",
|
||||
"number_of_options": "{{count}}개의 옵션",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "이 예약을 변경할 권한이 없습니다",
|
||||
"hide_calendar_event_details": "공유 캘린더에서 이벤트 세부 정보 숨기기",
|
||||
"description_hide_calendar_event_details": "캘린더가 공유되면 이벤트는 읽기 권한이 있는 사람에게 보이지만, 쓰기 권한이 없는 사람에게는 세부 정보가 숨겨집니다.",
|
||||
"last_number_of_days": "지난 {{count}}일",
|
||||
"month_to_date": "이번 달 현재까지",
|
||||
"year_to_date": "올해 현재까지",
|
||||
"custom_range": "사용자 지정 범위",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ 여기에 새 문자열을 추가하세요 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Privélink kopiëren",
|
||||
"copy_private_link_to_event": "Kopieer privélink naar evenement",
|
||||
"private_link_description": "Genereer een privé-URL om te delen zonder uw {{appName}}-gebruikersnaam te onthullen",
|
||||
"multiple_private_links_title": "Privélinks",
|
||||
"multiple_private_links_description": "Genereer privé-URL's zonder de gebruikersnaam bloot te stellen, die na gebruik worden vernietigd",
|
||||
"add_a_multiple_private_link": "Nieuwe link toevoegen",
|
||||
"multiple_private_link_copied": "Privélink gekopieerd",
|
||||
"invitees_can_schedule": "Genodigden kunnen plannen",
|
||||
"date_range": "Date Range",
|
||||
"calendar_days": "kalender dagen",
|
||||
"business_days": "werkdagen",
|
||||
"set_address_place": "Een adres of plaats instellen",
|
||||
"set_link_meeting": "Stel een link in naar de vergadering",
|
||||
"managed_event_field_parent_control_disabled": "Kan niet worden in- of uitgeschakeld. Kan alleen worden ontgrendeld voor subgebeurtenistypen",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} zal de genodigde om een telefoonnummer vragen.",
|
||||
"cal_provide_google_meet_location": "{{appName}} zal een Google Meet meeting-URL meegeven in de afspraak bevestiging.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} zal een Zoom meeting-URL meegeven in de afspraak bevestiging.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Afwezigheidsvergoeding innen",
|
||||
"no_show_fee_charged": "Afwezigheidsvergoeding in rekening gebracht",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Routeringsformulieren",
|
||||
"testing_workflow_info_message": "Let er bij het testen van deze werkstroom op dat e-mails en sms minimaal 1 uur van tevoren kunnen gepland moeten worden",
|
||||
"insights_no_data_found_for_filter": "Geen gegevens gevonden voor het geselecteerde filter of de geselecteerde datums.",
|
||||
"acknowledge_booking_no_show_fee": "Ik erken dat als ik deze gebeurtenis niet bijwoon, er {{amount, currency}} aan afwezigheidsvergoeding van mijn kaart worden afgeschreven.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Tijd selecteren",
|
||||
"select_date": "Datum selecteren",
|
||||
"connecting_you_to_someone": "We verbinden je met iemand.",
|
||||
"please_do_not_close_this_tab": "Sluit dit tabblad niet.",
|
||||
"please_do_not_close_this_tab": "Sluit of vernieuw deze pagina niet",
|
||||
"see_all_available_times": "Bekijk alle beschikbare tijden",
|
||||
"org_team_names_example_1": "bijvoorbeeld Marketingteam",
|
||||
"org_team_names_example_2": "bijvoorbeeld Verkoopteam",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Agenda verbinden",
|
||||
"outlook_connect_atom_already_connected_label": "Verbonden Outlook-agenda",
|
||||
"outlook_connect_atom_loading_label": "Outlook-agenda controleren",
|
||||
"stripe_connect_atom_label": "Verbinden met Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe verbonden",
|
||||
"stripe_connect_atom_loading_label": "Stripe-gegevens controleren",
|
||||
"booking_question_response_variables": "Variabelen voor boekingsvraagreacties",
|
||||
"managed_by_teamAdmins": "Beheerd door {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opties",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "U bent niet gemachtigd om deze wijziging in de boeking aan te brengen",
|
||||
"hide_calendar_event_details": "Verberg details van kalendergebeurtenissen op gedeelde kalenders",
|
||||
"description_hide_calendar_event_details": "Wanneer een kalender wordt gedeeld, zijn gebeurtenissen zichtbaar voor lezers, maar hun details zijn verborgen voor degenen zonder schrijfrechten.",
|
||||
"last_number_of_days": "laatste {{count}} dagen",
|
||||
"month_to_date": "maand tot nu toe",
|
||||
"year_to_date": "jaar tot nu toe",
|
||||
"custom_range": "aangepast bereik",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Voeg uw nieuwe strings hierboven toe ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Kopier privat lenke",
|
||||
"copy_private_link_to_event": "Kopier privat lenke til hendelse",
|
||||
"private_link_description": "Generer en privat URL for å dele uten å avsløre {{appName}}-brukernavnet ditt",
|
||||
"multiple_private_links_title": "Private lenker",
|
||||
"multiple_private_links_description": "Generer private URL-er uten å avsløre brukernavnet, som vil bli ødelagt etter bruk",
|
||||
"add_a_multiple_private_link": "Legg til ny lenke",
|
||||
"multiple_private_link_copied": "Privat lenke kopiert",
|
||||
"invitees_can_schedule": "Inviterte kan velge tidspunkt",
|
||||
"date_range": "Datointervall",
|
||||
"calendar_days": "kalenderdager",
|
||||
"business_days": "virkedager",
|
||||
"set_address_place": "Angi en adresse eller sted",
|
||||
"set_link_meeting": "Angi en lenke til møtet",
|
||||
"managed_event_field_parent_control_disabled": "Kan ikke slås av/på. Kan kun låses opp for underordnede hendelsestyper",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} vil be den inviterte om å skrive inn et telefonnummer før valg av tidspunkt.",
|
||||
"cal_provide_google_meet_location": "{{appName}} vil oppgi en Google Meet-lokasjon.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} vil angi en URL-adresse for Zoom-møte.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Innkrev gebyr for manglende oppmøte",
|
||||
"no_show_fee_charged": "Gebyr for manglende oppmøte belastet",
|
||||
"insights": "Innsikt",
|
||||
"routing_forms": "Rute skjemaer",
|
||||
"testing_workflow_info_message": "Når du tester denne arbeidsflyten, vær oppmerksom på at e-poster og SMS kun kan planlegges minst 1 time i forveien",
|
||||
"insights_no_data_found_for_filter": "Ingen data funnet for det valgte filteret eller de valgte datoene.",
|
||||
"acknowledge_booking_no_show_fee": "Jeg erkjenner at hvis jeg ikke deltar på dette arrangementet, vil et gebyr på {{amount, currency}} for manglende oppmøte bli belastet kortet mitt.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Velg tid",
|
||||
"select_date": "Velg dato",
|
||||
"connecting_you_to_someone": "Vi kobler deg til noen.",
|
||||
"please_do_not_close_this_tab": "Vennligst ikke lukk denne fanen",
|
||||
"please_do_not_close_this_tab": "Vennligst ikke lukk eller oppdater denne siden",
|
||||
"see_all_available_times": "Se alle tilgjengelige tider",
|
||||
"org_team_names_example_1": "f.eks. Markedsføringsteam",
|
||||
"org_team_names_example_2": "f.eks. Salgsteam",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Koble til Outlook-kalender",
|
||||
"outlook_connect_atom_already_connected_label": "Tilkoblet Outlook-kalender",
|
||||
"outlook_connect_atom_loading_label": "Sjekker Outlook-kalender",
|
||||
"stripe_connect_atom_label": "Koble til Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe tilkoblet",
|
||||
"stripe_connect_atom_loading_label": "Sjekker Stripe-legitimasjon",
|
||||
"booking_question_response_variables": "Variabler for svar på booking-spørsmål",
|
||||
"managed_by_teamAdmins": "Administrert av {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} alternativer",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tillatelse til å gjøre denne endringen i bestillingen",
|
||||
"hide_calendar_event_details": "Skjul kalenderhendelsesdetaljer på delte kalendere",
|
||||
"description_hide_calendar_event_details": "Når en kalender deles, er hendelser synlige for lesere, men detaljene er skjult for de uten skriveadgang.",
|
||||
"last_number_of_days": "siste {{count}} dager",
|
||||
"month_to_date": "måned til dags dato",
|
||||
"year_to_date": "år til dags dato",
|
||||
"custom_range": "tilpasset periode",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Legg til dine nye strenger over her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Skopiuj link prywatny",
|
||||
"copy_private_link_to_event": "Skopiuj prywatny link do wydarzenia",
|
||||
"private_link_description": "Wygeneruj prywatny adres URL do udostępniania bez ujawniania Twojej nazwy użytkownika {{appName}}",
|
||||
"multiple_private_links_title": "Prywatne Linki",
|
||||
"multiple_private_links_description": "Generuj prywatne adresy URL bez ujawniania nazwy użytkownika, które zostaną zniszczone po użyciu",
|
||||
"add_a_multiple_private_link": "Dodaj nowy link",
|
||||
"multiple_private_link_copied": "Prywatny link skopiowany",
|
||||
"invitees_can_schedule": "Zaproszeni mogą planować",
|
||||
"date_range": "Zakres czasu",
|
||||
"calendar_days": "dni kalendarzowe",
|
||||
"business_days": "dni robocze",
|
||||
"set_address_place": "Ustaw adres lub miejsce",
|
||||
"set_link_meeting": "Ustaw link do spotkania",
|
||||
"managed_event_field_parent_control_disabled": "Nie można przełączyć. Może być odblokowane tylko dla typów wydarzeń podrzędnych",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} poprosi swojego zaproszonego o wprowadzenie numeru telefonu przed planowaniem.",
|
||||
"cal_provide_google_meet_location": "{{appName}} zapewni lokalizację Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} zapewni URL spotkania Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Pobierz opłatę za niestawienie się",
|
||||
"no_show_fee_charged": "Pobrano opłatę za niestawienie się",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Formularze routingu",
|
||||
"testing_workflow_info_message": "Podczas testowania tego przepływu pracy pamiętaj, że wiadomości e-mail i SMS można zaplanować jedynie z co najmniej 1-godzinnym wyprzedzeniem",
|
||||
"insights_no_data_found_for_filter": "Nie znaleziono danych dotyczących wybranego filtru lub wskazanych dat.",
|
||||
"acknowledge_booking_no_show_fee": "Rozumiem, że jeśli nie wezmę udziału w wydarzeniu, z mojej karty pobrana zostanie opłata za niestawienie się w wysokości {{amount, currency}}.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Wybierz godzinę",
|
||||
"select_date": "Wybierz datę",
|
||||
"connecting_you_to_someone": "Łączymy Cię z kimś.",
|
||||
"please_do_not_close_this_tab": "Proszę nie zamykać tej karty",
|
||||
"please_do_not_close_this_tab": "Proszę nie zamykać ani nie odświeżać tej strony",
|
||||
"see_all_available_times": "Zobacz wszystkie dostępne godziny",
|
||||
"org_team_names_example_1": "np. zespół marketingowy",
|
||||
"org_team_names_example_2": "np. zespół ds. sprzedaży",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Połącz z Kalendarzem Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Połączono z Kalendarzem Outlook",
|
||||
"outlook_connect_atom_loading_label": "Sprawdzanie kalendarza Outlook",
|
||||
"stripe_connect_atom_label": "Połącz ze Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Połączono ze Stripe",
|
||||
"stripe_connect_atom_loading_label": "Sprawdzanie poświadczeń Stripe",
|
||||
"booking_question_response_variables": "Zmienne odpowiedzi na pytania dotyczące rezerwacji",
|
||||
"managed_by_teamAdmins": "Zarządzane przez {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opcji",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nie masz uprawnień do wprowadzenia tej zmiany w rezerwacji",
|
||||
"hide_calendar_event_details": "Ukryj szczegóły wydarzeń w kalendarzu na udostępnionych kalendarzach",
|
||||
"description_hide_calendar_event_details": "Gdy kalendarz jest udostępniony, wydarzenia są widoczne dla czytelników, ale ich szczegóły są ukryte dla osób bez dostępu do edycji.",
|
||||
"last_number_of_days": "ostatnie {{count}} dni",
|
||||
"month_to_date": "od początku miesiąca",
|
||||
"year_to_date": "od początku roku",
|
||||
"custom_range": "zakres niestandardowy",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodaj nowe ciągi powyżej ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Copiar link privado",
|
||||
"copy_private_link_to_event": "Copiar link privado para evento",
|
||||
"private_link_description": "Gere um URL privado para compartilhar sem expor seu nome de usuário do {{appName}}",
|
||||
"multiple_private_links_title": "Links Privados",
|
||||
"multiple_private_links_description": "Gere URLs privadas sem expor o nome de usuário, que serão destruídas após o uso",
|
||||
"add_a_multiple_private_link": "Adicionar novo link",
|
||||
"multiple_private_link_copied": "Link privado copiado",
|
||||
"invitees_can_schedule": "Os convidados podem agendar",
|
||||
"date_range": "Intervalo de Datas",
|
||||
"calendar_days": "dias da agenda",
|
||||
"business_days": "dias úteis",
|
||||
"set_address_place": "Defina um endereço ou local",
|
||||
"set_link_meeting": "Defina um link para a reunião",
|
||||
"managed_event_field_parent_control_disabled": "Não pode ser alternado. Só pode ser desbloqueado para tipos de eventos filhos",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} solicitará ao seu convidado que insira um número de telefone antes de agendar.",
|
||||
"cal_provide_google_meet_location": "{{appName}} fornecerá um link para o Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} fornecerá uma URL de reunião do Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Coletar taxa de não comparecimento",
|
||||
"no_show_fee_charged": "Taxa de não comparecimento cobrada",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Formulários de Roteamento",
|
||||
"testing_workflow_info_message": "Ao testar este fluxo de trabalho, não se esqueça de que e-mails e SMS só podem ser reprogramados com pelo menos uma hora de antecedência",
|
||||
"insights_no_data_found_for_filter": "Nenhum dado encontrado para o filtro ou dados selecionados.",
|
||||
"acknowledge_booking_no_show_fee": "Estou ciente de que, se eu não comparecer a este evento, será cobrada no meu cartão uma taxa de não comparecimento de {{amount, currency}}.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Selecione o horário",
|
||||
"select_date": "Selecione a data",
|
||||
"connecting_you_to_someone": "Estamos conectando você a alguém.",
|
||||
"please_do_not_close_this_tab": "Por favor, não feche esta aba",
|
||||
"please_do_not_close_this_tab": "Por favor, não feche ou atualize esta página",
|
||||
"see_all_available_times": "Veja todos os horários disponíveis",
|
||||
"org_team_names_example_1": "ex.: Time de Marketing",
|
||||
"org_team_names_example_2": "ex: Time de Vendas",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Conectar Calendário Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendário Outlook conectado",
|
||||
"outlook_connect_atom_loading_label": "Verificando Calendário Outlook",
|
||||
"stripe_connect_atom_label": "Conectar ao Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe Conectado",
|
||||
"stripe_connect_atom_loading_label": "Verificando credenciais do Stripe",
|
||||
"booking_question_response_variables": "Variáveis de resposta de perguntas de reserva",
|
||||
"managed_by_teamAdmins": "Gerenciado por {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opções",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Você não tem autorização para fazer essa alteração na reserva",
|
||||
"hide_calendar_event_details": "Ocultar detalhes dos eventos do calendário em calendários compartilhados",
|
||||
"description_hide_calendar_event_details": "Quando um calendário é compartilhado, os eventos são visíveis para os leitores, mas seus detalhes são ocultados para aqueles que não têm acesso de escrita.",
|
||||
"last_number_of_days": "últimos {{count}} dias",
|
||||
"month_to_date": "mês até a data",
|
||||
"year_to_date": "ano até a data",
|
||||
"custom_range": "intervalo personalizado",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adicione suas novas strings aqui em cima ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Copiar ligação privada",
|
||||
"copy_private_link_to_event": "Copiar ligação privada para o evento",
|
||||
"private_link_description": "Crie um URL privado para partilhar sem expor o seu nome de utilizador do {{appName}}",
|
||||
"multiple_private_links_title": "Links Privados",
|
||||
"multiple_private_links_description": "Gere URLs privadas sem expor o nome de utilizador, que serão destruídas após serem usadas",
|
||||
"add_a_multiple_private_link": "Adicionar novo link",
|
||||
"multiple_private_link_copied": "Link privado copiado",
|
||||
"invitees_can_schedule": "Os convidados podem agendar",
|
||||
"date_range": "Intervalo de Datas",
|
||||
"calendar_days": "dias da agenda",
|
||||
"business_days": "dias úteis",
|
||||
"set_address_place": "Defina um endereço ou local",
|
||||
"set_link_meeting": "Definir uma ligação para a reunião",
|
||||
"managed_event_field_parent_control_disabled": "Não pode ser alternado. Só pode ser desbloqueado para tipos de eventos filhos",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} solicitará ao seu convidado que insira um número de telefone antes de agendar.",
|
||||
"cal_provide_google_meet_location": "O {{appName}} irá fornecer uma localização do Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "O {{appName}} irá fornecer um URL de reunião do Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Cobrar taxa de não-comparência",
|
||||
"no_show_fee_charged": "A taxa de não-comparência debitada",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Formulários de Roteamento",
|
||||
"testing_workflow_info_message": "Ao testar este fluxo de trabalho tenha em consideração que e-mails e SMS só podem ser agendados com, pelo menos, 1 hora de antecedência",
|
||||
"insights_no_data_found_for_filter": "Não foram encontrados dados para as datas ou filtros selecionados.",
|
||||
"acknowledge_booking_no_show_fee": "Eu aceito que caso eu não compareça a este evento, será cobrada uma taxa de não-comparência de {{amount, currency}} no meu cartão.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Selecionar horário",
|
||||
"select_date": "Selecionar data",
|
||||
"connecting_you_to_someone": "Estamos a conectá-lo a alguém.",
|
||||
"please_do_not_close_this_tab": "Não feche este separador",
|
||||
"please_do_not_close_this_tab": "Por favor, não feche ou atualize esta página",
|
||||
"see_all_available_times": "Ver todos os horários disponíveis",
|
||||
"org_team_names_example_1": "Por exemplo, Equipa de Marketing",
|
||||
"org_team_names_example_2": "Por exemplo, Equipa de Vendas",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Conectar Calendário Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendário Outlook Conectado",
|
||||
"outlook_connect_atom_loading_label": "Verificando Calendário Outlook",
|
||||
"stripe_connect_atom_label": "Conectar ao Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe Conectado",
|
||||
"stripe_connect_atom_loading_label": "Verificando credenciais do Stripe",
|
||||
"booking_question_response_variables": "Variáveis de resposta às perguntas de reserva",
|
||||
"managed_by_teamAdmins": "Gerido por {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opções",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Não tem autorização para fazer esta alteração na reserva",
|
||||
"hide_calendar_event_details": "Ocultar detalhes dos eventos do calendário em calendários partilhados",
|
||||
"description_hide_calendar_event_details": "Quando um calendário é partilhado, os eventos são visíveis para os leitores, mas os seus detalhes são ocultados para aqueles que não têm acesso de escrita.",
|
||||
"last_number_of_days": "últimos {{count}} dias",
|
||||
"month_to_date": "mês até à data",
|
||||
"year_to_date": "ano até à data",
|
||||
"custom_range": "intervalo personalizado",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Copiere link privat",
|
||||
"copy_private_link_to_event": "Copiază linkul privat către eveniment",
|
||||
"private_link_description": "Generați un URL privat pentru distribuire fără expunerea numelui de utilizator {{appName}}",
|
||||
"multiple_private_links_title": "Linkuri Private",
|
||||
"multiple_private_links_description": "Generează URL-uri private fără a expune numele de utilizator, care vor fi distruse după utilizare",
|
||||
"add_a_multiple_private_link": "Adaugă un link nou",
|
||||
"multiple_private_link_copied": "Linkul privat a fost copiat",
|
||||
"invitees_can_schedule": "Invitații pot programa",
|
||||
"date_range": "Interval de date",
|
||||
"calendar_days": "zile calendaristice",
|
||||
"business_days": "zile lucrătoare",
|
||||
"set_address_place": "Setați o adresă sau un loc",
|
||||
"set_link_meeting": "Definiți un link pentru ședință",
|
||||
"managed_event_field_parent_control_disabled": "Nu poate fi comutat. Poate fi deblocat doar pentru tipurile de evenimente copil",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} va cere invitatului dvs. să introducă un număr de telefon înainte de programare.",
|
||||
"cal_provide_google_meet_location": "{{appName}} va oferi o locație Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} va oferi un URL pentru ședința de Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Colectare taxă de neprezentare",
|
||||
"no_show_fee_charged": "Taxă de neprezentare percepută",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Formulare de Rutare",
|
||||
"testing_workflow_info_message": "La testarea acestui flux de lucru, țineți cont de faptul că e-mailurile și SMS-urile pot fi programate doar cu cel puțin o oră în prealabil",
|
||||
"insights_no_data_found_for_filter": "Nu s-au găsit date pentru filtrul selectat sau pentru datele selectate.",
|
||||
"acknowledge_booking_no_show_fee": "Dacă nu particip la acest eveniment, accept să mi se perceapă de pe card o taxă de neprezentare în valoare de {{amount, currency}}.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Selectați ora",
|
||||
"select_date": "Selectați data",
|
||||
"connecting_you_to_someone": "Te conectăm cu cineva.",
|
||||
"please_do_not_close_this_tab": "Te rugăm să nu închizi acest tab",
|
||||
"please_do_not_close_this_tab": "Vă rugăm să nu închideți sau să reîmprospătați această pagină",
|
||||
"see_all_available_times": "Vedeți toate orele disponibile",
|
||||
"org_team_names_example_1": "de ex. echipa de marketing",
|
||||
"org_team_names_example_2": "de ex. echipa de vânzări",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Conectează Calendar Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Calendar Outlook Conectat",
|
||||
"outlook_connect_atom_loading_label": "Verificare Calendar Outlook",
|
||||
"stripe_connect_atom_label": "Conectează-te la Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe conectat",
|
||||
"stripe_connect_atom_loading_label": "Verificarea acreditărilor Stripe",
|
||||
"booking_question_response_variables": "Variabile de răspuns la întrebările de rezervare",
|
||||
"managed_by_teamAdmins": "Gestionat de {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opțiuni",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nu aveți autorizația necesară pentru a face această modificare în rezervare",
|
||||
"hide_calendar_event_details": "Ascunde detaliile evenimentelor din calendar pe calendarele partajate",
|
||||
"description_hide_calendar_event_details": "Când un calendar este partajat, evenimentele sunt vizibile pentru cititori, dar detaliile lor sunt ascunse celor fără acces de scriere.",
|
||||
"last_number_of_days": "ultimele {{count}} zile",
|
||||
"month_to_date": "luna până în prezent",
|
||||
"year_to_date": "anul până în prezent",
|
||||
"custom_range": "interval personalizat",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adăugați stringurile noi deasupra acestui rând ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Kopiraj privatni link",
|
||||
"copy_private_link_to_event": "Kopiraj privatni link do događaja",
|
||||
"private_link_description": "Generiši privatni URL za deljenje bez prikazivanja vašeg {{appName}} korisničkog imena",
|
||||
"multiple_private_links_title": "Privatni linkovi",
|
||||
"multiple_private_links_description": "Generišite privatne URL adrese bez otkrivanja korisničkog imena, koje će biti uništene nakon korišćenja",
|
||||
"add_a_multiple_private_link": "Dodaj novi link",
|
||||
"multiple_private_link_copied": "Privatni link je kopiran",
|
||||
"invitees_can_schedule": "Pozvane osobe mogu da rezervišu",
|
||||
"date_range": "Raspon Datuma",
|
||||
"calendar_days": "kalendarska dana",
|
||||
"business_days": "radna dana",
|
||||
"set_address_place": "Postavite adresu ili mesto",
|
||||
"set_link_meeting": "Postavi link za sastanak",
|
||||
"managed_event_field_parent_control_disabled": "Ne može se menjati. Može se otključati samo za podređene tipove događaja",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} će pitati pozvane da unesu broj telefona pre rezervisanja.",
|
||||
"cal_provide_google_meet_location": "{{appName}} će obezbediti Google Meet link.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} će obezbediti URL Zoom sastanka.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Prikupi naknadu za izostanak",
|
||||
"no_show_fee_charged": "Naplaćena je naknada za izostanak",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Obrasci za usmeravanje",
|
||||
"testing_workflow_info_message": "Kada testirate ovaj radni tok, imajte na umu da imejlovi i SMS-ovi mogu da budu zakazani najmanje 1 sat unapred",
|
||||
"insights_no_data_found_for_filter": "Nisu pronađeni podaci za izabrani filter ili izabrane datume.",
|
||||
"acknowledge_booking_no_show_fee": "Jasno mi je da će mi biti naplaćena naknada za izostanak putem kartice u iznosu od {{amount, currency}} ukoliko ne prisustvujem ovom događaju.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Izaberite vreme",
|
||||
"select_date": "Izaberite datum",
|
||||
"connecting_you_to_someone": "Povezujemo vas sa nekim.",
|
||||
"please_do_not_close_this_tab": "Molimo vas da ne zatvarate ovu karticu",
|
||||
"please_do_not_close_this_tab": "Молимо вас да не затварате или освежавате ову страницу",
|
||||
"see_all_available_times": "Pogledajte sva dostupna vremena",
|
||||
"org_team_names_example_1": "npr. Marketing tim",
|
||||
"org_team_names_example_2": "npr. Prodajni tim",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Poveži Outlook kalendar",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook kalendar povezan",
|
||||
"outlook_connect_atom_loading_label": "Provera Outlook kalendara",
|
||||
"stripe_connect_atom_label": "Повежите се са Stripe-ом",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe је повезан",
|
||||
"stripe_connect_atom_loading_label": "Провера Stripe акредитива",
|
||||
"booking_question_response_variables": "Varijable odgovora na pitanja za rezervaciju",
|
||||
"managed_by_teamAdmins": "Upravljaju {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} opcija",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nemate ovlašćenje da izvršite ovu promenu u rezervaciji",
|
||||
"hide_calendar_event_details": "Sakrij detalje događaja u deljenim kalendarima",
|
||||
"description_hide_calendar_event_details": "Kada je kalendar podeljen, događaji su vidljivi čitaocima, ali su njihovi detalji skriveni od onih koji nemaju pravo izmene.",
|
||||
"last_number_of_days": "poslednjih {{count}} dana",
|
||||
"month_to_date": "od početka meseca do danas",
|
||||
"year_to_date": "od početka godine do danas",
|
||||
"custom_range": "prilagođeni opseg",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Dodajte svoje nove stringove iznad ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Kopiera privat länk",
|
||||
"copy_private_link_to_event": "Kopiera privat länk till händelse",
|
||||
"private_link_description": "Generera en privat URL att dela utan att exponera ditt {{appName}}-användarnamn",
|
||||
"multiple_private_links_title": "Privata länkar",
|
||||
"multiple_private_links_description": "Generera privata URL:er utan att exponera användarnamnet, som kommer att förstöras efter användning",
|
||||
"add_a_multiple_private_link": "Lägg till ny länk",
|
||||
"multiple_private_link_copied": "Privat länk kopierad",
|
||||
"invitees_can_schedule": "Inbjudna kan schemalägga",
|
||||
"date_range": "Datumintervall",
|
||||
"calendar_days": "kalenderdagar",
|
||||
"business_days": "arbetsdagar",
|
||||
"set_address_place": "Ange adress eller plats",
|
||||
"set_link_meeting": "Ange en länk till mötet",
|
||||
"managed_event_field_parent_control_disabled": "Kan inte växlas. Det kan endast låsas upp för underordnade händelsetyper",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} kommer be den inbjudna att ange ett telefonnummer innan schemaläggning.",
|
||||
"cal_provide_google_meet_location": "{{appName}} kommer att generera en Google Meet länk.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} kommer att generera en länk för Zoom-möte.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Samla in avgift för utebliven närvaro",
|
||||
"no_show_fee_charged": "Avgift för utebliven närvaro har debiterats",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Routningsformulär",
|
||||
"testing_workflow_info_message": "När det här arbetsflödet testas ska du vara medveten om att e-postmeddelanden och SMS endast kan schemaläggas minst en timme i förväg",
|
||||
"insights_no_data_found_for_filter": "Inga data hittades för valt filter eller valda datum.",
|
||||
"acknowledge_booking_no_show_fee": "Jag bekräftar att en avgift på {{amount, currency}} för utebliven närvaro debiteras mitt kort om jag inte deltar i händelsen.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Välj tid",
|
||||
"select_date": "Välj datum",
|
||||
"connecting_you_to_someone": "Vi kopplar dig till någon.",
|
||||
"please_do_not_close_this_tab": "Stäng inte denna flik",
|
||||
"please_do_not_close_this_tab": "Vänligen stäng eller uppdatera inte den här sidan",
|
||||
"see_all_available_times": "Se alla tillgängliga tider",
|
||||
"org_team_names_example_1": "t.ex. marknadsföringsteam",
|
||||
"org_team_names_example_2": "t.ex. säljteam",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Anslut Outlook Kalender",
|
||||
"outlook_connect_atom_already_connected_label": "Ansluten Outlook Kalender",
|
||||
"outlook_connect_atom_loading_label": "Kontrollerar Outlook Kalender",
|
||||
"stripe_connect_atom_label": "Anslut till Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe ansluten",
|
||||
"stripe_connect_atom_loading_label": "Kontrollerar Stripe-uppgifter",
|
||||
"booking_question_response_variables": "Variabler för bokningsfrågesvar",
|
||||
"managed_by_teamAdmins": "Hanteras av {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} alternativ",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har inte behörighet att göra denna ändring i bokningen",
|
||||
"hide_calendar_event_details": "Dölj kalenderhändelsedetaljer på delade kalendrar",
|
||||
"description_hide_calendar_event_details": "När en kalender delas är händelser synliga för läsare, men deras detaljer är dolda för dem utan skrivåtkomst.",
|
||||
"last_number_of_days": "senaste {{count}} dagarna",
|
||||
"month_to_date": "månad till datum",
|
||||
"year_to_date": "år till datum",
|
||||
"custom_range": "anpassat intervall",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Özel bağlantıyı kopyala",
|
||||
"copy_private_link_to_event": "Etkinliğe özel bağlantıyı kopyala",
|
||||
"private_link_description": "{{appName}} kullanıcı adınızı göstermeden paylaşmak için özel bir URL oluşturun",
|
||||
"multiple_private_links_title": "Özel Bağlantılar",
|
||||
"multiple_private_links_description": "Kullanıcı adını açığa çıkarmadan özel URL'ler oluşturun, bu bağlantılar kullanıldıktan sonra yok edilecektir",
|
||||
"add_a_multiple_private_link": "Yeni bağlantı ekle",
|
||||
"multiple_private_link_copied": "Özel bağlantı kopyalandı",
|
||||
"invitees_can_schedule": "Davetliler planlayabilir",
|
||||
"date_range": "Tarih Aralığı",
|
||||
"calendar_days": "takvim günü",
|
||||
"business_days": "iş günü",
|
||||
"set_address_place": "Bir adres veya yer belirleyin",
|
||||
"set_link_meeting": "Toplantı için bir bağlantı ayarlayın",
|
||||
"managed_event_field_parent_control_disabled": "Değiştirilemez. Sadece alt etkinlik türleri için kilidi açılabilir",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}}, planlamadan önce davetlinizden bir telefon numarası girmesini isteyecektir.",
|
||||
"cal_provide_google_meet_location": "{{appName}}, bir Google Meet konumu sağlayacaktır.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}}, bir Zoom toplantı URL'si sağlayacaktır.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Katılmama ücretini tahsil edin",
|
||||
"no_show_fee_charged": "Katılmama ücreti tahsil edildi",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Yönlendirme Formları",
|
||||
"testing_workflow_info_message": "Bu iş akışını test ederken, E-postaların ve SMS'lerin yalnızca en az 1 saat önceden planlanabileceğini unutmayın",
|
||||
"insights_no_data_found_for_filter": "Seçili filtre veya tarihler için veri bulunamadı.",
|
||||
"acknowledge_booking_no_show_fee": "Bu etkinliğe katılmadığım takdirde kartımdan {{amount, currency}} tutarında katılmama ücreti alınmasını kabul ediyorum.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Saati Seçin",
|
||||
"select_date": "Tarihi Seçin",
|
||||
"connecting_you_to_someone": "Sizi birine bağlıyoruz.",
|
||||
"please_do_not_close_this_tab": "Lütfen bu sekmeyi kapatmayın",
|
||||
"please_do_not_close_this_tab": "Lütfen bu sayfayı kapatmayın veya yenilemeyin",
|
||||
"see_all_available_times": "Tüm müsait saatleri görün",
|
||||
"org_team_names_example_1": "Örneğin. Pazarlama ekibi",
|
||||
"org_team_names_example_2": "Örn. Satış Ekibi",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Outlook Takvimi Bağla",
|
||||
"outlook_connect_atom_already_connected_label": "Outlook Takvimi Bağlı",
|
||||
"outlook_connect_atom_loading_label": "Outlook Takvimi Kontrol Ediliyor",
|
||||
"stripe_connect_atom_label": "Stripe'a Bağlan",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe Bağlı",
|
||||
"stripe_connect_atom_loading_label": "Stripe kimlik bilgileri kontrol ediliyor",
|
||||
"booking_question_response_variables": "Rezervasyon soru yanıt değişkenleri",
|
||||
"managed_by_teamAdmins": "{{teamAdmins}} tarafından yönetiliyor",
|
||||
"number_of_options": "{{count}} seçenek",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bu rezervasyonda değişiklik yapma yetkiniz yok",
|
||||
"hide_calendar_event_details": "Paylaşılan takvimlerde etkinlik ayrıntılarını gizle",
|
||||
"description_hide_calendar_event_details": "Bir takvim paylaşıldığında, etkinlikler okuyuculara görünür ancak yazma erişimi olmayanlar için ayrıntılar gizlenir.",
|
||||
"last_number_of_days": "son {{count}} gün",
|
||||
"month_to_date": "ayın başından bugüne",
|
||||
"year_to_date": "yılın başından bugüne",
|
||||
"custom_range": "özel aralık",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni dizelerinizi yukarıya ekleyin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Копіювати приватне посилання",
|
||||
"copy_private_link_to_event": "Скопіювати приватне посилання на подію",
|
||||
"private_link_description": "Створіть приватну URL-адресу, щоб поширювати її, не виказуючи свого імені користувача в {{appName}}",
|
||||
"multiple_private_links_title": "Приватні посилання",
|
||||
"multiple_private_links_description": "Створюйте приватні URL-адреси без розкриття імені користувача, які будуть знищені після використання",
|
||||
"add_a_multiple_private_link": "Додати нове посилання",
|
||||
"multiple_private_link_copied": "Приватне посилання скопійовано",
|
||||
"invitees_can_schedule": "Запрошені можуть планувати",
|
||||
"date_range": "Діапазон дат",
|
||||
"calendar_days": "календарні дні",
|
||||
"business_days": "робочі дні",
|
||||
"set_address_place": "Укажіть адресу або місце",
|
||||
"set_link_meeting": "Створити посилання на нараду",
|
||||
"managed_event_field_parent_control_disabled": "Не можна перемикати. Можна розблокувати лише для дочірніх типів подій",
|
||||
"cal_invitee_phone_number_scheduling": "У {{appName}} запрошеним знадобиться ввести номер телефону, перш ніж планувати щось.",
|
||||
"cal_provide_google_meet_location": "{{appName}} надасть розташування Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} надасть URL-адресу наради Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Стягнути плату за відсутність",
|
||||
"no_show_fee_charged": "Стягнуто плату за відсутність",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Форми маршрутизації",
|
||||
"testing_workflow_info_message": "Тестуючи цей робочий процес, зважайте на те, що надсилання електронних листів і SMS-повідомлень можна запланувати щонайменше за 1 годину",
|
||||
"insights_no_data_found_for_filter": "Не знайдено жодних даних для вибраного фільтра або вибраних дат.",
|
||||
"acknowledge_booking_no_show_fee": "Я усвідомлюю, що в разі моєї відсутності на цьому заході з моєї картки буде стягнуто плату в розмірі {{amount, currency}}.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Виберіть час",
|
||||
"select_date": "Виберіть дату",
|
||||
"connecting_you_to_someone": "Ми з'єднуємо вас з кимось.",
|
||||
"please_do_not_close_this_tab": "Будь ласка, не закривайте цю вкладку",
|
||||
"please_do_not_close_this_tab": "Будь ласка, не закривайте і не оновлюйте цю сторінку",
|
||||
"see_all_available_times": "Переглянути доступні часові проміжки",
|
||||
"org_team_names_example_1": "напр. команда маркетингу",
|
||||
"org_team_names_example_2": "напр. відділ продажів",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Підключити календар Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Календар Outlook підключено",
|
||||
"outlook_connect_atom_loading_label": "Перевірка календаря Outlook",
|
||||
"stripe_connect_atom_label": "Підключитися до Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Stripe підключено",
|
||||
"stripe_connect_atom_loading_label": "Перевірка облікових даних Stripe",
|
||||
"booking_question_response_variables": "Змінні відповідей на питання бронювання",
|
||||
"managed_by_teamAdmins": "Керується {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} варіантів",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Ви не маєте дозволу на внесення цієї зміни до бронювання",
|
||||
"hide_calendar_event_details": "Приховати деталі подій календаря у спільних календарях",
|
||||
"description_hide_calendar_event_details": "Коли календар спільний, події видимі для читачів, але їхні деталі приховані для тих, хто не має прав на редагування.",
|
||||
"last_number_of_days": "останні {{count}} днів",
|
||||
"month_to_date": "з початку місяця",
|
||||
"year_to_date": "з початку року",
|
||||
"custom_range": "власний діапазон",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -876,12 +876,17 @@
|
||||
"copy_private_link": "Sao chép liên kết riêng",
|
||||
"copy_private_link_to_event": "Sao chép liên kết riêng tư đến sự kiện",
|
||||
"private_link_description": "Tạo một URL riêng tư để chia sẻ mà không tiết lộ tên người dùng {{appName}} của bạn",
|
||||
"multiple_private_links_title": "Liên kết Riêng tư",
|
||||
"multiple_private_links_description": "Tạo các URL riêng tư mà không tiết lộ tên người dùng, sẽ bị hủy sau khi sử dụng",
|
||||
"add_a_multiple_private_link": "Thêm liên kết mới",
|
||||
"multiple_private_link_copied": "Liên kết riêng tư đã được sao chép",
|
||||
"invitees_can_schedule": "Người được mời có thể lên lịch hẹn",
|
||||
"date_range": "Phạm vi ngày",
|
||||
"calendar_days": "ngày",
|
||||
"business_days": "ngày làm việc",
|
||||
"set_address_place": "Đặt địa chỉ hoặc địa điểm",
|
||||
"set_link_meeting": "Đặt liên kết đến cuộc họp",
|
||||
"managed_event_field_parent_control_disabled": "Không thể chuyển đổi. Chỉ có thể mở khóa cho các loại sự kiện con",
|
||||
"cal_invitee_phone_number_scheduling": "{{appName}} sẽ yêu cầu người được mời nhập số điện thoại trước khi lên lịch.",
|
||||
"cal_provide_google_meet_location": "{{appName}} sẽ cung cấp URL Google Meet.",
|
||||
"cal_provide_zoom_meeting_url": "{{appName}} sẽ cung cấp một URL cuộc họp Zoom.",
|
||||
@@ -2044,6 +2049,7 @@
|
||||
"collect_no_show_fee": "Thu phí vắng mặt",
|
||||
"no_show_fee_charged": "Phí vắng mặt đã tính",
|
||||
"insights": "Insights",
|
||||
"routing_forms": "Biểu mẫu Định tuyến",
|
||||
"testing_workflow_info_message": "Khi kiểm tra tiến độ công việc này, hãy để ý rằng Email và SMS chỉ có thể được đặt lịch trước ít nhất 1 giờ",
|
||||
"insights_no_data_found_for_filter": "Không tìm thấy dữ liệu cho bộ lọc hay ngày đã chọn.",
|
||||
"acknowledge_booking_no_show_fee": "Tôi công nhận là nếu tôi không tham dự sự kiện này thì một khoản phí vắng mặt là {{amount, currency}} sẽ được áp dụng cho thẻ của tôi.",
|
||||
@@ -2168,7 +2174,7 @@
|
||||
"select_time": "Chọn thời gian",
|
||||
"select_date": "Chọn ngày",
|
||||
"connecting_you_to_someone": "Chúng tôi đang kết nối bạn với ai đó.",
|
||||
"please_do_not_close_this_tab": "Vui lòng không đóng tab này",
|
||||
"please_do_not_close_this_tab": "Vui lòng không đóng hoặc làm mới trang này",
|
||||
"see_all_available_times": "Xem tất cả những thời gian trống",
|
||||
"org_team_names_example_1": "ví dụ Nhóm Tiếp thị",
|
||||
"org_team_names_example_2": "ví dụ Nhóm Kinh doanh",
|
||||
@@ -2601,6 +2607,9 @@
|
||||
"outlook_connect_atom_label": "Kết nối Lịch Outlook",
|
||||
"outlook_connect_atom_already_connected_label": "Đã kết nối Lịch Outlook",
|
||||
"outlook_connect_atom_loading_label": "Đang kiểm tra Lịch Outlook",
|
||||
"stripe_connect_atom_label": "Kết nối với Stripe",
|
||||
"stripe_connect_atom_already_connected_label": "Đã kết nối với Stripe",
|
||||
"stripe_connect_atom_loading_label": "Đang kiểm tra thông tin Stripe",
|
||||
"booking_question_response_variables": "Các biến phản hồi câu hỏi đặt lịch",
|
||||
"managed_by_teamAdmins": "Được quản lý bởi {{teamAdmins}}",
|
||||
"number_of_options": "{{count}} tùy chọn",
|
||||
@@ -2614,5 +2623,9 @@
|
||||
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bạn không có quyền thực hiện thay đổi này đối với đặt chỗ",
|
||||
"hide_calendar_event_details": "Ẩn chi tiết sự kiện trên lịch chia sẻ",
|
||||
"description_hide_calendar_event_details": "Khi một lịch được chia sẻ, các sự kiện sẽ hiển thị cho người xem nhưng chi tiết của chúng sẽ bị ẩn đối với những người không có quyền chỉnh sửa.",
|
||||
"last_number_of_days": "{{count}} ngày qua",
|
||||
"month_to_date": "từ đầu tháng đến nay",
|
||||
"year_to_date": "từ đầu năm đến nay",
|
||||
"custom_range": "phạm vi tùy chỉnh",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user