Compare commits

...
23 Commits
Author SHA1 Message Date
Omar López 32aaa26bf1 chore: v4.7.4 (#17728)
release.yml / release (push) Canceled after 0s
2024-11-18 13:12:53 -07:00
Omar López 7c0ee9f186 revert: "feat: Populate gCal calendar cache via webhooks (#11928)"" (#17682) 2024-11-18 19:48:33 +00:00
Benny Joo 50df22928b fix: correct domain for link column in org member table (#17726) 2024-11-18 18:40:24 +00:00
Calcom BotandReplexica fbda8a38cb feat: update translations via @replexica (#17722)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-18 14:56:01 +00:00
8194ffcd07 feat: Autofill feature (#15437)
* feat: Autofill feature

* Revert "feat: Autofill feature"

This reverts commit ac94257cbb516e140ed550ae804e725ca5d036ca.

* feat: store prev response in localStorage

* review fixes

* add tests

* remove unused import

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-11-18 20:04:02 +05:30
VijayandAnik Dhabal Babu 7b94822c8d fix: add default schedule while creating org member (#16855)
* add default schedule while creating org user

* naming as per review comments

* added e2e test

* move bookTeamEvent and expectPageNotFound to testUtils

* Update EventTeamAssignmentTab.tsx

* Update AddMembersWithSwitch.tsx

* Update utils.ts

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-18 20:03:24 +05:30
Syed Ali Shahbaz 9aa8f8a30c Update oauth.mdx (#17671) 2024-11-18 14:29:33 +00:00
Rajiv Sahal e206ead611 fix: calendar settings atom modifications (#17579)
* update selected calendars display when no calendar connected

* remove no calendar connected message
2024-11-18 11:05:34 -03:00
Udit Takkar ecac331fd1 perf: get event types (#17689)
* refactor: get event types

* chore

* test: add team and managed team event types

* perf: use Promise.all

* fix: type err
2024-11-18 11:05:12 -03:00
sean-brydon fb4cd7f0e4 feat: Add column visibility filters to routing insights table (#17715)
* add table as a child callback to we can pass it to columnVisibility

* add back failed bookings component
2024-11-18 11:03:58 -03:00
Rajiv Sahal f743db9eaa feat: enable all users to create platform team (#17707)
* add extra checks for platform to make sure user is genuine

* make slug unique for every team
2024-11-18 11:03:23 -03:00
Udit Takkar 0f3773d724 feat: add instant meeting parameter (#17647)
* feat: add instant meeting parameter

* feat: show modal only when parameter present

* chore: remove logs

* fix: type err

* fix: UI

* fix: remove useFieldArray
2024-11-18 19:29:10 +05:30
Eunjae Lee e6633050ba fix: provide explicit column width for member list (#17718) 2024-11-18 13:43:14 +00:00
Replexica Bot f1e972a213 feat: update translations via @replexica (#17712) 2024-11-18 13:01:07 +00:00
Morgan 12e78a7350 chore: add default reading direction to atoms wrapper (#17710) 2024-11-18 12:25:11 +00:00
Amit Sharmaandsean-brydon 277b3e0cf5 feat: DataTable component should use a "key" of data as the selection Key. (#17714)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-11-18 10:25:52 +00:00
Lauris Skraucis 2b1041460c feat: v2 subsequent recurring booking cancellation (#17645)
* refactor: cancelling all remaining seated recurring bookings not possible

* chore: document cancel booking endpoint

* chore: remove unused imports

* feat: cancel subsequent bookigns

* fix: flaky test

* refactor: getCanceledSubsequentBookings

* refactor: after cancellation return whole recurrence sequence
2024-11-18 08:17:48 +00:00
Keith Williams ce0d685849 chore: Remove faulty languages for Replexica (#17705) 2024-11-18 02:58:16 +00:00
cc5ce4e598 feat: update translations via @replexica (#17704)
* chore: sync with main

* feat: update translations via @replexica

* Update common.json

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-18 02:21:52 +00:00
Keith Williams 4123152bd5 feat: Added more supported languages (#17702) 2024-11-18 01:09:35 +00:00
Keith Williams 1b4469f6e5 chore: v4.7.3 (#17701)
release.yml / release (push) Canceled after 0s
2024-11-17 21:58:45 -03:00
Benny JooandKeith Williams 5401bcca4e feat: AI description - DB model + frontend + backend (fetch only) (#17651)
* feat: AI description - DB model + frontend + backend (fetch only)

* fix types and add validation to backend

* improve log

* improve

* import type

* fix replexica error

* fix

* fix test

* update replexica type

* Renamed descriptionTranslations to fieldTranslations

* Moved the eventTypeId column to 2nd

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-18 00:16:56 +00:00
Benny Joo f9afca2939 feat: Add link column to CSV file for org members (#17695)
* improve spacing

* feat: Add link column to CSV file for org members

* update tests
2024-11-17 10:53:43 +00:00
144 changed files with 13470 additions and 901 deletions
+7
View File
@@ -129,6 +129,10 @@ GOOGLE_LOGIN_ENABLED=false
# Needed to enable Google Calendar integration and Login with Google
# @see https://github.com/calcom/cal.com#obtaining-the-google-api-credentials
GOOGLE_API_CREDENTIALS=
# Token to verify incoming webhooks from Google Calendar
GOOGLE_WEBHOOK_TOKEN=
# Optional URL to override for tunelling webhooks. Defaults to NEXT_PUBLIC_WEBAPP_URL.
GOOGLE_WEBHOOK_URL=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
@@ -408,3 +412,6 @@ NEXT_PUBLIC_WEBSITE_TERMS_URL=
# NEXT_PUBLIC_LOGGER_LEVEL=3 sets to log info, warn, error and fatal logs.
# [0: silly & upwards, 1: trace & upwards, 2: debug & upwards, 3: info & upwards, 4: warn & upwards, 5: error & fatal, 6: fatal]
NEXT_PUBLIC_LOGGER_LEVEL=
# Used to use Replexica SDK, a tool for real-time AI-powered localization
REPLEXICA_API_KEY=
+1 -1
View File
@@ -29,7 +29,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.60",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.61",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -191,7 +191,12 @@ export class BookingsController_2024_08_13 {
@Post("/:bookingUid/cancel")
@UseGuards(BookingUidGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Cancel a booking" })
@ApiOperation({
summary: "Cancel a booking",
description: `:bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.
For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call
like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.`,
})
async cancelBooking(
@Req() request: Request,
@Param("bookingUid") bookingUid: string,
@@ -25,13 +25,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
@@ -0,0 +1,691 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { AttendeeCancelledEmail, OrganizerCancelledEmail } from "@calcom/platform-libraries";
import {
CreateRecurringBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Creating recurring bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 3;
let recurringEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
const recurrenceCount = 1000;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount - 1;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount - 1);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.meetingUrl).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
const thirdBooking = data[2];
expect(thirdBooking.id).toBeDefined();
expect(thirdBooking.uid).toBeDefined();
expect(thirdBooking.hosts[0].id).toEqual(user.id);
expect(thirdBooking.status).toEqual("accepted");
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
expect(thirdBooking.duration).toEqual(60);
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(thirdBooking.recurringBookingUid).toBeDefined();
expect(thirdBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(thirdBooking.location).toEqual(body.location);
expect(thirdBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterEach(async () => {
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
describe("Recurring bookings cancel all subsequent bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 4;
let recurringEventTypeId: number;
let recurringBooking: RecurringBookingOutput_2024_08_13[];
beforeAll(async () => {
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should create a recurring booking", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
recurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should cancel all recurrences after a specific recurrence", async () => {
const recurringBookingUid = recurringBooking[2].recurringBookingUid;
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
for (const booking of bookings) {
expect(booking.status).toEqual("ACCEPTED");
}
// note(Lauris): cancel all recurrences after and including third recurrence in the series of 4 recurrences
// aka cancel 3rd and 4th recurrences.
const thirdRecurrenceUid = recurringBooking[2].uid;
const fourthRecurringUid = recurringBooking[3].uid;
const body: CancelBookingInput_2024_08_13 = {
cancelSubsequentBookings: true,
};
return request(app.getHttpServer())
.post(`/v2/bookings/${thirdRecurrenceUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
const firstRecurrence = data.find((booking) => booking.uid === recurringBooking[0].uid);
expect(firstRecurrence).toBeDefined();
expect(firstRecurrence?.status).toEqual("accepted");
const secondRecurrence = data.find((booking) => booking.uid === recurringBooking[1].uid);
expect(secondRecurrence).toBeDefined();
expect(secondRecurrence?.status).toEqual("accepted");
const thirdRecurrence = data.find((booking) => booking.uid === thirdRecurrenceUid);
expect(thirdRecurrence).toBeDefined();
expect(thirdRecurrence?.status).toEqual("cancelled");
const fourthRecurrence = data.find((booking) => booking.uid === fourthRecurringUid);
expect(fourthRecurrence).toBeDefined();
expect(fourthRecurrence?.status).toEqual("cancelled");
const bookings = await bookingsRepositoryFixture.getByRecurringBookingUid(recurringBookingUid);
const bookingFirst = bookings.find((booking) => booking.uid === recurringBooking[0].uid);
expect(bookingFirst?.status).toEqual("ACCEPTED");
const bookingSecond = bookings.find((booking) => booking.uid === recurringBooking[1].uid);
expect(bookingSecond?.status).toEqual("ACCEPTED");
const bookingThird = bookings.find((booking) => booking.uid === recurringBooking[2].uid);
expect(bookingThird?.status).toEqual("CANCELLED");
const bookingFourth = bookings.find((booking) => booking.uid === recurringBooking[3].uid);
expect(bookingFourth?.status).toEqual("CANCELLED");
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
describe("Recurring bookings cancel all remaining bookings (bookings with start time greater than this moment)", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 4;
let recurringEventTypeId: number;
let recurringBooking: RecurringBookingOutput_2024_08_13[];
beforeAll(async () => {
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should create a recurring booking", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 1, 3, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
recurringBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should cancel all remaning recurrences", async () => {
const recurringBookingUid = recurringBooking[0].recurringBookingUid;
return request(app.getHttpServer())
.post(`/v2/bookings/${recurringBookingUid}/cancel`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(4);
for (const booking of data) {
expect(booking.status).toEqual("cancelled");
}
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -1127,293 +1127,4 @@ describe("Bookings Endpoints 2024-08-13", () => {
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
describe("Recurring bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
const maxRecurrenceCount = 3;
let recurringEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: "peer-coding-recurring",
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
user.id
);
recurringEventTypeId = recurringEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should not create recurring booking with recurrenceCount larger than event type recurrence count", async () => {
const recurrenceCount = 1000;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a recurring booking with recurrenceCount smaller than event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount - 1;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount - 1);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should create a recurring booking with recurrenceCount equal to event type recurrence count", async () => {
const recurrenceCount = maxRecurrenceCount;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString(),
eventTypeId: recurringEventTypeId,
attendee: {
name: "Mr Proper Recurring",
email: "mr_proper_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsRecurringBooking(responseBody.data)).toBe(true);
if (responseDataIsRecurringBooking(responseBody.data)) {
const data: RecurringBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(maxRecurrenceCount);
const firstBooking = data[0];
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(new Date(Date.UTC(2030, 1, 4, 13, 0, 0)).toISOString());
expect(firstBooking.end).toEqual(new Date(Date.UTC(2030, 1, 4, 14, 0, 0)).toISOString());
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.meetingUrl).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
expect(secondBooking.start).toEqual(new Date(Date.UTC(2030, 1, 11, 13, 0, 0)).toISOString());
expect(secondBooking.end).toEqual(new Date(Date.UTC(2030, 1, 11, 14, 0, 0)).toISOString());
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(secondBooking.recurringBookingUid).toBeDefined();
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
const thirdBooking = data[2];
expect(thirdBooking.id).toBeDefined();
expect(thirdBooking.uid).toBeDefined();
expect(thirdBooking.hosts[0].id).toEqual(user.id);
expect(thirdBooking.status).toEqual("accepted");
expect(thirdBooking.start).toEqual(new Date(Date.UTC(2030, 1, 18, 13, 0, 0)).toISOString());
expect(thirdBooking.end).toEqual(new Date(Date.UTC(2030, 1, 18, 14, 0, 0)).toISOString());
expect(thirdBooking.duration).toEqual(60);
expect(thirdBooking.eventTypeId).toEqual(recurringEventTypeId);
expect(thirdBooking.recurringBookingUid).toBeDefined();
expect(thirdBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(thirdBooking.location).toEqual(body.location);
expect(thirdBooking.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
afterEach(async () => {
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -307,9 +307,26 @@ export class BookingsService_2024_08_13 {
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
await handleCancelBooking(bookingRequest);
if ("cancelSubsequentBookings" in body && body.cancelSubsequentBookings) {
return this.getAllRecurringBookingsByIndividualUid(bookingUid);
}
return this.getBooking(bookingUid);
}
private async getAllRecurringBookingsByIndividualUid(bookingUid: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
const recurringBookingUid = booking?.recurringEventId;
if (!recurringBookingUid) {
throw new BadRequestException(
`Booking with bookingUid=${bookingUid} is not part of a recurring booking.`
);
}
return await this.getBooking(recurringBookingUid);
}
async markAbsent(bookingUid: string, bookingOwnerId: number, body: MarkAbsentBookingInput_2024_08_13) {
const bodyTransformed = this.inputService.transformInputMarkAbsentBooking(body);
@@ -493,23 +493,38 @@ export class InputBookingsService_2024_08_13 {
}
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
let allRemainingBookings = false;
let uid = bookingUid;
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
bookingUid
);
const recurringBooking = await this.bookingsRepository.getRecurringByUid(bookingUid);
// note(Lauris): isRecurring means that recurringEventId was passed as uid. isRecurring does not refer to the uid of 1 individual booking within a recurring booking consisting of many bookings.
// That is what recurringEventId refers to.
const isRecurringUid = !!recurringBooking.length;
if (recurringBooking.length) {
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
allRemainingBookings = true;
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
uid = recurringBooking[0].uid;
if (isRecurringUid && inputBooking.cancelSubsequentBookings) {
throw new BadRequestException(
"Cannot cancel subsequent bookings for recurring event - you have to provide uid of one of the individual bookings of a recurring booking."
);
}
if (isRecurringUid) {
return {
// note(Lauris): set uid as one of the oldest individual recurring ids
uid: recurringBooking[0].uid,
cancellationReason: inputBooking.cancellationReason,
allRemainingBookings: true,
};
}
if (inputBooking.cancelSubsequentBookings) {
return {
uid: bookingUid,
cancellationReason: inputBooking.cancellationReason,
cancelSubsequentBookings: true,
};
}
return {
uid,
uid: bookingUid,
cancellationReason: inputBooking.cancellationReason,
allRemainingBookings,
allRemainingBookings: false,
};
}
@@ -517,23 +532,12 @@ export class InputBookingsService_2024_08_13 {
bookingUid: string,
inputBooking: CancelSeatedBookingInput_2024_08_13
) {
let allRemainingBookings = false;
let uid = bookingUid;
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(
bookingUid
);
if (recurringBooking.length) {
// note(Lauirs): this means that bookingUid is equal to recurringEventId on individual bookings of recurring one aka main recurring event
allRemainingBookings = true;
// note(Lauirs): we need to set uid as one of the individual recurring ids, not the main recurring event id
uid = recurringBooking[0].uid;
}
// note(Lauris): for recurring seated booking it is not possible to cancel all remaining bookings
// for an individual person, so api users need to call booking by booking using uid + seatUid to cancel it.
return {
uid,
uid: bookingUid,
cancellationReason: "",
allRemainingBookings,
allRemainingBookings: false,
seatReferenceUid: inputBooking.seatUid,
};
}
+1
View File
@@ -3162,6 +3162,7 @@
"post": {
"operationId": "BookingsController_2024_08_13_cancelBooking",
"summary": "Cancel a booking",
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
"parameters": [
{
"name": "cal-api-version",
@@ -22,6 +22,14 @@ export class BookingsRepositoryFixture {
return this.prismaReadClient.booking.findUnique({ where: { uid: bookingUid } });
}
async getByRecurringBookingUid(recurringBookingUid: string) {
return this.prismaReadClient.booking.findMany({
where: {
recurringEventId: recurringBookingUid,
},
});
}
async create(booking: Prisma.BookingCreateInput) {
return this.prismaWriteClient.booking.create({ data: booking });
}
+5 -2
View File
@@ -1,9 +1,12 @@
import { CronJob } from "cron";
import dotEnv from "dotenv";
dotEnv.config({ path: "../../.env" });
async function fetchCron(endpoint: string) {
const apiKey = process.env.CRON_API_KEY;
const res = await fetch(`http://localhost:3000/api${endpoint}?${apiKey}`, {
const res = await fetch(`http://localhost:3000/api${endpoint}?apiKey=${apiKey}`, {
headers: {
"Content-Type": "application/json",
authorization: `Bearer ${process.env.CRON_SECRET}`,
@@ -20,7 +23,7 @@ try {
"*/5 * * * * *",
async function () {
await Promise.allSettled([
fetchCron("/tasks/cron"),
fetchCron("/calendar-cache/cron"),
// fetchCron("/cron/calVideoNoShowWebhookTriggers"),
//
// fetchCron("/tasks/cleanup"),
@@ -1,12 +1,12 @@
"use client";
import {
FailedBookingsByField,
RoutingFormResponsesTable,
RoutingKPICards,
FailedBookingsByField,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
import { RoutingInsightsFilters } from "@calcom/features/insights/filters/routing/FilterBar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import InsightsLayout from "./layout";
@@ -17,12 +17,18 @@ export default function InsightsPage() {
return (
<InsightsLayout>
<FiltersProvider>
<Filters showRoutingFilters />
<div className="mb-4 space-y-4">
<RoutingKPICards />
<RoutingFormResponsesTable />
<RoutingFormResponsesTable>
{/* We now render the "filters and KPI as a children of the table but we still need to pass the table instance to it so we can access column status in the toolbar.*/}
{(table) => (
<div className="header mb-4">
<div className="flex items-center justify-between">
<RoutingInsightsFilters table={table} />
</div>
<RoutingKPICards />
</div>
)}
</RoutingFormResponsesTable>
<FailedBookingsByField />
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.7.2",
"version": "4.7.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -64,6 +64,7 @@
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-toggle-group": "^1.0.0",
"@radix-ui/react-tooltip": "^1.0.0",
"@replexica/sdk": "^0.6.0",
"@sentry/nextjs": "^8.8.0",
"@stripe/react-stripe-js": "^1.10.0",
"@stripe/stripe-js": "^1.35.0",
+75 -81
View File
@@ -3,98 +3,92 @@ import { z } from "zod";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import { HttpError } from "@calcom/lib/http-error";
import notEmpty from "@calcom/lib/notEmpty";
import prisma from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { UserRepository } from "@calcom/lib/server/repository/user";
const selectedCalendarSelectSchema = z.object({
integration: z.string(),
externalId: z.string(),
credentialId: z.number().optional(),
credentialId: z.coerce.number(),
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const session = await getServerSession({ req, res });
/** Shared authentication middleware for GET, DELETE and POST requests */
async function authMiddleware(req: CustomNextApiRequest) {
const session = await getServerSession({ req });
if (!session?.user?.id) {
res.status(401).json({ message: "Not authenticated" });
return;
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
}
const userWithCredentials = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
credentials: {
select: credentialForCalendarServiceSelect,
},
timeZone: true,
id: true,
selectedCalendars: true,
},
});
const userWithCredentials = await UserRepository.findUserWithCredentials({ id: session.user.id });
if (!userWithCredentials) {
res.status(401).json({ message: "Not authenticated" });
return;
}
const { credentials, ...user } = userWithCredentials;
if (req.method === "POST") {
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body);
await prisma.selectedCalendar.upsert({
where: {
userId_integration_externalId: {
userId: user.id,
integration,
externalId,
},
},
create: {
userId: user.id,
integration,
externalId,
credentialId,
},
// already exists
update: {},
});
res.status(200).json({ message: "Calendar Selection Saved" });
}
if (req.method === "DELETE") {
const { integration, externalId } = selectedCalendarSelectSchema.parse(req.query);
await prisma.selectedCalendar.delete({
where: {
userId_integration_externalId: {
userId: user.id,
externalId,
integration,
},
},
});
res.status(200).json({ message: "Calendar Selection Saved" });
}
if (req.method === "GET") {
const selectedCalendarIds = await prisma.selectedCalendar.findMany({
where: {
userId: user.id,
},
select: {
externalId: true,
},
});
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(credentials);
// get all the connected integrations' calendars (from third party)
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
const selectableCalendars = calendars.map((cal) => {
return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal };
});
res.status(200).json(selectableCalendars);
throw new HttpError({ statusCode: 401, message: "Not authenticated" });
}
req.userWithCredentials = userWithCredentials;
return userWithCredentials;
}
type CustomNextApiRequest = NextApiRequest & {
userWithCredentials?: Awaited<ReturnType<typeof authMiddleware>>;
};
async function postHandler(req: CustomNextApiRequest) {
if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" });
const user = req.userWithCredentials;
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.body);
await SelectedCalendarRepository.upsert({
userId: user.id,
integration,
externalId,
credentialId,
});
return { message: "Calendar Selection Saved" };
}
async function deleteHandler(req: CustomNextApiRequest) {
if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" });
const user = req.userWithCredentials;
const { integration, externalId, credentialId } = selectedCalendarSelectSchema.parse(req.query);
const calendarCacheRepository = await CalendarCache.initFromCredentialId(credentialId);
await calendarCacheRepository.unwatchCalendar({ calendarId: externalId });
await SelectedCalendarRepository.delete({
userId: user.id,
externalId,
integration,
});
return { message: "Calendar Selection Saved" };
}
async function getHandler(req: CustomNextApiRequest) {
if (!req.userWithCredentials) throw new HttpError({ statusCode: 401, message: "Not authenticated" });
const user = req.userWithCredentials;
const selectedCalendarIds = await SelectedCalendarRepository.findMany({
where: { userId: user.id },
select: { externalId: true },
});
// get user's credentials + their connected integrations
const calendarCredentials = getCalendarCredentials(user.credentials);
// get all the connected integrations' calendars (from third party)
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, user.selectedCalendars);
const calendars = connectedCalendars.flatMap((c) => c.calendars).filter(notEmpty);
const selectableCalendars = calendars.map((cal) => {
return { selected: selectedCalendarIds.findIndex((s) => s.externalId === cal.externalId) > -1, ...cal };
});
return selectableCalendars;
}
export default defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
await authMiddleware(req);
return defaultHandler({
GET: Promise.resolve({ default: defaultResponder(getHandler) }),
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
DELETE: Promise.resolve({ default: defaultResponder(deleteHandler) }),
})(req, res);
});
@@ -0,0 +1 @@
export { default } from "@calcom/features/calendar-cache/api/cron";
@@ -3,6 +3,12 @@ import type { NextApiRequest, NextApiResponse } from "next";
import prisma from "@calcom/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const apiKey = req.headers.authorization || req.query.apiKey;
if (![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(`${apiKey}`)) {
res.status(401).json({ message: "Not authenticated" });
return;
}
const deleted = await prisma.calendarCache.deleteMany({
where: {
// Delete all cache entries that expired before now
+50 -1
View File
@@ -9,7 +9,7 @@ import type { Messages } from "mailhog";
import { totp } from "otplib";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import type { IntervalLimit } from "@calcom/types/Calendar";
import type { createEmailsFixture } from "../fixtures/emails";
@@ -464,3 +464,52 @@ export async function confirmReschedule(page: Page, url = "/api/book/event") {
action: () => page.locator('[data-testid="confirm-reschedule-button"]').click(),
});
}
export async function bookTeamEvent({
page,
team,
event,
teamMatesObj,
opts,
}: {
page: Page;
team: {
slug: string | null;
name: string | null;
};
event: { slug: string; title: string; schedulingType: SchedulingType | null };
teamMatesObj?: { name: string }[];
opts?: { attendeePhoneNumber?: string };
}) {
// Note that even though the default way to access a team booking in an organization is to not use /team in the URL, but it isn't testable with playwright as the rewrite is taken care of by Next.js config which can't handle on the fly org slug's handling
// So, we are using /team in the URL to access the team booking
// There are separate tests to verify that the next.config.js rewrites are working
// Also there are additional checkly tests that verify absolute e2e flow. They are in __checks__/organization.spec.ts
await page.goto(`/team/${team.slug}/${event.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page, opts);
await expect(page.getByTestId("success-page")).toBeVisible();
// The title of the booking
if (event.schedulingType === SchedulingType.ROUND_ROBIN && teamMatesObj) {
const bookingTitle = await page.getByTestId("booking-title").textContent();
const isMatch = teamMatesObj?.some((teamMate) => {
const expectedTitle = `${event.title} between ${teamMate.name} and ${testName}`;
return expectedTitle.trim() === bookingTitle?.trim();
});
expect(isMatch).toBe(true);
} else {
const BookingTitle = `${event.title} between ${team.name} and ${testName}`;
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
}
// The booker should be in the attendee list
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
}
export async function expectPageToBeNotFound({ page, url }: { page: Page; url: string }) {
await page.goto(`${url}`);
await expect(page.getByTestId(`404-page`)).toBeVisible();
}
@@ -9,8 +9,10 @@ import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { test } from "../lib/fixtures";
import {
bookTeamEvent,
bookTimeSlot,
doOnOrgDomain,
expectPageToBeNotFound,
selectFirstAvailableTimeSlotNextMonth,
submitAndWaitForResponse,
testName,
@@ -604,55 +606,6 @@ async function bookUserEvent({
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
}
async function bookTeamEvent({
page,
team,
event,
teamMatesObj,
opts,
}: {
page: Page;
team: {
slug: string | null;
name: string | null;
};
event: { slug: string; title: string; schedulingType: SchedulingType | null };
teamMatesObj?: { name: string }[];
opts?: { attendeePhoneNumber?: string };
}) {
// Note that even though the default way to access a team booking in an organization is to not use /team in the URL, but it isn't testable with playwright as the rewrite is taken care of by Next.js config which can't handle on the fly org slug's handling
// So, we are using /team in the URL to access the team booking
// There are separate tests to verify that the next.config.js rewrites are working
// Also there are additional checkly tests that verify absolute e2e flow. They are in __checks__/organization.spec.ts
await page.goto(`/team/${team.slug}/${event.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page, opts);
await expect(page.getByTestId("success-page")).toBeVisible();
// The title of the booking
if (event.schedulingType === SchedulingType.ROUND_ROBIN && teamMatesObj) {
const bookingTitle = await page.getByTestId("booking-title").textContent();
const isMatch = teamMatesObj?.some((teamMate) => {
const expectedTitle = `${event.title} between ${teamMate.name} and ${testName}`;
return expectedTitle.trim() === bookingTitle?.trim();
});
expect(isMatch).toBe(true);
} else {
const BookingTitle = `${event.title} between ${team.name} and ${testName}`;
await expect(page.getByTestId("booking-title")).toHaveText(BookingTitle);
}
// The booker should be in the attendee list
await expect(page.getByTestId(`attendee-name-${testName}`)).toHaveText(testName);
}
async function expectPageToBeNotFound({ page, url }: { page: Page; url: string }) {
await page.goto(`${url}`);
await expect(page.getByTestId(`404-page`)).toBeVisible();
}
const markPhoneNumberAsRequiredAndEmailAsOptional = async (page: Page, eventId: number) => {
// Make phone as required
await markPhoneNumberAsRequiredField(page, eventId);
@@ -3,11 +3,12 @@ import { expect } from "@playwright/test";
import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { moveUserToOrg } from "@lib/orgMigration";
import { test } from "../lib/fixtures";
import { getInviteLink } from "../lib/testUtils";
import { bookTeamEvent, doOnOrgDomain, expectPageToBeNotFound, getInviteLink } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
test.describe.configure({ mode: "parallel" });
@@ -417,6 +418,51 @@ test.describe("Organization", () => {
});
});
});
test("can book an event with auto accepted invitee (not completed on-boarding) added as fixed host.", async ({
page,
users,
}) => {
const orgOwner = await users.create(undefined, {
hasTeam: true,
isOrg: true,
hasSubteam: true,
isOrgVerified: true,
isDnsSetup: true,
orgRequestedSlug: "example",
schedulingType: SchedulingType.ROUND_ROBIN,
});
const { team: org } = await orgOwner.getOrgMembership();
const { team } = await orgOwner.getFirstTeamMembership();
await orgOwner.apiLogin();
await page.goto(`/settings/teams/${team.id}/members`);
const invitedUserEmail = users.trackEmail({ username: "rick", domain: "example.com" });
await inviteAnEmail(page, invitedUserEmail, true);
//add invitee as fixed host to team event
const teamEvent = await orgOwner.getFirstTeamEvent(team.id);
await page.goto(`/event-types/${teamEvent.id}?tabName=team`);
await page.locator('[data-testid="fixed-hosts-switch"]').click();
await page.locator('[data-testid="fixed-hosts-select"]').click();
await page.locator(`text="${invitedUserEmail}"`).click();
await page.locator('[data-testid="update-eventtype"]').click();
await page.waitForResponse("/api/trpc/eventTypes/update?batch=1");
await expectPageToBeNotFound({ page, url: `/team/${team.slug}/${teamEvent.slug}` });
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async ({ page, goToUrlWithErrorHandling }) => {
const result = await goToUrlWithErrorHandling(`/team/${team.slug}/${teamEvent.slug}`);
await bookTeamEvent({ page, team, event: teamEvent });
await expect(page.getByText(invitedUserEmail, { exact: true })).toBeVisible();
return { url: result.url };
}
);
});
});
});
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "حدّد بعض الفترات المتاحة وأدرجها في رسالتك الإلكترونية",
"select_time": "اختيار الوقت",
"select_date": "اختيار التاريخ",
"only_show_if_parameter_set": "إظهار فقط في حالة تعيين المعامل",
"add_parameter": "إضافة معامل",
"connecting_you_to_someone": "نقوم بربطك بشخص من {{orgName}}.",
"please_do_not_close_this_tab": "من فضلك لا تغلق أو تحدّث هذه الصفحة",
"see_all_available_times": "رؤية كل الأوقات المتاحة",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "التوجيه إلى مستخدم يطابق حقل البحث في الحساب",
"salesforce_option": "خيار Salesforce",
"lookup_field_name": "اسم حقل البحث",
"translate_description_button": "ترجمة الوصف إلى لغة متصفح الزائر باستخدام الذكاء الاصطناعي",
"rr_distribution_method": "التوزيع",
"rr_distribution_method_description": "يتيح تحسين التوزيع لتحقيق أقصى قدر من التوفر أو لتحقيق توزيع أكثر توازناً.",
"rr_distribution_method_availability_title": "تعظيم التوفر",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Mövcud vaxtları seçin və Email-ə əlavə edin",
"select_time": "Vaxtı Seçin",
"select_date": "Tarixi Seçin",
"only_show_if_parameter_set": "Yalnız parametr təyin edildikdə göstər",
"add_parameter": "Parametr əlavə et",
"connecting_you_to_someone": "Sizi {{orgName}}-dən birisi ilə əlaqələndiririk.",
"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",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Hesabda axtarış sahəsinə uyğun gələn istifadəçiyə yönləndir",
"salesforce_option": "Salesforce Seçimi",
"lookup_field_name": "Axtarış Sahəsinin Adı",
"translate_description_button": "Təsviri ziyarətçinin brauzer dilinə süni intellekt vasitəsilə tərcümə et",
"rr_distribution_method": "Paylama",
"rr_distribution_method_description": "Maksimum əlçatanlıq üçün paylamanı optimallaşdırmağa və ya daha balanslı bir təyinat üçün çalışmağa imkan verir.",
"rr_distribution_method_availability_title": "Əlçatanlığı maksimuma çatdırmaq",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Vyberte několik dostupných časů a vložte je do e-mailu",
"select_time": "Vyberte čas",
"select_date": "Vyberte datum",
"only_show_if_parameter_set": "Zobrazit pouze pokud je parametr nastaven",
"add_parameter": "Přidat parametr",
"connecting_you_to_someone": "Spojujeme vás s někým z {{orgName}}.",
"please_do_not_close_this_tab": "Prosím, nezavírejte ani neobnovujte tuto stránku",
"see_all_available_times": "Zobrazit všechny dostupné časy",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Přiřadit uživateli, který odpovídá vyhledávacímu poli na účtu",
"salesforce_option": "Možnost Salesforce",
"lookup_field_name": "Název vyhledávacího pole",
"translate_description_button": "Přeložit popis do jazyka prohlížeče návštěvníka pomocí AI",
"rr_distribution_method": "Distribuce",
"rr_distribution_method_description": "Umožňuje optimalizovat distribuci pro maximální dostupnost nebo pro vyváženější přidělování.",
"rr_distribution_method_availability_title": "Maximalizovat dostupnost",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Vælg nogle tilgængelige tidspunkter og indlejre dem i din e-mail",
"select_time": "Vælg tid",
"select_date": "Vælg dato",
"only_show_if_parameter_set": "Vis kun hvis en parameter er indstillet",
"add_parameter": "Tilføj parameter",
"connecting_you_to_someone": "Vi forbinder dig med en person fra {{orgName}}.",
"please_do_not_close_this_tab": "Luk eller opdater venligst ikke denne side",
"see_all_available_times": "Se alle tilgængelige tidspunkter",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Rute til en bruger, der matcher et opslagfelt på en konto",
"salesforce_option": "Salesforce-indstilling",
"lookup_field_name": "Opslagsfeltnavn",
"translate_description_button": "Oversæt beskrivelsen til besøgendes browsersprog ved hjælp af AI",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Gør det muligt at optimere distributionen for maksimal tilgængelighed eller for at opnå en mere balanceret tildeling.",
"rr_distribution_method_availability_title": "Maksimer tilgængelighed",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Zeiten zur E-Mail hinzufügen",
"select_time": "Zeit auswählen",
"select_date": "Datum auswählen",
"only_show_if_parameter_set": "Nur anzeigen, wenn ein Parameter gesetzt ist",
"add_parameter": "Parameter hinzufügen",
"connecting_you_to_someone": "Wir verbinden Sie mit jemandem von {{orgName}}.",
"please_do_not_close_this_tab": "Bitte schließen oder aktualisieren Sie diese Seite nicht",
"see_all_available_times": "Alle verfügbaren Zeiten ansehen",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Weiterleitung an einen Benutzer, der mit einem Lookup-Feld eines Accounts übereinstimmt",
"salesforce_option": "Salesforce-Option",
"lookup_field_name": "Name des Lookup-Felds",
"translate_description_button": "Beschreibung mit KI in die Browsersprache des Besuchers übersetzen",
"rr_distribution_method": "Verteilung",
"rr_distribution_method_description": "Ermöglicht die Optimierung der Verteilung für maximale Verfügbarkeit oder eine ausgewogenere Zuweisung.",
"rr_distribution_method_availability_title": "Verfügbarkeit maximieren",
File diff suppressed because it is too large Load Diff
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Select a few available times and embed them in your Email",
"select_time": "Select Time",
"select_date": "Select Date",
"only_show_if_parameter_set": "Only show if a parameter is set",
"add_parameter": "Add parameter",
"connecting_you_to_someone": "We are connecting you to someone from {{orgName}}.",
"please_do_not_close_this_tab": "Please do not close or refresh this page",
"see_all_available_times": "See all available times",
@@ -2791,6 +2793,7 @@
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
"salesforce_option": "Salesforce Option",
"lookup_field_name": "Lookup Field Name",
"translate_description_button": "Translate description to the visitor's browser language using AI",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Allows for optimising distribution for maximum availability or to aim for a more balanced assignment.",
"rr_distribution_method_availability_title": "Maximize availability",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Selecciona algunos horarios disponibles e insértalos en tu correo electrónico",
"select_time": "Seleccionar hora",
"select_date": "Seleccionar fecha",
"only_show_if_parameter_set": "Mostrar solo si se establece un parámetro",
"add_parameter": "Agregar parámetro",
"connecting_you_to_someone": "Te estamos conectando con alguien de {{orgName}}.",
"please_do_not_close_this_tab": "Por favor, no cierres ni actualices esta página",
"see_all_available_times": "Ver todos los horarios disponibles",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Dirigir a un usuario que coincida con un campo de búsqueda en una cuenta",
"salesforce_option": "Opción de Salesforce",
"lookup_field_name": "Nombre del campo de búsqueda",
"translate_description_button": "Traducir descripción al idioma del navegador del visitante usando IA",
"rr_distribution_method": "Distribución",
"rr_distribution_method_description": "Permite optimizar la distribución para maximizar la disponibilidad o lograr una asignación más equilibrada.",
"rr_distribution_method_availability_title": "Maximizar disponibilidad",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Seleccione algunos horarios disponibles e incrústelos en su correo electrónico",
"select_time": "Seleccione la hora",
"select_date": "Seleccione la fecha",
"only_show_if_parameter_set": "Mostrar solo si se establece un parámetro",
"add_parameter": "Agregar parámetro",
"connecting_you_to_someone": "Te estamos conectando con alguien de {{orgName}}.",
"please_do_not_close_this_tab": "Por favor, no cierres ni actualices esta página",
"see_all_available_times": "Ver todas las horas disponibles",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Dirigir a un usuario que coincida con un campo de búsqueda en una cuenta",
"salesforce_option": "Opción de Salesforce",
"lookup_field_name": "Nombre del campo de búsqueda",
"translate_description_button": "Traducir descripción al idioma del navegador del visitante usando IA",
"rr_distribution_method": "Distribución",
"rr_distribution_method_description": "Permite optimizar la distribución para maximizar la disponibilidad o lograr una asignación más equilibrada.",
"rr_distribution_method_availability_title": "Maximizar disponibilidad",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Valige mõned vabad ajad ja manustage need oma e-kirja",
"select_time": "Vali aeg",
"select_date": "Vali kuupäev",
"only_show_if_parameter_set": "Kuva ainult siis, kui parameeter on määratud",
"add_parameter": "Lisa parameeter",
"connecting_you_to_someone": "Me ühendame teid kellegagi {{orgName}}-st.",
"please_do_not_close_this_tab": "Palun ärge sulgege ega värskendage seda lehte",
"see_all_available_times": "Vaata kõiki saadaolevaid aegu",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Suuna kasutajale, kes vastab konto otsinguväljale",
"salesforce_option": "Salesforce'i valik",
"lookup_field_name": "Otsinguvälja nimi",
"translate_description_button": "Tõlgi kirjeldus tehisintellekti abil külastaja brauseri keelde",
"rr_distribution_method": "Jaotamine",
"rr_distribution_method_description": "Võimaldab optimeerida jaotust maksimaalseks kättesaadavuseks või tasakaalustatumaks määramiseks.",
"rr_distribution_method_availability_title": "Maksimeeri kättesaadavus",
File diff suppressed because it is too large Load Diff
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Sélectionnez quelques créneaux disponibles et intégrez-les dans votre e-mail.",
"select_time": "Sélectionner un créneau",
"select_date": "Sélectionner une date",
"only_show_if_parameter_set": "Afficher uniquement si un paramètre est défini",
"add_parameter": "Ajouter un paramètre",
"connecting_you_to_someone": "Nous vous mettons en relation avec quelqu'un de {{orgName}}.",
"please_do_not_close_this_tab": "Veuillez ne pas fermer ou actualiser cette page",
"see_all_available_times": "Voir tous les créneaux disponibles",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Acheminer vers un utilisateur correspondant à un champ de recherche sur un compte",
"salesforce_option": "Option Salesforce",
"lookup_field_name": "Nom du champ de recherche",
"translate_description_button": "Traduire la description dans la langue du navigateur du visiteur avec l'IA",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Permet d'optimiser la distribution pour une disponibilité maximale ou pour viser une répartition plus équilibrée.",
"rr_distribution_method_availability_title": "Maximiser la disponibilité",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "בחר/י כמה מועדים פנויים והטבע/י אותם בדוא\"ל",
"select_time": "בחירת שעה",
"select_date": "בחירת תאריך",
"only_show_if_parameter_set": "הצג רק אם הוגדר פרמטר",
"add_parameter": "הוסף פרמטר",
"connecting_you_to_someone": "אנו מחברים אותך למישהו מ-{{orgName}}.",
"please_do_not_close_this_tab": "אנא אל תסגור או תרענן דף זה",
"see_all_available_times": "לצפייה בכל המועדים הפנויים",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "נתב למשתמש התואם לשדה חיפוש בחשבון",
"salesforce_option": "אפשרות Salesforce",
"lookup_field_name": "שם שדה החיפוש",
"translate_description_button": "תרגם את התיאור לשפת הדפדפן של המבקר באמצעות בינה מלאכותית",
"rr_distribution_method": "שיטת חלוקה",
"rr_distribution_method_description": "מאפשר אופטימיזציה של החלוקה למקסימום זמינות או לחלוקה מאוזנת יותר",
"rr_distribution_method_availability_title": "מקסום זמינות",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Válasszon ki néhány elérhető időpontot, és ágyazza be e-mailjeibe",
"select_time": "Válasszon időt",
"select_date": "Válasszon dátumot",
"only_show_if_parameter_set": "Csak akkor jelenjen meg, ha egy paraméter be van állítva",
"add_parameter": "Paraméter hozzáadása",
"connecting_you_to_someone": "Kapcsolatba hozzuk egy {{orgName}} munkatársával.",
"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",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Átirányítás olyan felhasználóhoz, aki megfelel a fiók keresési mezőjének",
"salesforce_option": "Salesforce beállítás",
"lookup_field_name": "Keresési mező neve",
"translate_description_button": "A leírás lefordítása a látogató böngészőjének nyelvére MI segítségével",
"rr_distribution_method": "Elosztás",
"rr_distribution_method_description": "Lehetővé teszi az elosztás optimalizálását a maximális rendelkezésre állás érdekében, vagy a kiegyensúlyozottabb feladatkiosztás eléréséhez.",
"rr_distribution_method_availability_title": "Rendelkezésre állás maximalizálása",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Seleziona alcuni orari disponibili e incorporali nella tua e-mail",
"select_time": "Seleziona l'ora",
"select_date": "Seleziona la data",
"only_show_if_parameter_set": "Mostra solo se un parametro è impostato",
"add_parameter": "Aggiungi parametro",
"connecting_you_to_someone": "Ti stiamo mettendo in contatto con qualcuno di {{orgName}}.",
"please_do_not_close_this_tab": "Per favore, non chiudere o aggiornare questa pagina",
"see_all_available_times": "Vedi tutti gli orari disponibili",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Instrada a un utente che corrisponde a un campo di ricerca su un account",
"salesforce_option": "Opzione Salesforce",
"lookup_field_name": "Nome del campo di ricerca",
"translate_description_button": "Traduci la descrizione nella lingua del browser del visitatore usando l'IA",
"rr_distribution_method": "Distribuzione",
"rr_distribution_method_description": "Permette di ottimizzare la distribuzione per massimizzare la disponibilità o per ottenere un'assegnazione più bilanciata.",
"rr_distribution_method_availability_title": "Massimizza disponibilità",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "出席できる時間帯をいくつか選んで、メールに埋め込みます",
"select_time": "時間帯を選ぶ",
"select_date": "日付を選ぶ",
"only_show_if_parameter_set": "パラメーターが設定されている場合のみ表示",
"add_parameter": "パラメーターを追加",
"connecting_you_to_someone": "{{orgName}}の担当者におつなぎしています。",
"please_do_not_close_this_tab": "このページを閉じたり、リフレッシュしたりしないでください",
"see_all_available_times": "出席できる時間帯をすべて表示",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "取引先の参照項目に一致するユーザーに割り当てる",
"salesforce_option": "Salesforceオプション",
"lookup_field_name": "参照項目名",
"translate_description_button": "AIを使用して説明文を訪問者のブラウザ言語に翻訳",
"rr_distribution_method": "配分方式",
"rr_distribution_method_description": "最大限の予約可能性を確保するため、または均等な配分を目指すための最適化が可能です。",
"rr_distribution_method_availability_title": "予約可能性を最大化",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "ជ្រើសរើសពេលវេលាដែលអាចប្រើបានប៉ុន្មាន និងបញ្ចូលវានៅក្នុងអ៊ីមែលរបស់អ្នក",
"select_time": "ជ្រើសរើសពេលវេលា",
"select_date": "ជ្រើសរើសកាលបរិច្ឆេទ",
"only_show_if_parameter_set": "បង្ហាញតែនៅពេលដែលប៉ារ៉ាម៉ែត្រត្រូវបានកំណត់",
"add_parameter": "បន្ថែមប៉ារ៉ាម៉ែត្រ",
"connecting_you_to_someone": "យើងកំពុងភ្ជាប់អ្នកទៅនរណាម្នាក់ពី {{orgName}}។",
"please_do_not_close_this_tab": "សូមកុំបិទ ឬធ្វើការបន្ទាន់សម័យទំព័រនេះ",
"see_all_available_times": "មើលពេលវេលាទាំងអស់ដែលអាចប្រើបាន",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "ផ្លូវទៅកាន់អ្នកប្រើដែលត្រូវនឹងវាលស្វែងរកនៅលើគណនី។",
"salesforce_option": "ជម្រើស Salesforce",
"lookup_field_name": "ឈ្មោះវាលស្វែងរក",
"translate_description_button": "បកប្រែការពិពណ៌នាទៅជាភាសាកម្មវិធីរុករករបស់ភ្ញៀវដោយប្រើ AI",
"rr_distribution_method": "ការចែកចាយ",
"rr_distribution_method_description": "អនុញ្ញាតឱ្យធ្វើការបែងចែកប្រសិទ្ធភាពដើម្បីបង្កើនភាពអាចរកបានអតិបរមា ឬដើម្បីសម្រេចបាននូវការចាត់តាំងដែលមានតុល្យភាពជាងមុន។",
"rr_distribution_method_availability_title": "បង្កើនភាពអាចរកបាន",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "사용 가능한 시간을 몇 개 선택하여 이메일에 포함시키세요",
"select_time": "시간 선택",
"select_date": "날짜 선택",
"only_show_if_parameter_set": "매개변수가 설정된 경우에만 표시",
"add_parameter": "매개변수 추가",
"connecting_you_to_someone": "{{orgName}}의 담당자와 연결 중입니다.",
"please_do_not_close_this_tab": "이 페이지를 닫거나 새로 고침하지 마세요",
"see_all_available_times": "모든 사용 가능한 시간 보기",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "계정의 조회 필드와 일치하는 사용자에게 라우팅합니다.",
"salesforce_option": "Salesforce 옵션",
"lookup_field_name": "조회 필드 이름",
"translate_description_button": "AI를 사용하여 방문자의 브라우저 언어로 설명 번역하기",
"rr_distribution_method": "분배 방식",
"rr_distribution_method_description": "최대 가용성을 위한 분배 최적화 또는 균형 잡힌 할당을 위한 설정이 가능합니다.",
"rr_distribution_method_availability_title": "가용성 최대화",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Selecteer enkele beschikbare tijden en voeg ze toe aan uw e-mail",
"select_time": "Tijd selecteren",
"select_date": "Datum selecteren",
"only_show_if_parameter_set": "Alleen tonen als een parameter is ingesteld",
"add_parameter": "Parameter toevoegen",
"connecting_you_to_someone": "We verbinden je met iemand van {{orgName}}.",
"please_do_not_close_this_tab": "Sluit of vernieuw deze pagina niet",
"see_all_available_times": "Bekijk alle beschikbare tijden",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Routeer naar een gebruiker die overeenkomt met een zoekveld op een account",
"salesforce_option": "Salesforce-optie",
"lookup_field_name": "Zoekveldnaam",
"translate_description_button": "Vertaal beschrijving naar de browsertaal van de bezoeker met behulp van AI",
"rr_distribution_method": "Distributie",
"rr_distribution_method_description": "Maakt het mogelijk om de distributie te optimaliseren voor maximale beschikbaarheid of voor een meer gebalanceerde toewijzing.",
"rr_distribution_method_availability_title": "Maximale beschikbaarheid",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Velg noen tilgjengelige tider og legg dem inn i e-posten din",
"select_time": "Velg tid",
"select_date": "Velg dato",
"only_show_if_parameter_set": "Vis kun hvis en parameter er satt",
"add_parameter": "Legg til parameter",
"connecting_you_to_someone": "Vi kobler deg til noen fra {{orgName}}.",
"please_do_not_close_this_tab": "Vennligst ikke lukk eller oppdater denne siden",
"see_all_available_times": "Se alle tilgjengelige tider",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Rute til en bruker som samsvarer med et oppslagsfelt på en konto",
"salesforce_option": "Salesforce-alternativ",
"lookup_field_name": "Oppslagsfeltnavn",
"translate_description_button": "Oversett beskrivelsen til besøkendes nettleserspråk ved hjelp av AI",
"rr_distribution_method": "Distribusjon",
"rr_distribution_method_description": "Gjør det mulig å optimalisere distribusjonen for maksimal tilgjengelighet eller for å oppnå en mer balansert fordeling.",
"rr_distribution_method_availability_title": "Maksimer tilgjengelighet",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Wybierz kilka dostępnych terminów i osadź je w wiadomości e-mail",
"select_time": "Wybierz godzinę",
"select_date": "Wybierz datę",
"only_show_if_parameter_set": "Pokaż tylko jeśli parametr jest ustawiony",
"add_parameter": "Dodaj parametr",
"connecting_you_to_someone": "Łączymy Cię z kimś z {{orgName}}.",
"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",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Przekieruj do użytkownika, który odpowiada polu wyszukiwania w koncie",
"salesforce_option": "Opcja Salesforce",
"lookup_field_name": "Nazwa pola wyszukiwania",
"translate_description_button": "Przetłumacz opis na język przeglądarki użytkownika za pomocą AI",
"rr_distribution_method": "Dystrybucja",
"rr_distribution_method_description": "Pozwala zoptymalizować dystrybucję pod kątem maksymalnej dostępności lub bardziej zrównoważonego przydziału spotkań.",
"rr_distribution_method_availability_title": "Maksymalizuj dostępność",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Selecione alguns horários disponíveis e incorpore-os no seu e-mail",
"select_time": "Selecione o horário",
"select_date": "Selecione a data",
"only_show_if_parameter_set": "Mostrar apenas se um parâmetro estiver definido",
"add_parameter": "Adicionar parâmetro",
"connecting_you_to_someone": "Estamos conectando você a alguém da {{orgName}}.",
"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",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Direcionar para um usuário que corresponda a um campo de pesquisa em uma conta",
"salesforce_option": "Opção do Salesforce",
"lookup_field_name": "Nome do campo de pesquisa",
"translate_description_button": "Traduzir descrição para o idioma do navegador do visitante usando IA",
"rr_distribution_method": "Distribuição",
"rr_distribution_method_description": "Permite otimizar a distribuição para máxima disponibilidade ou buscar uma atribuição mais equilibrada.",
"rr_distribution_method_availability_title": "Maximizar disponibilidade",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Selecione alguns horários disponíveis e incorpore-os no seu E-mail",
"select_time": "Selecionar horário",
"select_date": "Selecionar data",
"only_show_if_parameter_set": "Mostrar apenas se um parâmetro estiver definido",
"add_parameter": "Adicionar parâmetro",
"connecting_you_to_someone": "Estamos conectando você a alguém da {{orgName}}.",
"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",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Direcionar para um usuário que corresponda a um campo de pesquisa em uma conta",
"salesforce_option": "Opção do Salesforce",
"lookup_field_name": "Nome do campo de pesquisa",
"translate_description_button": "Traduzir descrição para o idioma do navegador do visitante usando IA",
"rr_distribution_method": "Distribuição",
"rr_distribution_method_description": "Permite otimizar a distribuição para máxima disponibilidade ou para buscar uma atribuição mais equilibrada.",
"rr_distribution_method_availability_title": "Maximizar disponibilidade",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Selectați câteva date disponibile și încorporați-le în e-mailul dvs.",
"select_time": "Selectați ora",
"select_date": "Selectați data",
"only_show_if_parameter_set": "Afișează doar dacă este setat un parametru",
"add_parameter": "Adaugă parametru",
"connecting_you_to_someone": "Vă conectăm cu cineva de la {{orgName}}.",
"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",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Redirecționează către un utilizator care se potrivește cu un câmp de căutare pe un cont",
"salesforce_option": "Opțiune Salesforce",
"lookup_field_name": "Nume câmp de căutare",
"translate_description_button": "Traduce descrierea în limba browserului vizitatorului folosind AI",
"rr_distribution_method": "Distribuție",
"rr_distribution_method_description": "Permite optimizarea distribuției pentru disponibilitate maximă sau pentru o alocare mai echilibrată.",
"rr_distribution_method_availability_title": "Maximizează disponibilitatea",
@@ -39,11 +39,16 @@
"email_not_sent": "При отправке письма произошла ошибка",
"event_declined_subject": "Отклонено: {{title}} {{date}}",
"event_cancelled_subject": "Отмена: {{title}} {{date}}",
"event_reassigned_subject": "Переназначено: {{title}} на {{date}}",
"event_request_declined": "Ваш запрос на встречу отклонен",
"event_request_declined_recurring": "Ваш запрос на повторяющееся событие отклонен",
"event_request_cancelled": "Ваша запланированная встреча была отменена",
"event_request_reassigned": "Ваша запланированная встреча была переназначена",
"event_reassigned_subtitle": "Встреча будет удалена из вашего календаря, и это не повлияет отрицательно на ваши шансы в round robin",
"organizer": "Организатор",
"reassigned_to": "Переназначено",
"need_to_reschedule_or_cancel": "Хотите перенести или отменить?",
"you_can_view_booking_details_with_this_url": "Вы можете просмотреть детали бронирования по этой ссылке {{url}} и добавить встречу в свой календарь",
"no_options_available": "Нет доступных вариантов",
"cancellation_reason": "Причина отмены (необязательно)",
"cancellation_reason_placeholder": "Укажите причину отмены подписки",
@@ -126,6 +131,7 @@
"your_meeting_has_been_booked": "Ваша встреча была забронирована",
"event_type_has_been_rescheduled_on_time_date": "Ваше {{title}} было перенесено на {{date}}.",
"event_has_been_rescheduled": "Ваше событие было перенесено.",
"your_event_has_been_rescheduled": "Ваша встреча была перенесена",
"request_reschedule_subtitle": "{{organizer}} отменил(-а) бронирование и попросил(-а) вас выбрать другое время.",
"request_reschedule_title_organizer": "Вы запросили у {{attendee}} изменение расписания",
"request_reschedule_subtitle_organizer": "Вы отменили бронирование; теперь вы и {{attendee}} должны выбрать новое время.",
@@ -134,6 +140,7 @@
"hi_user_name": "Привет {{name}}",
"ics_event_title": "{{eventType}} с {{name}}",
"please_book_a_time_sometime_later": "Упс, мы не смогли вас соединить в этот раз. Пожалуйста, запланируйте звонок на будущее.",
"schedule_instead": "Запланировать вместо этого",
"new_event_subject": "Новое событие: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Присоединиться с {{entryPoint}}",
"notes": "Примечания",
@@ -192,6 +199,8 @@
"webhook_disabled": "Веб-хук выключен",
"webhook_response": "Ответ вебхука",
"webhook_test": "Тест вебхука",
"webhook_update_form": "Форма обновления вебхука",
"webhook_update_form_description": "Добавьте вебхук к вашему OAuthClient.",
"manage_your_webhook": "Управление веб-хуком",
"webhook_created_successfully": "Веб-хук успешно создан!",
"webhook_updated_successfully": "Веб-хук успешно обновлён!",
@@ -212,6 +221,7 @@
"view_notifications": "Показать уведомления",
"view_public_page": "Открыть публичную страницу",
"copy_public_page_link": "Копировать ссылку на публичную страницу",
"copy_referral_link": "Скопировать реферальную ссылку",
"sign_out": "Выйти",
"add_another": "Добавить ещё",
"install_another": "Установить еще одну копию",
@@ -341,9 +351,13 @@
"other": "Прочее",
"email_sign_in_subject": "Ссылка для входа в {{appName}}",
"round_robin_emailed_you_and_attendees": "Вы встречаетесь с {{user}}. Мы отправили всем письмо с приглашением в календарь с деталями.",
"round_robin_emailed_host_and_attendee": "{{host}} встречается с {{attendee}}. Мы отправили всем участникам email с приглашением в календарь и деталями встречи.",
"emailed_you_and_attendees": "Мы отправили вам и другим участникам приглашение в календарь со всеми деталями.",
"emailed_host_and_attendee": "Мы отправили всем участникам email с приглашением в календарь и деталями встречи.",
"emailed_you_and_attendees_recurring": "Мы отправили вам и другим участникам в календарь приглашение на первое из этой серии повторяющихся событий.",
"emailed_host_and_attendee_recurring": "Мы отправили всем участникам email с приглашением в календарь и деталями для первой из серии повторяющихся встреч.",
"round_robin_emailed_you_and_attendees_recurring": "Вы встречаетесь с {{user}}. Мы отправили всем электронное письмо с приглашением в календарь с деталями для первого из этих повторяющихся событий.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} встречается с {{attendee}}. Мы отправили всем участникам email с приглашением в календарь и деталями для первой из серии повторяющихся встреч.",
"emailed_you_and_any_other_attendees": "Вы и другие участники были уведомлены по электронной почте.",
"needs_to_be_confirmed_or_rejected": "Ваша встреча будет подтверждена или отклонена.",
"needs_to_be_confirmed_or_rejected_recurring": "Ваша повторяющаяся встреча все еще должна быть подтверждена или отклонена.",
@@ -396,6 +410,8 @@
"back_to_bookings": "Вернуться к списку бронирований",
"free_to_pick_another_event_type": "Вы можете выбрать любое другое событие.",
"cancelled": "Отменено",
"no_booking": "Нет бронирования",
"awaiting_host": "Ожидание организатора",
"cancellation_successful": "Отмена прошла успешно",
"really_cancel_booking": "Вы действительно хотите отменить бронирование?",
"cannot_cancel_booking": "Вы не можете отменить это бронирование",
@@ -486,7 +502,9 @@
"booking_payment_initiated": "Начинается оплата за бронирование",
"meeting_ended": "Встреча завершилась",
"form_submitted": "Форма отправлена",
"form_submitted_no_event": "Форма отправлена, встреча не забронирована",
"booking_paid": "Бронирование оплачено",
"ooo_created": "Статус \"Вне офиса\" создан",
"booking_no_show_updated": "Обновление статуса отсутствия на встрече",
"event_triggers": "Триггеры событий",
"subscriber_url": "URL-адрес подписчика",
@@ -625,6 +643,8 @@
"role": "Роль",
"edit_role": "Изменить роль",
"edit_team": "Редактировать команду",
"editing_user": "Редактирование пользователя",
"editing_org": "Редактирование организации",
"reject": "Отклонить",
"reject_all": "Отклонить все",
"accept": "Принять",
@@ -692,6 +712,9 @@
"no_teams": "У вас пока нет команд.",
"no_teams_description": "В команде другие пользователи могут бронировать совместные мероприятия с сотрудниками.",
"submit": "Отправить",
"confirm_remove_attribute": "Да, удалить атрибут",
"remove_attribute": "Удалить атрибут",
"remove_attribute_confirmation_message": "Вы уверены, что хотите удалить атрибут '{{name}}'? Все пользователи, которым он назначен, будут отвязаны.",
"delete": "Удалить",
"update": "Обновить",
"save": "Сохранить",
@@ -873,12 +896,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}} создаст ссылку на встречу в Zoom.",
@@ -969,6 +997,8 @@
"signin_with_google": "Войти через Google",
"signin_with_saml": "Войти с помощью SAML",
"signin_with_saml_oidc": "Войти с помощью SAML/OIDC",
"continue_with_email": "Продолжить с email",
"continue_with_google": "Продолжить с Google",
"last_used": "Последнее использование",
"you_will_need_to_generate": "Вам нужно сгенерировать токен доступа на странице интеграции.",
"import": "Импорт",
@@ -976,6 +1006,8 @@
"access_token": "Токен доступа",
"visit_roadmap": "План действий",
"featured_categories": "Рекомендуемые категории",
"feature_flags": "Флаги функций",
"admin_flags_description": "Здесь вы можете включать и отключать функции вашего экземпляра Cal.com.",
"popular_categories": "Популярные категории",
"number_apps_one": "Приложений: {{count}}",
"number_apps_other": "Приложений: {{count}}",
@@ -1146,6 +1178,7 @@
"set_location": "Установить местоположение",
"update_location": "Обновить местоположение",
"location_updated": "Местоположение обновлено",
"location_update_failed": "Не удалось обновить местоположение",
"guests_added": "Гости добавлены",
"unable_to_add_guests": "Не удалось добавить гостей",
"email_validation_error": "Недопустимый адрес электронной почты",
@@ -1216,6 +1249,8 @@
"number_provided": "Будет указан номер телефона",
"before_event_trigger": "до начала события",
"event_cancelled_trigger": "при отмене события",
"after_hosts_cal_video_no_show": "После того, как организаторы не присоединились к видеовстрече cal",
"after_guests_cal_video_no_show": "После того, как гости не присоединились к видеовстрече cal",
"new_event_trigger": "когда будет забронировано новое событие",
"email_host_action": "отправить письмо организатору",
"email_attendee_action": "отправить письмо участникам",
@@ -1374,6 +1409,8 @@
"event_limit_tab_description": "Как часто можно забронировать для вас событие",
"event_advanced_tab_description": "Настройки календаря и ещё...",
"event_advanced_tab_title": "Расширенные",
"event_payments_tab_title": "Платежи",
"event_payments_tab_description": "Настройка платежей для встречи",
"event_setup_multiple_duration_error": "Настройка события: чтобы выбрать несколько вариантов продолжительности, требуется как минимум 1 параметр.",
"event_setup_multiple_duration_default_error": "Настройка события: выберите допустимую продолжительность по умолчанию.",
"event_setup_booking_limits_error": "Лимиты бронирования должны указываться от меньшей единицы к большей [день, неделя, месяц, год]",
@@ -1514,6 +1551,7 @@
"workflow_example_4": "Отправить участнику напоминание по электронной почте за 1 час до начала события",
"workflow_example_5": "Отправить организатору электронное письмо с пользовательским содержанием при переносе события",
"workflow_example_6": "Отправить организатору SMS с пользовательским содержанием, когда бронируется новое событие",
"count_managed_to_limit": "Учитывать количество бронирований из управляемых типов встреч",
"welcome_to_cal_header": "Добро пожаловать на {{appName}}!",
"edit_form_later_subtitle": "Вы сможете изменить эту информацию позже.",
"connect_calendar_later": "Я подключу свой календарь позже",
@@ -1542,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Маршрутизация",
"routing_forms_description": "Создавайте формы, чтобы направлять участников к нужному сотруднику",
"routing_forms_send_email_owner": "Отправляйте письма владельцу",
"routing_forms_send_email_owner_description": "Отправляйте владельцу формы электронное письмо, когда пользователь ее заполняет",
@@ -1604,6 +1643,7 @@
"format": "Формат",
"uppercase_for_letters": "Использовать только прописные буквы",
"replace_whitespaces_underscores": "Заменить пробелы подчеркиванием",
"platform_billing": "Биллинг платформы",
"manage_billing": "Управление биллингом",
"manage_billing_description": "Управление всем, что связано с биллингом",
"billing_freeplan_title": "В настоящее время вы используете бесплатный тарифный план",
@@ -1631,6 +1671,9 @@
"email_address_action": "отправить письмо на указанный адрес электронной почты",
"after_event_trigger": "после окончания события",
"how_long_after": "Спустя какое время после окончания события?",
"how_long_after_hosts_no_show": "Через какое время после того, как организаторы не появились на видеовстрече cal?",
"how_long_after_guests_no_show": "Через какое время после того, как гости не появились на видеовстрече cal?",
"how_long_after_user_no_show_minutes": "Через сколько минут после того, как пользователи не появились на видеовстрече cal?",
"no_available_slots": "Нет доступных интервалов",
"time_available": "Доступное время",
"cant_find_the_right_conferencing_app_visit_our_app_store": "Не можете найти подходящее приложение для видеоконференций? Посетите наш <1>App Store</1>.",
@@ -1656,6 +1699,7 @@
"billing_frequency": "Периодичность расчетов",
"monthly": "Ежемесячная",
"yearly": "Ежегодная",
"weekly": "Еженедельно",
"checkout": "Оплатить заказ",
"your_team_disbanded_successfully": "Ваша команда расформирована",
"your_org_disbanded_successfully": "Ваша организация расформирована",
@@ -1727,6 +1771,7 @@
"already_have_account": "Уже есть аккаунт?",
"already_have_key": "У меня уже есть ключ:",
"already_have_key_suggestion": "Скопируйте сюда переменную окружения CALCOM_LICENSE_KEY.",
"create_license_key": "Создать лицензионный ключ",
"app_is_enabled": "{{appName}} включено",
"app_is_disabled": "{{appName}} отключено",
"keys_have_been_saved": "Ключи сохранены",
@@ -1734,6 +1779,12 @@
"disable_app_description": "Отключение этого приложения может привести к проблемам при взаимодействии ваших пользователей с Cal",
"edit_keys": "Изменить ключи",
"admin_apps_description": "Включить приложения для вашего экземпляра Cal",
"admin_users_description": "Список всех пользователей в вашем аккаунте, включая их имя, должность, email и роль.",
"admin_users_add_description": "Здесь вы можете добавить нового пользователя.",
"admin_users_edit_description": "Здесь вы можете редактировать текущего пользователя.",
"admin_orgs_edit_description": "Здесь вы можете редактировать организацию.",
"admin_oAuth_description": "Добавить новые OAuth клиенты",
"admin_lockedSMS_description": "Блокировка или разблокировка отправки SMS для пользователей",
"no_available_apps": "Нет доступных приложений",
"no_available_apps_description": "Убедитесь, что в вашей установке в 'packages/app-store' есть приложения",
"no_apps": "В этом экземпляре Cal нет включенных приложений",
@@ -1778,6 +1829,7 @@
"wrong_code": "Неправильный код подтверждения",
"not_verified": "Еще не подтвержден",
"no_availability_in_month": "В этом месяце ({{month}}) нет свободных мест",
"view_previous_month": "Просмотреть предыдущий месяц",
"view_next_month": "Посмотреть следующий месяц",
"send_code": "Отправить код",
"number_verified": "Номер подтвержден",
@@ -1886,6 +1938,7 @@
"default_app_link_title": "Установить ссылку на приложение по умолчанию",
"default_app_link_description": "Если установить ссылку на приложение по умолчанию, она будет присваиваться всем вновь создаваемым событиям.",
"organizer_default_conferencing_app": "Приложение организатора по умолчанию",
"organizer_default_conferencing_app_not_found": "У {{organizer}} нет приложения для конференций по умолчанию",
"under_maintenance": "Идет техническое обслуживание",
"under_maintenance_description": "Команда {{appName}} выполняет плановое техобслуживание. По вопросам обращайтесь в службу поддержки.",
"event_type_seats": "Мест: {{numberOfSeats}}",
@@ -1901,6 +1954,7 @@
"not_enough_seats": "Недостаточно мест",
"form_builder_field_already_exists": "Поле с таким именем уже существует",
"show_on_booking_page": "Показывать на странице бронирования",
"visit_cancelled_booking": "Вы можете перейти на страницу отмененной встречи",
"get_started_zapier_templates": "Начать работу с шаблонами Zapier",
"team_is_unpublished": "Команда {{team}} снята с публикации",
"org_is_unpublished_description": "Эта ссылка на организацию в настоящее время недоступна. Свяжитесь с владельцем организации и попросите его опубликовать ее.",
@@ -1997,6 +2051,8 @@
"looking_for_more_insights": "Нужно больше Insights?",
"filters": "Фильтры",
"add_filter": "Добавить фильтр",
"add_rule": "Добавить правило",
"add_rule_group": "Добавить группу правил",
"remove_filters": "Очистить все фильтры",
"email_verified": "Электронная почта подтверждена",
"select_user": "Выбрать пользователя",
@@ -2034,6 +2090,8 @@
"error_charging_card": "Не удалось списать комиссию за неявку. Повторите попытку позже.",
"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}}.",
@@ -2097,6 +2155,9 @@
"set_up_your_organization": "Настройка профиля организации",
"set_up_your_platform_organization": "Настройте свою платформу",
"organizations_description": "Организация — это общая рабочая среда, в которой команды могут создавать общие типы событий, приложения, рабочие процессы и многое другое.",
"plans": "Тарифы",
"platform": "Платформа",
"platform_description": "Управление всем, что связано с платформой",
"platform_organization_description": "Платформа Cal.com позволяет легко интегрировать планирование в ваше приложение с помощью API и атомов платформы.",
"must_enter_organization_name": "Необходимо ввести название организации",
"must_enter_organization_admin_email": "Необходимо ввести ваш адрес электронной почты в организации",
@@ -2157,6 +2218,8 @@
"add_times_to_your_email": "Выберите доступные промежутки времени и вставьте их в письмо",
"select_time": "Выбрать время",
"select_date": "Выбрать дату",
"only_show_if_parameter_set": "Показывать только если параметр задан",
"add_parameter": "Добавить параметр",
"connecting_you_to_someone": "Мы соединяем вас с кем-то.",
"please_do_not_close_this_tab": "Пожалуйста, не закрывайте эту вкладку",
"see_all_available_times": "Посмотреть все доступные интервалы времени",
@@ -2170,8 +2233,11 @@
"add_to_team": "Добавить в команду",
"remove_users_from_org": "Удалить пользователей из организации",
"remove_users_from_org_confirm": "Удалить {{userCount}} пользователей из этой организации?",
"remove_users_from_team": "Удалить пользователей из команды",
"remove_users_from_team_confirm": "Вы уверены, что хотите удалить {{userCount}} пользователей из этой команды?",
"user_has_no_schedules": "Этот пользователь еще не настроил ни одного расписания",
"user_isnt_in_any_teams": "Этот пользователь не относится ни к одной команде",
"user_has_no_app_installed": "Этот пользователь еще не установил ни одного приложения",
"requires_booker_email_verification": "Требуется подтверждение электронной почты пользователя, оформляющего бронирование",
"description_requires_booker_email_verification": "Чтобы подтвердить электронную почту пользователя, оформляющего бронирование, до планирования событий",
"requires_confirmation_mandatory": "Участникам можно отправлять сообщения, только если тип события предполагает подтверждение.",
@@ -2229,6 +2295,10 @@
"seat_options_doesnt_multiple_durations": "Параметр «место» не поддерживает несколько вариантов продолжительности",
"include_calendar_event": "Включить событие календаря",
"oAuth": "OAuth",
"oAuth_client_creation_form": "Форма создания OAuth клиента",
"oAuth_client_creation_form_description": "Это форма для создания нового OAuth клиента",
"oAuth_client_updation_form": "Форма обновления OAuth клиента",
"oAuth_client_updation_form_description": "Это форма для редактирования существующего OAuth клиента",
"recently_added": "Недавно добавленные",
"connect_all_calendars": "Подключите все свои календари",
"connect_all_calendars_description": "{{appName}} считывает доступность из всех ваших существующих календарей.",
@@ -2283,6 +2353,7 @@
"add_client": "Добавить клиента",
"copy_client_secret_info": "После копирования секретного ключа вы больше не сможете его просматривать",
"add_new_client": "Добавить нового клиента",
"add_new_user": "Добавить нового пользователя",
"this_app_is_not_setup_already": "Это приложение еще не настроено",
"as_csv": "как CSV",
"overlay_my_calendar": "Объединить календарь",
@@ -2313,6 +2384,7 @@
"unified_billing_description": "Добавьте одну кредитную карту для оплаты всех подписок вашей команды",
"advanced_managed_events": "Расширенные управляемые типы событий",
"advanced_managed_events_description": "Добавьте одну кредитную карту для оплаты всех подписок вашей команды",
"enterprise": "Корпоративный",
"enterprise_description": "Обновитесь до Enterprise, чтобы создать свою организацию",
"create_your_org": "Создайте свою организацию",
"create_your_org_description": "Обновитесь до Organizations и получите поддомен, единый биллинг, аналитику, расширенные возможности брендирования и многое другое",
@@ -2451,6 +2523,7 @@
"add_emails": "Добавить Email-адреса",
"add_email_description": "Добавьте адрес электронной почты, чтобы заменить основной или использовать его в качестве альтернативного для ваших типов событий.",
"confirm_email": "Подтвердите ваш email",
"confirming_your_booking_sms": "$t(hey_there) {{name}}, подтверждаем вашу встречу на {{date}}.",
"scheduler_first_name": "Имя человека, бронирующего встречу",
"scheduler_last_name": "Фамилия человека, бронирующего встречу",
"scheduler_name": "Имя планировщика",
@@ -2468,6 +2541,7 @@
"lock_org_users_eventtypes_description": "Запретить участникам создавать свои собственные типы событий.",
"add_to_event_type": "Добавить к типу события",
"create_account_password": "Создать пароль для аккаунта",
"create_account_with_saml": "Создать аккаунт через SAML",
"error_creating_account_password": "Не удалось создать пароль для аккаунта",
"cannot_create_account_password_cal_provider": "Невозможно создать пароль для аккаунтов cal",
"cannot_create_account_password_already_existing": "Невозможно создать пароль для уже существующих аккаунтов",
@@ -2515,6 +2589,7 @@
"hide_support": "Скрыть поддержку",
"event_ratings": "Средние оценки",
"event_no_show": "Организатор не явился",
"event_no_show_guest": "Неявка (Гость)",
"recent_ratings": "Недавние оценки",
"no_ratings": "Оценок нет",
"no_ratings_description": "Добавьте рабочий процесс с 'Оценкой', чтобы собирать отзывы после встреч",
@@ -2541,6 +2616,7 @@
"team_select_info": "триггеры для всех типов событий команды и всех личных типов событий участников команды",
"no_show_updated": "Статус неявки обновлен для участников",
"email_copied": "Электронная почта скопирована",
"phone_number_copied": "Номер телефона скопирован",
"USER_PENDING_MEMBER_OF_THE_ORG": "Пользователь ожидает подтверждения членства в организации",
"USER_ALREADY_INVITED_OR_MEMBER": "Пользователь уже приглашен или является членом",
"USER_MEMBER_OF_OTHER_ORGANIZATION": "Пользователь является членом другой организации, которая не входит в эту команду.",
@@ -2563,6 +2639,7 @@
"new_option": "Новый вариант",
"update_profile": "Обновить профиль",
"attribute_updated_successfully": "Атрибут успешно обновлен",
"attribute_deleted_successfully": "Атрибут успешно удален",
"attributes_edited_successfully": "Атрибуты успешно отредактированы",
"attribute_meta_description": "Управляйте атрибутами для членов вашей команды",
"attributes_edit_description": "Редактируйте атрибуты для членов вашей команды",
@@ -2576,6 +2653,7 @@
"disable_email": "Отключить email",
"grant_admin_api": "Предоставить доступ к Admin API",
"revoke_admin_api": "Отозвать доступ к Admin API",
"add_calendar_label": "Добавить календарь {{calendar}}",
"apple_connect_atom_label": "Подключить календарь Apple",
"apple_connect_atom_already_connected_label": "Календарь Apple подключен",
"apple_connect_atom_loading_label": "Проверка календаря Apple",
@@ -2585,11 +2663,140 @@
"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}} вариантов",
"reschedule_with_same_round_robin_host_title": "Переназначить с тем же организатором Round-Robin",
"reschedule_with_same_round_robin_host_description": "Перенесённые события будут назначены тому же ведущему, что и изначально",
"disable_input_if_prefilled": "Отключить ввод, если URL-идентификатор уже заполнен",
"booking_limits": "Ограничения бронирования",
"booking_limits_team_description": "Ограничения бронирования для участников команды по всем типам командных событий",
"limit_team_booking_frequency_description": "Ограничить количество бронирований участников по всем типам командных событий",
"booking_limits_updated_successfully": "Ограничения бронирования успешно обновлены",
"you_are_unauthorized_to_make_this_change_to_the_booking": "У вас нет прав для внесения этих изменений в бронирование",
"matching_members": "Подходящие участники",
"matching_members_queue_using_attribute_weights": "Очередь подходящих участников (с использованием весов атрибутов)",
"matching_members_queue_using_event_assignee_weights": "Очередь подходящих участников (с использованием весов назначенных на событие)",
"no_matching_members": "Нет подходящих участников. Будут использованы участники команды, назначенные на тип события.",
"no_matching_members_will_fallback_to_all_assigned_members": "Нет подходящих участников. Будут использованы участники команды, назначенные на тип события. Рекомендуется добавить резервный вариант или исправить логику using_fallback_members",
"hide_calendar_event_details": "Скрыть детали событий календаря в общих календарях",
"description_hide_calendar_event_details": "Когда календарь общий, события видны читателям, но их детали скрыты от пользователей без прав на запись",
"last_number_of_days": "последние {{count}} дней",
"month_to_date": "с начала месяца",
"year_to_date": "с начала года",
"custom_range": "произвольный период",
"show_all_columns": "Показать все столбцы",
"toggle_columns": "Переключить столбцы",
"no_columns_found": "Столбцы не найдены",
"salesforce_create_record_as": "При бронировании добавлять события и новых участников как:",
"salesforce_lead": "Лид",
"salesforce_contact_under_account": "Контакт в рамках организации",
"salesforce_skip_entry_creation": "Пропустить создание записи {{entry}}, если она отсутствует в Salesforce",
"salesforce_if_account_does_not_exist": "Если контакт не существует в рамках организации, создать новый лид из участника",
"salesforce_create_new_contact_under_account": "Создать новый контакт в организации на основе домена электронной почты участника и существующих контактов",
"mass_assign_attributes": "Массовое назначение атрибутов",
"reroute_preview_custom_message": "Это приведет к отображению пользовательского сообщения. Попробуйте изменить ответ для перенаправления на событие",
"reroute_preview_external_redirect": "Это приведет к перенаправлению на {{externalUrl}}. Попробуйте изменить ответ для перенаправления на событие",
"reroute_preview_possible_host": "Возможный организатор",
"current_routing_status": "Текущий статус маршрутизации",
"reroute_booking": "Перенаправить бронирование",
"reroute_booking_description": "Перенаправить бронирование другим участникам команды",
"verify_new_route": "Проверить новый маршрут",
"reroute": "Перенаправить",
"new_routing_status": "Новый статус маршрутизации",
"salesforce_book_directly_with_attendee_owner": "Если участник существует в Salesforce, бронировать напрямую с владельцем",
"salesforce_check_owner_of": "Сущность для проверки владельца для прямого бронирования",
"account": "Организация",
"choose_hosts_schedule": "Выбрать расписания организаторов",
"hosts_schedule_description": "Вы можете выбрать разное расписание для каждого организатора. По умолчанию выбрано стандартное расписание каждого организатора.",
"no_hosts_description": "Нет назначенных организаторов, добавьте их во вкладке назначения.",
"team_member_round_robin_reassign": "Вручную переназначить организатора round robin",
"team_member_round_robin_reassign_description": "Вручную переназначить организатора round robin другому доступному организатору",
"round_robin_reassign_description": "Переназначить организатора round robin другому доступному организатору",
"invalid_round_robin_host": "Новый пользователь не является допустимым организатором round robin для этого типа события",
"user_is_round_robin_fixed": "Новый пользователь является постоянным организатором для этого типа события",
"reschedule_with_same_timeslot": "Перенести на то же время",
"reschedule_with_same_timeslot_of_new_event": "Перенести на то же время нового события",
"reschedule_to_the_new_event_with_different_timeslot": "Перенести на новое событие с другим временем",
"reschedule_with_different_timeslot": "Перенести на другое время",
"we_dont_have_id_for_the_new_event": "У нас нет ID для нового события. Пожалуйста, перейдите в форму маршрутизации и сохраните её снова.",
"rerouted_booking_successfully_redirecting_to_booking_page": "Бронирование успешно перенаправлено. Перенаправление на страницу бронирования",
"on_booking_write_to_event_object": "При бронировании записать в объект события",
"field_name": "Название поля",
"add_new_field": "Добавить новое поле",
"you_dont_have_access_to_reroute_this_booking": "У вас нет доступа для перенаправления этого бронирования",
"form_response_not_found": "Ответ формы не найден",
"contact_owner": "Владелец контакта",
"contact_owner_not_found": "Не найден",
"using_fallback_members": "Использование резервных участников",
"chosen_route": "Выбранный маршрут",
"attribute_logic_matched": "Логика атрибутов совпала",
"attribute_logic_fallback_matched": "Резервная логика атрибутов совпала",
"all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules": "Все назначенные участники командного типа события. Рекомендуется добавить правила атрибутов.",
"all_assigned_members_of_the_team_event_type_consider_adding_some_attribute_rules_to_fallback": "Все назначенные участники командного типа события. Рекомендуется добавить правила атрибутов для резервного варианта.",
"all_assigned_members_of_the_team_event_type_consider_tweaking_fallback_to_have_a_match": "Все назначенные участники командного типа события. Рекомендуется настроить резервный вариант для получения совпадений.",
"warning": "Предупреждение",
"fallback_attribute_logic_description": "Резервный вариант: Если нет подходящих участников команды, использовать тех, кто соответствует следующим критериям (по умолчанию соответствует всем назначенным участникам команды события)",
"fallback_attribute_logic_warning": "Предупреждение резервного варианта",
"fallback_not_needed": "Не требуется",
"confirm_reassign_unavailable": "Организатор недоступен",
"confirm_reassign_available": "Организатор доступен",
"reassign_unavailable_team_member_description": "Вы уверены, что хотите переназначить эту встречу недоступному организатору?",
"reassign_available_team_member_description": "Вы уверены, что хотите переназначить эту встречу?",
"yes_reassign": "Да, переназначить",
"reassign_reason": "Причина переназначения",
"reassigned_by": "Переназначено",
"row": "строка",
"rows": "строк",
"attendee_timezone_variable": "Часовой пояс участника",
"event_start_time_in_attendee_timezone_variable": "Время начала события в часовом поясе участника",
"event_end_time_in_attendee_timezone_variable": "Время окончания события в часовом поясе участника",
"attendee_timezone_info": "Часовой пояс участника",
"event_start_time_in_attendee_timezone_info": "Время начала события в часовом поясе участника",
"event_end_time_in_attendee_timezone_info": "Время окончания события в часовом поясе участника",
"salesforce_create_event_on_contact": "Создать событие для контакта, если он существует. В противном случае создать для лида",
"salesforce_owner_name_to_change": "Имя владельца для изменения",
"dont_have_access_this_page": "У вас нет доступа к этой странице",
"you_need_admin_or_owner_privileges_to_access": "Для просмотра или взаимодействия с этой страницей необходимы права администратора или владельца",
"field_type": "Тип поля",
"when_to_write": "Когда записывать",
"salesforce_on_booking_write_to_record": "При бронировании записать в пользовательское поле записи {{record}} участника",
"on_every_booking": "При каждом бронировании",
"only_if_field_is_empty": "Только если поле пустое",
"booking_start_date": "Дата начала встречи",
"booking_created_date": "Дата создания встречи",
"booking_reassigned_to_host": "Встреча переназначена {{host}}",
"no_contact_owner": "Нет владельца контакта",
"routing_forms_created": "Созданные формы маршрутизации",
"routing_forms_total_responses": "Всего ответов",
"routing_forms_total_responses_without_booking": "Всего ответов без бронирования",
"routing_forms_total_responses_with_booking": "Всего ответов с бронированием",
"routing_form_insights": "Нет бронирований",
"routing_form_insights_form_name_header": "Название формы",
"routing_form_insights_booking_status_header": "Бронирование",
"routing_form_insights_response_no_booking": "Без бронирования",
"routing_form_insights_booked_by": "Забронировано",
"routing_form_insights_created_at": "Создано",
"routing_form_insights_booking_status": "Статус бронирования",
"routing_form_insights_booking_at": "Время бронирования",
"routing_form_insights_submitted_at": "Время отправки",
"access_denied": "Доступ запрещен",
"salesforce_route_to_owner": "Владелец контакта будет организатором Round Robin, если доступен",
"salesforce_do_not_route_to_owner": "Владелец контакта не будет принудительно назначен (но может быть организатором, если соответствует атрибутам и критериям Round Robin)",
"salesforce_route_to_custom_lookup_field": "Направить пользователю, соответствующему полю поиска в аккаунте",
"salesforce_option": "Опция Salesforce",
"lookup_field_name": "Название поля поиска",
"translate_description_button": "Перевести описание на язык браузера посетителя с помощью ИИ",
"rr_distribution_method": "Распределение",
"rr_distribution_method_description": "Позволяет оптимизировать распределение для максимальной доступности или для достижения более сбалансированного назначения",
"rr_distribution_method_availability_title": "Максимизировать доступность",
"rr_distribution_method_availability_description": "Позволяет участникам бронировать встречи, когда организатор доступен. Используйте этот метод для максимизации количества потенциальных встреч, когда равномерное распределение встреч между организаторами менее важно.",
"rr_distribution_method_balanced_title": "Балансировка нагрузки",
"rr_distribution_method_balanced_description": "Мы будем отслеживать количество бронирований у каждого организатора и сравнивать их между собой, отключая тех организаторов, у которых слишком много встреч, чтобы обеспечить равномерное распределение.",
"exclude_emails_that_contain": "Исключить email-адреса, содержащие...",
"exclude_emails_match_found_error_message": "Пожалуйста, введите действительный рабочий email-адрес",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Добавьте строки выше ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Vyberte niekoľko dostupných časov a vložte ich do svojho e-mailu",
"select_time": "Vybrať čas",
"select_date": "Vybrať dátum",
"only_show_if_parameter_set": "Zobraziť iba ak je parameter nastavený",
"add_parameter": "Pridať parameter",
"connecting_you_to_someone": "Pripájame vás k niekomu z {{orgName}}.",
"please_do_not_close_this_tab": "Prosím, nezatvárajte ani neobnovujte túto stránku",
"see_all_available_times": "Zobraziť všetky dostupné časy",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Smerovať na používateľa, ktorý zodpovedá poľu vyhľadávania na účte",
"salesforce_option": "Možnosť Salesforce",
"lookup_field_name": "Názov poľa vyhľadávania",
"translate_description_button": "Preložiť popis do jazyka prehliadača návštevníka pomocou AI",
"rr_distribution_method": "Distribúcia",
"rr_distribution_method_description": "Umožňuje optimalizovať distribúciu pre maximálnu dostupnosť alebo pre vyváženejšie prideľovanie.",
"rr_distribution_method_availability_title": "Maximalizovať dostupnosť",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Izaberite nekoliko dostupnih termina i ugradite ih u svoj imejl",
"select_time": "Izaberite vreme",
"select_date": "Izaberite datum",
"only_show_if_parameter_set": "Prikaži samo ako je parametar podešen",
"add_parameter": "Dodaj parametar",
"connecting_you_to_someone": "Povezujemo vas sa nekim iz {{orgName}}.",
"please_do_not_close_this_tab": "Молимо вас да не затварате или освежавате ову страницу",
"see_all_available_times": "Pogledajte sva dostupna vremena",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Usmeri ka korisniku koji odgovara polju za pretragu na nalogu",
"salesforce_option": "Salesforce opcija",
"lookup_field_name": "Naziv polja za pretragu",
"translate_description_button": "Prevedi opis na jezik posetiočevog pretraživača koristeći AI",
"rr_distribution_method": "Distribucija",
"rr_distribution_method_description": "Omogućava optimizaciju distribucije za maksimalnu dostupnost ili za balansiranu raspodelu.",
"rr_distribution_method_availability_title": "Maksimalna dostupnost",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Välj några tillgängliga tider och bädda in dem i din e-post",
"select_time": "Välj tid",
"select_date": "Välj datum",
"only_show_if_parameter_set": "Visa endast om en parameter är inställd",
"add_parameter": "Lägg till parameter",
"connecting_you_to_someone": "Vi kopplar dig till någon från {{orgName}}.",
"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",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Rikta till en användare som matchar ett uppslagsfält på ett konto",
"salesforce_option": "Salesforce-alternativ",
"lookup_field_name": "Uppslagsfältsnamn",
"translate_description_button": "Översätt beskrivningen till besökarens webbläsarspråk med hjälp av AI",
"rr_distribution_method": "Distribution",
"rr_distribution_method_description": "Möjliggör optimering av distributionen för maximal tillgänglighet eller för att uppnå en mer balanserad fördelning.",
"rr_distribution_method_availability_title": "Maximera tillgänglighet",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Uygun zamanlardan bazılarını seçin ve bunları E-postanıza ekleyin",
"select_time": "Saati Seçin",
"select_date": "Tarihi Seçin",
"only_show_if_parameter_set": "Yalnızca parametre ayarlandığında göster",
"add_parameter": "Parametre ekle",
"connecting_you_to_someone": "Sizi {{orgName}}'den birisiyle bağlıyoruz.",
"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",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Bir hesaptaki arama alanıyla eşleşen bir kullanıcıya yönlendir",
"salesforce_option": "Salesforce seçeneği",
"lookup_field_name": "Arama alanı adı",
"translate_description_button": "Açıklamayı ziyaretçinin tarayıcı diline yapay zeka kullanarak çevir",
"rr_distribution_method": "Dağıtım",
"rr_distribution_method_description": "Maksimum kullanılabilirlik için dağıtımı optimize etmeye veya daha dengeli bir atama sağlamaya olanak tanır.",
"rr_distribution_method_availability_title": "Kullanılabilirliği maksimize et",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Виберіть кілька доступних часових проміжків і вбудуйте їх в електронний лист",
"select_time": "Виберіть час",
"select_date": "Виберіть дату",
"only_show_if_parameter_set": "Показувати, лише якщо параметр встановлено",
"add_parameter": "Додати параметр",
"connecting_you_to_someone": "Ми з'єднуємо вас із представником {{orgName}}.",
"please_do_not_close_this_tab": "Будь ласка, не закривайте і не оновлюйте цю сторінку",
"see_all_available_times": "Переглянути доступні часові проміжки",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Маршрутизація до користувача, який відповідає полю пошуку в обліковому записі",
"salesforce_option": "Опція Salesforce",
"lookup_field_name": "Назва поля пошуку",
"translate_description_button": "Перекласти опис мовою браузера відвідувача за допомогою ШІ",
"rr_distribution_method": "Розподіл",
"rr_distribution_method_description": "Дозволяє оптимізувати розподіл для максимальної доступності або для більш збалансованого розподілу завдань.",
"rr_distribution_method_availability_title": "Максимізувати доступність",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "Chọn một số thời gian trống và nhúng chúng vào trong Email của bạn",
"select_time": "Chọn thời gian",
"select_date": "Chọn ngày",
"only_show_if_parameter_set": "Chỉ hiển thị khi tham số được thiết lập",
"add_parameter": "Thêm tham số",
"connecting_you_to_someone": "Chúng tôi đang kết nối bạn với một người từ {{orgName}}.",
"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",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "Chuyển đến người dùng phù hợp với trường tra cứu trên tài khoản",
"salesforce_option": "Tùy chọn Salesforce",
"lookup_field_name": "Tên trường tra cứu",
"translate_description_button": "Dịch mô tả sang ngôn ngữ trình duyệt của khách bằng AI",
"rr_distribution_method": "Phân phối",
"rr_distribution_method_description": "Cho phép tối ưu hóa việc phân phối để đạt khả năng sẵn sàng tối đa hoặc hướng đến việc phân công cân bằng hơn.",
"rr_distribution_method_availability_title": "Tối đa hóa khả năng sẵn sàng",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "选择几个可预约时间并将它们嵌入到您的电子邮件",
"select_time": "选择时间",
"select_date": "选择日期",
"only_show_if_parameter_set": "仅在设置参数时显示",
"add_parameter": "添加参数",
"connecting_you_to_someone": "我们正在为您接通{{orgName}}的工作人员。",
"please_do_not_close_this_tab": "请不要关闭或刷新此页面",
"see_all_available_times": "查看所有可预约时间",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "路由至与账户查找字段匹配的用户",
"salesforce_option": "Salesforce选项",
"lookup_field_name": "查找字段名称",
"translate_description_button": "使用AI将描述翻译成访问者浏览器的语言",
"rr_distribution_method": "分配方式",
"rr_distribution_method_description": "可以优化分配方式以实现最大可用性或更均衡的任务分配。",
"rr_distribution_method_availability_title": "最大化可用性",
@@ -2218,6 +2218,8 @@
"add_times_to_your_email": "選取幾個開放預約的時段,然後嵌入您的電子郵件",
"select_time": "選取時間",
"select_date": "選取日期",
"only_show_if_parameter_set": "僅在設定參數時顯示",
"add_parameter": "新增參數",
"connecting_you_to_someone": "我們正在為您連接 {{orgName}} 的人員。",
"please_do_not_close_this_tab": "請勿關閉或重新整理此頁面",
"see_all_available_times": "查看所有可預約時段",
@@ -2787,6 +2789,7 @@
"salesforce_route_to_custom_lookup_field": "路由至符合帳戶查詢欄位的使用者",
"salesforce_option": "Salesforce選項",
"lookup_field_name": "查詢欄位名稱",
"translate_description_button": "使用 AI 將描述翻譯成訪客瀏覽器的語言",
"rr_distribution_method": "分配方式",
"rr_distribution_method_description": "可以優化分配方式以達到最大可用性或更平衡的任務分配。",
"rr_distribution_method_availability_title": "最大化可用性",
+50 -45
View File
@@ -2,7 +2,7 @@ import type { Table } from "@tanstack/react-table";
import { describe, it, expect, vi } from "vitest";
import type { UserTableUser } from "@calcom/features/users/components/UserTable/types";
import { generateCsvRaw, generateHeaderFromReactTable } from "@calcom/lib/csvUtils";
import { generateCsvRawForMembersTable, generateHeaderFromReactTable } from "@calcom/lib/csvUtils";
import type { MembershipRole } from "@calcom/prisma/enums";
function createMockTable(data: UserTableUser[]): Table<UserTableUser> {
@@ -52,9 +52,9 @@ function createMockTable(data: UserTableUser[]): Table<UserTableUser> {
} as unknown as Table<UserTableUser>;
}
describe("generate Csv for Org Users Table", () => {
describe("generate Csv for Org Users Table @test", () => {
const orgDomain = "https://acme.cal.com";
const mockAttributeIds = ["attr1", "attr2"];
const HEADER_IDS_TO_EXCLUDE = ["select", "actions"];
const mockUser: UserTableUser = {
id: 1,
username: "testuser",
@@ -69,22 +69,20 @@ describe("generate Csv for Org Users Table", () => {
attributes: [],
};
it("should return null if no headers", () => {
const mockTableNoHeaders = {
getHeaderGroups: vi.fn().mockReturnValue([]),
} as unknown as Table<UserTableUser>;
expect(generateCsvRaw([], [], mockAttributeIds)).toBeNull();
it("should throw if no headers", () => {
expect(() => generateCsvRawForMembersTable([], [], mockAttributeIds, orgDomain)).toThrow();
});
it("should generate correct CSV headers", () => {
const mockTable = createMockTable([]);
const csv = generateCsvRaw(
generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [],
const csv = generateCsvRawForMembersTable(
generateHeaderFromReactTable(mockTable) ?? [],
[],
mockAttributeIds
mockAttributeIds,
orgDomain
);
const headers = csv?.split("\n")[0];
expect(headers).toBe("Members,Role,Teams,Attribute 1,Attribute 2");
expect(headers).toBe("Members,Link,Role,Teams,Attribute 1,Attribute 2");
});
it("should handle user with single attribute value", () => {
@@ -97,15 +95,16 @@ describe("generate Csv for Org Users Table", () => {
];
const mockTable = createMockTable(mockData);
const csv = generateCsvRaw(
generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [],
const csv = generateCsvRawForMembersTable(
generateHeaderFromReactTable(mockTable) ?? [],
mockData,
mockAttributeIds
mockAttributeIds,
orgDomain
);
expect(csv).toMatchInlineSnapshot(`
"Members,Role,Teams,Attribute 1,Attribute 2
test@example.com,MEMBER,Team1,value1,"
"Members,Link,Role,Teams,Attribute 1,Attribute 2
test@example.com,https://acme.cal.com/testuser,MEMBER,Team1,value1,"
`);
});
@@ -122,15 +121,16 @@ describe("generate Csv for Org Users Table", () => {
];
const mockTable = createMockTable(mockData);
const csv = generateCsvRaw(
generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [],
const csv = generateCsvRawForMembersTable(
generateHeaderFromReactTable(mockTable) ?? [],
mockData,
mockAttributeIds
mockAttributeIds,
orgDomain
);
expect(csv).toMatchInlineSnapshot(`
"Members,Role,Teams,Attribute 1,Attribute 2
test@example.com,MEMBER,Team1,"value1,value2","
"Members,Link,Role,Teams,Attribute 1,Attribute 2
test@example.com,https://acme.cal.com/testuser,MEMBER,Team1,"value1,value2","
`);
});
@@ -147,15 +147,16 @@ describe("generate Csv for Org Users Table", () => {
];
const mockTable = createMockTable(mockData);
const csv = generateCsvRaw(
generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [],
const csv = generateCsvRawForMembersTable(
generateHeaderFromReactTable(mockTable) ?? [],
mockData,
mockAttributeIds
mockAttributeIds,
orgDomain
);
expect(csv).toMatchInlineSnapshot(`
"Members,Role,Teams,Attribute 1,Attribute 2
test@example.com,MEMBER,"Team1,Team2",,"
"Members,Link,Role,Teams,Attribute 1,Attribute 2
test@example.com,https://acme.cal.com/testuser,MEMBER,"Team1,Team2",,"
`);
});
@@ -169,15 +170,16 @@ describe("generate Csv for Org Users Table", () => {
];
const mockTable = createMockTable(mockData);
const csv = generateCsvRaw(
generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [],
const csv = generateCsvRawForMembersTable(
generateHeaderFromReactTable(mockTable) ?? [],
mockData,
mockAttributeIds
mockAttributeIds,
orgDomain
);
expect(csv).toMatchInlineSnapshot(`
"Members,Role,Teams,Attribute 1,Attribute 2
test@example.com,MEMBER,"Team,1","value,1","
"Members,Link,Role,Teams,Attribute 1,Attribute 2
test@example.com,https://acme.cal.com/testuser,MEMBER,"Team,1","value,1","
`);
});
@@ -185,22 +187,24 @@ describe("generate Csv for Org Users Table", () => {
const roles: MembershipRole[] = ["OWNER", "ADMIN", "MEMBER"];
const mockData: UserTableUser[] = roles.map((role) => ({
...mockUser,
username: role.toLowerCase(),
role,
email: `${role.toLowerCase()}@example.com`,
}));
const mockTable = createMockTable(mockData);
const csv = generateCsvRaw(
generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [],
const csv = generateCsvRawForMembersTable(
generateHeaderFromReactTable(mockTable) ?? [],
mockData,
mockAttributeIds
mockAttributeIds,
orgDomain
);
expect(csv).toMatchInlineSnapshot(`
"Members,Role,Teams,Attribute 1,Attribute 2
owner@example.com,OWNER,,,
admin@example.com,ADMIN,,,
member@example.com,MEMBER,,,"
"Members,Link,Role,Teams,Attribute 1,Attribute 2
owner@example.com,https://acme.cal.com/owner,OWNER,,,
admin@example.com,https://acme.cal.com/admin,ADMIN,,,
member@example.com,https://acme.cal.com/member,MEMBER,,,"
`);
});
@@ -208,21 +212,22 @@ describe("generate Csv for Org Users Table", () => {
const mockData: UserTableUser[] = [
{
...mockUser,
username: null,
username: "testuser",
avatarUrl: null,
},
];
const mockTable = createMockTable(mockData);
const csv = generateCsvRaw(
generateHeaderFromReactTable(mockTable, HEADER_IDS_TO_EXCLUDE) ?? [],
const csv = generateCsvRawForMembersTable(
generateHeaderFromReactTable(mockTable) ?? [],
mockData,
mockAttributeIds
mockAttributeIds,
orgDomain
);
expect(csv).toMatchInlineSnapshot(`
"Members,Role,Teams,Attribute 1,Attribute 2
test@example.com,MEMBER,,,"
"Members,Link,Role,Teams,Attribute 1,Attribute 2
test@example.com,https://acme.cal.com/testuser,MEMBER,,,"
`);
});
});
@@ -112,6 +112,7 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -182,7 +183,7 @@ describe("handleChildrenEventTypes", () => {
bookingLimits: undefined,
},
});
const { profileId, ...rest } = evType;
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
@@ -265,6 +266,7 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
...evType
} = mockFindFirstEventType({
id: 123,
@@ -337,7 +339,7 @@ describe("handleChildrenEventTypes", () => {
length: 30,
},
});
const { profileId, ...rest } = evType;
const { profileId, autoTranslateDescriptionEnabled, ...rest } = evType;
expect(prismaMock.eventType.update).toHaveBeenCalledWith({
data: {
...rest,
@@ -378,6 +380,7 @@ describe("handleChildrenEventTypes", () => {
lockTimeZoneToggleOnBookingPage,
useEventTypeDestinationCalendarEmail,
secondaryEmailId,
autoTranslateDescriptionEnabled,
...evType
} = mockFindFirstEventType({
metadata: { managedEventConfig: {} },
+4
View File
@@ -8,6 +8,10 @@
"path": "/api/tasks/cron",
"schedule": "* * * * *"
},
{
"path": "/api/calendar-cache/cron",
"schedule": "* * * * *"
},
{
"path": "/api/tasks/cleanup",
"schedule": "0 0 * * *"
@@ -7,11 +7,6 @@ To enable OAuth in one of your apps, you will need a Client ID, Client Secret, A
<img src="/images/oauth-zapier.png" />
### OAuth Client Credentials
Only the Cal.com team can create new OAuth clients. Please contact us at [support@cal.com](mailto:support@cal.com) with the following details: client name, redirect URI (provided by the app), app logo, and a link to the app.
The Cal.com team will register the app and provide you with the Client ID and Client Secret. Keep these credentials confidential and secure.
### Authorization URL
To initiate the OAuth flow, direct users to the following authorization URL:
- `https://app.cal.com/auth/oauth2/authorize`
+8 -4
View File
@@ -4,13 +4,16 @@
"source": "en",
"targets": [
"ar",
"az",
"bg",
"ca",
"cs",
"da",
"az",
"cs",
"de",
"el",
"es",
"es-419",
"eu",
"et",
"fr",
"he",
@@ -25,14 +28,15 @@
"pt-BR",
"pt",
"ro",
"ru",
"sk-SK",
"sr",
"sv",
"tr",
"uk",
"vi",
"zh-CN",
"zh-TW",
"sk-SK"
"zh-TW"
]
},
"buckets": {
+3
View File
@@ -2220,6 +2220,8 @@ checksums:
add_times_to_your_email: 572a04f56df1ec2623ea7ee8c9c9c5bb
select_time: cb9cc2393ca45c6faea13fe4c9d4d6a5
select_date: 1e6063bfd25d982048726ff586df55dc
only_show_if_parameter_set: 0c49b94d7ed0f93d150d19f11a3b62d8
add_parameter: cca109791a0e6afc2ea230e585791388
connecting_you_to_someone: 23f139181e74b0afe63cc9493e1f41bd
please_do_not_close_this_tab: 0fc355011de61686ff1dd5606336a33a
see_all_available_times: 4213ac0595fa798d9c140e13ec5b1133
@@ -2789,6 +2791,7 @@ checksums:
salesforce_route_to_custom_lookup_field: 672a24e4de55eb073559e94b9266da7d
salesforce_option: f09423b2785ee269db428163fc0a58d0
lookup_field_name: 8d81d557128a52661d1ed9e9aae0575c
translate_description_button: 65b94fd5bdb06c983ee3f4c1be74a1ac
rr_distribution_method: c3d4633236c01f8314a38a9e9c2361f1
rr_distribution_method_description: e9c1dda0299fbfc979a52a79b57e40c7
rr_distribution_method_availability_title: 3a7c8afaf3ddb8d262e2a03d7fc8779d
@@ -105,7 +105,7 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
// Wrapping in a try/catch to reduce chance of race conditions-
// also this improves performance for most of the happy-paths.
try {
await GoogleRepository.createSelectedCalendar({
await GoogleRepository.upsertSelectedCalendar({
credentialId: gcalCredential.id,
externalId: selectedCalendarWhereUnique.externalId,
userId: selectedCalendarWhereUnique.userId,
@@ -1,2 +1,3 @@
export { default as add } from "./add";
export { default as callback } from "./callback";
export { default as webhook } from "./webhook";
@@ -0,0 +1,41 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { getCalendar } from "../../_utils/getCalendar";
async function postHandler(req: NextApiRequest) {
if (req.headers["x-goog-channel-token"] !== process.env.GOOGLE_WEBHOOK_TOKEN) {
throw new HttpError({ statusCode: 403, message: "Invalid API key" });
}
if (typeof req.headers["x-goog-channel-id"] !== "string") {
throw new HttpError({ statusCode: 403, message: "Missing Channel ID" });
}
const selectedCalendar = await SelectedCalendarRepository.findByGoogleChannelId(
req.headers["x-goog-channel-id"]
);
if (!selectedCalendar) {
throw new HttpError({
statusCode: 200,
message: `No selected calendar found for googleChannelId: ${req.headers["x-goog-channel-id"]}`,
});
}
const { credential } = selectedCalendar;
if (!credential)
throw new HttpError({
statusCode: 200,
message: `No credential found for selected calendar for googleChannelId: ${req.headers["x-goog-channel-id"]}`,
});
const { selectedCalendars } = credential;
const calendar = await getCalendar(credential);
await calendar?.fetchAvailabilityAndSetCache?.(selectedCalendars);
return { message: "ok" };
}
export default defaultHandler({
POST: Promise.resolve({ default: defaultResponder(postHandler) }),
});
@@ -5,7 +5,11 @@ import { googleapisMock, setCredentialsMock } from "./__mocks__/googleapis";
import { expect, test, vi } from "vitest";
import "vitest-fetch-mock";
import CalendarService, { getTimeMax, getTimeMin } from "./CalendarService";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import CalendarService from "./CalendarService";
vi.stubEnv("GOOGLE_WEBHOOK_TOKEN", "test-webhook-token");
vi.mock("@calcom/features/flags/server/utils", () => ({
getFeatureFlag: vi.fn().mockReturnValue(true),
@@ -32,7 +36,7 @@ const getSampleCredential = () => {
return {
invalid: false,
key: googleTestCredentialKey,
type: "test",
type: "google_calendar",
};
};
@@ -42,21 +46,22 @@ const testSelectedCalendar = {
externalId: "example@cal.com",
};
test("Calendar Cache is being read and updated", async () => {
test("Calendar Cache is being read on cache HIT", async () => {
const credentialInDb1 = await createCredentialInDb();
const dateFrom1 = new Date().toISOString();
const dateTo1 = new Date().toISOString();
// Create cache
await prismock.calendarCache.create({
data: {
credentialId: credentialInDb1.id,
key: JSON.stringify({
timeMin: getTimeMin(dateFrom1),
timeMax: getTimeMax(dateTo1),
items: [{ id: testSelectedCalendar.externalId }],
}),
value: {
const calendarCache = await CalendarCache.init(null);
await calendarCache.upsertCachedAvailability(
credentialInDb1.id,
{
timeMin: dateFrom1,
timeMax: dateTo1,
items: [{ id: testSelectedCalendar.externalId }],
},
JSON.parse(
JSON.stringify({
calendars: [
{
busy: [
@@ -67,10 +72,9 @@ test("Calendar Cache is being read and updated", async () => {
],
},
],
},
expiresAt: String(Date.now() + 10000),
},
});
})
)
);
oAuthManagerMock.OAuthManager = defaultMockOAuthManager;
const calendarService = new CalendarService(credentialInDb1);
@@ -83,29 +87,73 @@ test("Calendar Cache is being read and updated", async () => {
end: "2023-12-01T19:00:00Z",
},
]);
});
const credentialInDb2 = await createCredentialInDb();
const dateFrom2 = new Date(Date.now()).toISOString();
test("Calendar Cache is being ignored on cache MISS", async () => {
const calendarCache = await CalendarCache.init(null);
const credentialInDb = await createCredentialInDb();
const dateFrom = new Date(Date.now()).toISOString();
// Tweak date so that it's a cache miss
const dateTo2 = new Date(Date.now() + 100000000).toISOString();
const calendarService2 = new CalendarService(credentialInDb2);
const dateTo = new Date(Date.now() + 100000000).toISOString();
const calendarService = new CalendarService(credentialInDb);
// Test Cache Miss
await calendarService2.getAvailability(dateFrom2, dateTo2, [testSelectedCalendar]);
await calendarService.getAvailability(dateFrom, dateTo, [testSelectedCalendar]);
// Expect cache to be updated in case of a MISS
const calendarCache = await prismock.calendarCache.findFirst({
// Expect cache to be ignored in case of a MISS
const cachedAvailability = await calendarCache.getCachedAvailability(credentialInDb.id, {
timeMin: dateFrom,
timeMax: dateTo,
items: [{ id: testSelectedCalendar.externalId }],
});
expect(cachedAvailability).toBeNull();
});
test("Calendar can be watched and unwatched", async () => {
const credentialInDb1 = await createCredentialInDb();
oAuthManagerMock.OAuthManager = defaultMockOAuthManager;
const calendarCache = await CalendarCache.initFromCredentialId(credentialInDb1.id);
await calendarCache.watchCalendar({ calendarId: testSelectedCalendar.externalId });
const watchedCalendar = await prismock.selectedCalendar.findFirst({
where: {
credentialId: credentialInDb2.id,
key: JSON.stringify({
timeMin: getTimeMin(dateFrom2),
timeMax: getTimeMax(dateTo2),
items: [{ id: testSelectedCalendar.externalId }],
}),
userId: credentialInDb1.userId!,
externalId: testSelectedCalendar.externalId,
integration: "google_calendar",
},
});
expect(watchedCalendar).toEqual({
userId: 1,
integration: "google_calendar",
externalId: "example@cal.com",
credentialId: 1,
googleChannelId: "mock-channel-id",
googleChannelKind: "api#channel",
googleChannelResourceId: "mock-resource-id",
googleChannelResourceUri: "mock-resource-uri",
googleChannelExpiration: "1111111111",
});
await calendarCache.unwatchCalendar({ calendarId: testSelectedCalendar.externalId });
// There's a bug in prismock where upsert creates duplicate records so we need to acces the second element
const [, unWatchedCalendar] = await prismock.selectedCalendar.findMany({
where: {
userId: credentialInDb1.userId!,
externalId: testSelectedCalendar.externalId,
integration: "google_calendar",
},
});
expect(calendarCache?.value).toEqual({ calendars: [] });
expect(unWatchedCalendar).toEqual({
userId: 1,
integration: "google_calendar",
externalId: "example@cal.com",
credentialId: 1,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
});
});
test("`updateTokenObject` should update credential in DB as well as myGoogleAuth", async () => {
@@ -3,10 +3,12 @@ import type { Prisma } from "@prisma/client";
import type { calendar_v3 } from "googleapis";
import { google } from "googleapis";
import { RRule } from "rrule";
import { v4 as uuid } from "uuid";
import { MeetLocationType } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import { getFeatureFlag } from "@calcom/features/flags/server/utils";
import { CalendarCache } from "@calcom/features/calendar-cache/calendar-cache";
import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cache.repository.interface";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import type CalendarService from "@calcom/lib/CalendarService";
import {
@@ -19,6 +21,7 @@ import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import { getAllCalendars } from "@calcom/lib/google";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { GoogleRepository } from "@calcom/lib/server/repository/google";
import prisma from "@calcom/prisma";
import type {
Calendar,
@@ -39,39 +42,16 @@ import { metadata } from "../_metadata";
import { getGoogleAppKeys } from "./getGoogleAppKeys";
const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] });
interface GoogleCalError extends Error {
code?: number;
}
const ONE_MINUTE_MS = 60 * 1000;
const CACHING_TIME = ONE_MINUTE_MS;
/** Expand the start date to the start of the month */
export function getTimeMin(timeMin: string) {
const dateMin = new Date(timeMin);
return new Date(dateMin.getFullYear(), dateMin.getMonth(), 1, 0, 0, 0, 0).toISOString();
}
/** Expand the end date to the end of the month */
export function getTimeMax(timeMax: string) {
const dateMax = new Date(timeMax);
return new Date(dateMax.getFullYear(), dateMax.getMonth() + 1, 0, 0, 0, 0, 0).toISOString();
}
/**
* Enable or disable the expanded cache
* TODO: Make this configurable
* */
const ENABLE_EXPANDED_CACHE = true;
/**
* By expanding the cache to whole months, we can save round trips to the third party APIs.
* In this case we already have the data in the database, so we can just return it.
*/
function handleMinMax(min: string, max: string) {
if (!ENABLE_EXPANDED_CACHE) return { timeMin: min, timeMax: max };
return { timeMin: getTimeMin(min), timeMax: getTimeMax(max) };
}
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const ONE_MONTH_IN_MS = 30 * MS_PER_DAY;
// eslint-disable-next-line turbo/no-undeclared-env-vars -- GOOGLE_WEBHOOK_URL only for local testing
const GOOGLE_WEBHOOK_URL_BASE = process.env.GOOGLE_WEBHOOK_URL || process.env.NEXT_PUBLIC_WEBAPP_URL;
const GOOGLE_WEBHOOK_URL = `${GOOGLE_WEBHOOK_URL_BASE}/api/integrations/googlecalendar/webhook`;
export default class GoogleCalendarService implements Calendar {
private integrationName = "";
@@ -515,70 +495,23 @@ export default class GoogleCalendarService implements Calendar {
}
}
async getCacheOrFetchAvailability(args: {
timeMin: string;
timeMax: string;
items: { id: string }[];
}): Promise<EventBusyDate[] | null> {
async fetchAvailability(requestBody: FreeBusyArgs): Promise<calendar_v3.Schema$FreeBusyResponse> {
const calendar = await this.authedCalendar();
const calendarCacheEnabled = await getFeatureFlag(prisma, "calendar-cache");
const apiResponse = await this.oAuthManagerInstance.request(
async () => new AxiosLikeResponseToFetchResponse(await calendar.freebusy.query({ requestBody }))
);
return apiResponse.json;
}
async getCacheOrFetchAvailability(args: FreeBusyArgs): Promise<EventBusyDate[] | null> {
const { timeMin, timeMax, items } = args;
let freeBusyResult: calendar_v3.Schema$FreeBusyResponse = {};
if (!calendarCacheEnabled) {
const { timeMin, timeMax, items } = args;
({ json: freeBusyResult } = await this.oAuthManagerInstance.request(
async () =>
new AxiosLikeResponseToFetchResponse(
await calendar.freebusy.query({
requestBody: { timeMin, timeMax, items },
})
)
));
const calendarCache = await CalendarCache.init(null);
const cached = await calendarCache.getCachedAvailability(this.credential.id, args);
if (cached) {
freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
} else {
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
const key = JSON.stringify({ timeMin, timeMax, items });
const cached = await prisma.calendarCache.findUnique({
where: {
credentialId_key: {
credentialId: this.credential.id,
key,
},
expiresAt: { gte: new Date(Date.now()) },
},
});
if (cached) {
freeBusyResult = cached.value as unknown as calendar_v3.Schema$FreeBusyResponse;
} else {
({ json: freeBusyResult } = await this.oAuthManagerInstance.request(
async () =>
new AxiosLikeResponseToFetchResponse(
await calendar.freebusy.query({
requestBody: { timeMin, timeMax, items },
})
)
));
// Skipping await to respond faster
await prisma.calendarCache.upsert({
where: {
credentialId_key: {
credentialId: this.credential.id,
key,
},
},
update: {
value: JSON.parse(JSON.stringify(freeBusyResult)),
expiresAt: new Date(Date.now() + CACHING_TIME),
},
create: {
value: JSON.parse(JSON.stringify(freeBusyResult)),
credentialId: this.credential.id,
key,
expiresAt: new Date(Date.now() + CACHING_TIME),
},
});
}
freeBusyResult = await this.fetchAvailability({ timeMin, timeMax, items });
}
if (!freeBusyResult.calendars) return null;
@@ -696,6 +629,92 @@ export default class GoogleCalendarService implements Calendar {
throw error;
}
}
async watchCalendar({ calendarId }: { calendarId: string }) {
if (!process.env.GOOGLE_WEBHOOK_TOKEN) {
log.warn("GOOGLE_WEBHOOK_TOKEN is not set, skipping watching calendar");
return;
}
const calendar = await this.authedCalendar();
const res = await calendar.events.watch({
// Calendar identifier. To retrieve calendar IDs call the calendarList.list method. If you want to access the primary calendar of the currently logged in user, use the "primary" keyword.
calendarId,
requestBody: {
// A UUID or similar unique string that identifies this channel.
id: uuid(),
type: "web_hook",
address: GOOGLE_WEBHOOK_URL,
token: process.env.GOOGLE_WEBHOOK_TOKEN,
params: {
// The time-to-live in seconds for the notification channel. Default is 604800 seconds.
ttl: `${Math.round(ONE_MONTH_IN_MS / 1000)}`,
},
},
});
const response = res.data;
await GoogleRepository.upsertSelectedCalendar({
userId: this.credential.userId!,
externalId: calendarId,
credentialId: this.credential.id,
googleChannelId: response?.id,
googleChannelKind: response?.kind,
googleChannelResourceId: response?.resourceId,
googleChannelResourceUri: response?.resourceUri,
googleChannelExpiration: response?.expiration,
});
return res.data;
}
async unwatchCalendar({ calendarId }: { calendarId: string }) {
const credentialId = this.credential.id;
const sc = await prisma.selectedCalendar.findFirst({
where: {
credentialId,
externalId: calendarId,
},
});
// Delete the calendar cache to force a fresh cache
await prisma.calendarCache.deleteMany({ where: { credentialId } });
const calendar = await this.authedCalendar();
await calendar.channels
.stop({
requestBody: {
resourceId: sc?.googleChannelResourceId,
id: sc?.googleChannelId,
},
})
.catch((err) => {
console.warn(JSON.stringify(err));
});
await GoogleRepository.upsertSelectedCalendar({
userId: this.credential.userId!,
externalId: calendarId,
credentialId: this.credential.id,
googleChannelId: null,
googleChannelKind: null,
googleChannelResourceId: null,
googleChannelResourceUri: null,
googleChannelExpiration: null,
});
}
async setAvailabilityInCache(args: FreeBusyArgs, data: calendar_v3.Schema$FreeBusyResponse): Promise<void> {
const calendarCache = await CalendarCache.init(null);
await calendarCache.upsertCachedAvailability(this.credential.id, args, JSON.parse(JSON.stringify(data)));
}
async fetchAvailabilityAndSetCache(selectedCalendars: IntegrationCalendar[]) {
const date = new Date();
const parsedArgs = {
/** Expand the start date to the start of the month */
timeMin: new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0, 0).toISOString(),
/** Expand the end date to the end of the month */
timeMax: new Date(date.getFullYear(), date.getMonth() + 1, 0, 0, 0, 0, 0).toISOString(),
items: selectedCalendars.map((sc) => ({ id: sc.externalId })),
};
const data = await this.fetchAvailability(parsedArgs);
await this.setAvailabilityInCache(parsedArgs, data);
}
}
class MyGoogleAuth extends google.auth.OAuth2 {
@@ -19,7 +19,22 @@ const setCredentialsMock = vi.fn();
googleapisMock.google = {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
calendar: vi.fn().mockReturnValue({}),
calendar: vi.fn().mockReturnValue({
channels: {
stop: vi.fn().mockResolvedValue(undefined),
},
events: {
watch: vi.fn().mockResolvedValue({
data: {
kind: "api#channel",
id: "mock-channel-id",
resourceId: "mock-resource-id",
resourceUri: "mock-resource-uri",
expiration: "1111111111",
},
}),
},
}),
auth: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
@@ -1,12 +1,12 @@
import { beforeEach, vi } from "vitest";
import { mockReset, mockDeep } from "vitest-mock-extended";
import { mockClear, mockDeep } from "vitest-mock-extended";
import type * as OAuthManager from "../../_utils/oauth/OAuthManager";
vi.mock("../../_utils/oauth/OAuthManager", () => oAuthManagerMock);
beforeEach(() => {
mockReset(oAuthManagerMock);
mockClear(oAuthManagerMock);
});
const oAuthManagerMock = mockDeep<typeof OAuthManager>({
+2
View File
@@ -6,6 +6,8 @@ const config = {
defaultLocale: "en",
locales: [
"ar",
"az",
"ca",
"cs",
"da",
"de",
+1 -1
View File
@@ -1,4 +1,3 @@
import type { SelectedCalendar } from "@prisma/client";
// eslint-disable-next-line no-restricted-imports
import { sortBy } from "lodash";
@@ -15,6 +14,7 @@ import type {
EventBusyDate,
IntegrationCalendar,
NewCalendarEventType,
SelectedCalendar,
} from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
import type { EventResult } from "@calcom/types/EventManager";
+5 -4
View File
@@ -1,11 +1,9 @@
import type { SelectedCalendar } from "@prisma/client";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import logger from "@calcom/lib/logger";
import { getPiiFreeCredential, getPiiFreeSelectedCalendar } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import { performance } from "@calcom/lib/server/perfObserver";
import type { EventBusyDate } from "@calcom/types/Calendar";
import type { EventBusyDate, SelectedCalendar } from "@calcom/types/Calendar";
import type { CredentialPayload } from "@calcom/types/Credential";
const log = logger.getSubLogger({ prefix: ["getCalendarsEvents"] });
@@ -30,7 +28,10 @@ const getCalendarsEvents = async (
/** We just pass the calendars that matched the credential type,
* TODO: Migrate credential type or appId
*/
const passedSelectedCalendars = selectedCalendars.filter((sc) => sc.integration === type);
const passedSelectedCalendars = selectedCalendars
.filter((sc) => sc.integration === type)
// Needed to ensure cache keys are consistent
.sort((a, b) => (a.externalId < b.externalId ? -1 : a.externalId > b.externalId ? 1 : 0));
if (!passedSelectedCalendars.length) return [];
/** We extract external Ids so we don't cache too much */
@@ -1,3 +1,5 @@
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
import AttendeeScheduledEmail from "./attendee-scheduled-email";
@@ -17,11 +19,15 @@ export default class AttendeeCancelledEmail extends AttendeeScheduledEmail {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("AttendeeCancelledEmail", {
calEvent: this.calEvent,
attendee: this.attendee,
}),
html: await this.getHtml(this.calEvent, this.attendee),
text: this.getTextBody("event_request_cancelled", "emailed_you_and_any_other_attendees"),
};
}
protected async getHtml(calEvent: CalendarEvent, attendee: Person) {
return await renderEmail("AttendeeCancelledEmail", {
calEvent,
attendee,
});
}
}
@@ -1,4 +1,5 @@
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
import { renderEmail } from "../";
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
@@ -20,11 +21,15 @@ export default class OrganizerCancelledEmail extends OrganizerScheduledEmail {
title: this.calEvent.title,
date: this.getFormattedDate(),
})}`,
html: await renderEmail("OrganizerCancelledEmail", {
attendee: this.calEvent.organizer,
calEvent: this.calEvent,
}),
html: await this.getHtml(this.calEvent, this.calEvent.organizer),
text: this.getTextBody("event_request_cancelled"),
};
}
protected async getHtml(calEvent: CalendarEvent, organizer: Person) {
return await renderEmail("OrganizerCancelledEmail", {
calEvent,
attendee: organizer,
});
}
}
+25 -15
View File
@@ -1,5 +1,6 @@
import { AnimatePresence, LazyMotion, m } from "framer-motion";
import dynamic from "next/dynamic";
import { useSearchParams } from "next/navigation";
import { useEffect, useMemo, useRef } from "react";
import { Toaster } from "react-hot-toast";
import StickyBox from "react-sticky-box";
@@ -68,6 +69,13 @@ const BookerComponent = ({
customClassNames,
}: BookerProps & WrappedBookerProps) => {
const { t } = useLocale();
const searchParams = useSearchParams();
const areInstantMeetingParametersSet = !!event.data?.instantMeetingParameters
? searchParams &&
event.data.instantMeetingParameters.every((param) => Array.from(searchParams.values()).includes(param))
: true;
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
const selectedDate = useBookerStore((state) => state.selectedDate);
const {
@@ -441,21 +449,23 @@ const BookerComponent = ({
}}
/>
{bookerState !== "booking" && event.data?.showInstantEventConnectNowModal && (
<div
className={classNames(
"animate-fade-in-up z-40 my-2 opacity-0",
layout === BookerLayouts.MONTH_VIEW && isEmbed ? "" : "fixed bottom-2"
)}
style={{ animationDelay: "1s" }}>
<InstantBooking
event={event.data}
onConnectNow={() => {
onConnectNowInstantMeeting();
}}
/>
</div>
)}
{bookerState !== "booking" &&
event.data?.showInstantEventConnectNowModal &&
areInstantMeetingParametersSet && (
<div
className={classNames(
"animate-fade-in-up z-40 my-2 opacity-0",
layout === BookerLayouts.MONTH_VIEW && isEmbed ? "" : "fixed bottom-2"
)}
style={{ animationDelay: "1s" }}>
<InstantBooking
event={event.data}
onConnectNow={() => {
onConnectNowInstantMeeting();
}}
/>
</div>
)}
{!hideBranding && !isPlatform && (
<m.span
key="logo"
@@ -48,6 +48,8 @@ export const EventMeta = ({
| "recurringEvent"
| "price"
| "isDynamic"
| "fieldTranslations"
| "autoTranslateDescriptionEnabled"
> | null;
isPending: boolean;
isPlatform?: boolean;
@@ -101,6 +103,10 @@ export const EventMeta = ({
: isHalfFull
? "text-yellow-500"
: "text-bookinghighlight";
const browserLocale = navigator.language; // e.g. "en-US", "es-ES", "fr-FR"
const translatedDescription = (event?.fieldTranslations ?? []).find((translation) =>
browserLocale.startsWith(translation.targetLang)
)?.translatedText;
return (
<div className={`${classNames?.eventMetaContainer || ""} relative z-10 p-6`} data-testid="event-meta">
@@ -120,9 +126,9 @@ export const EventMeta = ({
/>
)}
<EventTitle className={`${classNames?.eventMetaTitle} my-2`}>{event?.title}</EventTitle>
{event.description && (
{(event.description || translatedDescription) && (
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
<div dangerouslySetInnerHTML={{ __html: event.description }} />
<div dangerouslySetInnerHTML={{ __html: translatedDescription ?? event.description }} />
</EventMetaBlock>
)}
<div className="space-y-4 font-medium rtl:-mr-2">
@@ -22,6 +22,7 @@ export interface IUseBookingForm {
guests: string[];
name: string | null;
};
lastBookingResponse?: Record<string, string>;
}
export type UseBookingFormReturnType = ReturnType<typeof useBookingForm>;
@@ -34,6 +35,7 @@ export const useBookingForm = ({
hasSession,
extraOptions,
prefillFormParams,
lastBookingResponse,
}: IUseBookingForm) => {
const rescheduleUid = useBookerStore((state) => state.rescheduleUid);
const bookingData = useBookerStore((state) => state.bookingData);
@@ -71,6 +73,7 @@ export const useBookingForm = ({
hasSession,
extraOptions,
prefillFormParams,
lastBookingResponse,
});
const bookingForm = useForm<BookingFormValues>({
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import type { z } from "zod";
import { z } from "zod";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import type getBookingResponsesSchema from "@calcom/features/bookings/lib/getBookingResponsesSchema";
@@ -21,6 +21,7 @@ type UseInitialFormValuesProps = {
guests: string[];
name: string | null;
};
lastBookingResponse?: Record<string, string>;
};
export function useInitialFormValues({
@@ -33,6 +34,7 @@ export function useInitialFormValues({
hasSession,
extraOptions,
prefillFormParams,
lastBookingResponse,
}: UseInitialFormValuesProps) {
const [initialValues, setDefaultValues] = useState<{
responses?: Partial<z.infer<ReturnType<typeof getBookingResponsesSchema>>>;
@@ -62,6 +64,7 @@ export function useInitialFormValues({
// `guests` because the `name` of the corresponding bookingField is `guests`
guests: prefillFormParams.guests,
});
const parsedLastBookingResponse = z.record(z.any()).nullish().parse(lastBookingResponse);
const defaultUserValues = {
email:
@@ -69,13 +72,13 @@ export function useInitialFormValues({
? bookingData?.attendees[0].email
: !!parsedQuery["email"]
? parsedQuery["email"]
: email ?? "",
: email ?? parsedLastBookingResponse?.email ?? "",
name:
rescheduleUid && bookingData && bookingData.attendees.length > 0
? bookingData?.attendees[0].name
: !!parsedQuery["name"]
? parsedQuery["name"]
: name ?? username ?? "",
: name ?? username ?? parsedLastBookingResponse?.name ?? "",
};
if (!isRescheduling) {
@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { localStorage } from "@calcom/lib/webstorage";
import { setLastBookingResponse, getLastBookingResponse } from "./lastBookingResponse";
vi.mock("@calcom/lib/webstorage", () => ({
localStorage: {
getItem: vi.fn(),
setItem: vi.fn(),
},
}));
describe("Last Booking Response", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("setLastBookingResponse", () => {
it("should not store anything if responses is null", () => {
setLastBookingResponse(null);
expect(localStorage.setItem).not.toHaveBeenCalled();
});
it("should store only allowed responses in localStorage", () => {
const responses = {
email: "test@example.com",
name: "John Doe",
age: 30,
};
setLastBookingResponse(responses);
expect(localStorage.setItem).toHaveBeenCalledWith(
"lastBookingResponse",
JSON.stringify({ email: "test@example.com", name: "John Doe" })
);
});
});
describe("getLastBookingResponse", () => {
it("should return an empty object if there is no last booking response", () => {
(localStorage.getItem as jest.Mock).mockReturnValue(null);
const result = getLastBookingResponse();
expect(result).toEqual({});
});
it("should return parsed response if valid JSON is present", () => {
const mockResponse = JSON.stringify({ email: "test@example.com", name: "John Doe" });
(localStorage.getItem as jest.Mock).mockReturnValue(mockResponse);
const result = getLastBookingResponse();
expect(result).toEqual({ email: "test@example.com", name: "John Doe" });
});
it("should return an empty object if JSON is invalid", () => {
const invalidJson = "{ email: 'test@example.com' ";
(localStorage.getItem as jest.Mock).mockReturnValue(invalidJson);
const result = getLastBookingResponse();
expect(result).toEqual({});
});
});
});
@@ -0,0 +1,22 @@
import logger from "@calcom/lib/logger";
import { localStorage } from "@calcom/lib/webstorage";
const responsesToStore = ["email", "name"];
export const setLastBookingResponse = (responses: Record<string, unknown> | null) => {
if (!responses) return;
const prevResponse = Object.fromEntries(
Object.entries(responses).filter(([key]) => responsesToStore.includes(key))
);
localStorage.setItem("lastBookingResponse", JSON.stringify(prevResponse));
};
export const getLastBookingResponse = () => {
const lastBookingResponse = localStorage.getItem("lastBookingResponse");
try {
return JSON.parse(lastBookingResponse ?? "{}");
} catch (err) {
logger.error("Error parsing lastBookingResponse: ", err);
return {};
}
};
@@ -155,8 +155,15 @@ export type HandleCancelBookingResponse = {
};
async function handler(req: CustomRequest) {
const { id, uid, allRemainingBookings, cancellationReason, seatReferenceUid, cancelledBy } =
bookingCancelInput.parse(req.body);
const {
id,
uid,
allRemainingBookings,
cancellationReason,
seatReferenceUid,
cancelledBy,
cancelSubsequentBookings,
} = bookingCancelInput.parse(req.body);
req.bookingToDelete = await getBookingToDelete(id, uid);
const {
bookingToDelete,
@@ -406,14 +413,19 @@ async function handler(req: CustomRequest) {
// by cancelling first, and blocking whilst doing so; we can ensure a cancel
// action always succeeds even if subsequent integrations fail cancellation.
if (bookingToDelete.eventType?.recurringEvent && bookingToDelete.recurringEventId && allRemainingBookings) {
if (
bookingToDelete.eventType?.recurringEvent &&
bookingToDelete.recurringEventId &&
(allRemainingBookings || cancelSubsequentBookings)
) {
const recurringEventId = bookingToDelete.recurringEventId;
const gte = cancelSubsequentBookings ? bookingToDelete.startTime : new Date();
// Proceed to mark as cancelled all remaining recurring events instances (greater than or equal to right now)
await prisma.booking.updateMany({
where: {
recurringEventId,
startTime: {
gte: new Date(),
gte,
},
},
data: {
+3
View File
@@ -51,6 +51,9 @@ export type BookerEvent = Pick<
| "bookingFields"
| "seatsShowAvailabilityCount"
| "isInstantEvent"
| "instantMeetingParameters"
| "fieldTranslations"
| "autoTranslateDescriptionEnabled"
> & { users: BookerEventUser[]; showInstantEventConnectNowModal: boolean } & { profile: BookerEventProfile };
export type ValidationErrors<T extends object> = { key: FieldPath<T>; error: ErrorOption }[];
@@ -0,0 +1,65 @@
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
import { CalendarCache } from "../calendar-cache";
const validateRequest = (req: NextApiRequest) => {
const apiKey = String(req.query.apiKey) || req.headers.authorization;
if (!apiKey || ![process.env.CRON_API_KEY, `Bearer ${process.env.CRON_SECRET}`].includes(apiKey)) {
throw new HttpError({ statusCode: 401, message: "Unauthorized" });
}
};
const handleCalendarsToUnwatch = async () => {
const calendarsToUnwatch = await SelectedCalendarRepository.getNextBatchToUnwatch();
const result = await Promise.allSettled(
calendarsToUnwatch.map(async (sc) => {
if (!sc.credentialId) return;
const cc = await CalendarCache.initFromCredentialId(sc.credentialId);
await cc.unwatchCalendar({ calendarId: sc.externalId });
})
);
return result;
};
const handleCalendarsToWatch = async () => {
const calendarsToWatch = await SelectedCalendarRepository.getNextBatchToWatch();
const result = await Promise.allSettled(
calendarsToWatch.map(async (sc) => {
if (!sc.credentialId) return;
const cc = await CalendarCache.initFromCredentialId(sc.credentialId);
await cc.watchCalendar({ calendarId: sc.externalId });
})
);
return result;
};
// This cron is used to activate and renew calendar subcriptions
const handler = defaultResponder(async (request: NextApiRequest) => {
validateRequest(request);
const [watchedResult, unwatchedResult] = await Promise.all([
handleCalendarsToWatch(),
handleCalendarsToUnwatch(),
]);
// TODO: Credentials can be installed on a whole team, check for selected calendars on the team
return {
succeededAt: new Date().toISOString(),
watched: {
successful: watchedResult.filter((x) => x.status === "fulfilled").length,
failed: watchedResult.filter((x) => x.status === "rejected").length,
},
unwatched: {
successful: unwatchedResult.filter((x) => x.status === "fulfilled").length,
failed: unwatchedResult.filter((x) => x.status === "rejected").length,
},
};
});
export default defaultHandler({
GET: Promise.resolve({ default: defaultResponder(handler) }),
});
@@ -0,0 +1,14 @@
import type { CalendarCache, Prisma } from "@prisma/client";
export type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] };
export interface ICalendarCacheRepository {
watchCalendar(args: { calendarId: string }): Promise<any>;
unwatchCalendar(args: { calendarId: string }): Promise<any>;
upsertCachedAvailability(
credentialId: number,
args: FreeBusyArgs,
value: Prisma.JsonNullValueInput | Prisma.InputJsonValue
): Promise<void>;
getCachedAvailability(credentialId: number, args: FreeBusyArgs): Promise<CalendarCache | null>;
}
@@ -0,0 +1,22 @@
import logger from "@calcom/lib/logger";
import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface";
const log = logger.getSubLogger({ prefix: ["CalendarCacheRepositoryMock"] });
export class CalendarCacheRepositoryMock implements ICalendarCacheRepository {
async watchCalendar() {
log.info(`Skipping watchCalendar due to calendar-cache being disabled`);
}
async upsertCachedAvailability() {
log.info(`Skipping upsertCachedAvailability due to calendar-cache being disabled`);
}
async getCachedAvailability() {
log.info(`Skipping getCachedAvailability due to calendar-cache being disabled`);
return null;
}
async unwatchCalendar() {
log.info(`Skipping unwatchCalendar due to calendar-cache being disabled`);
}
}
@@ -0,0 +1,9 @@
import { z } from "zod";
export const watchCalendarSchema = z.object({
kind: z.literal("api#channel"),
id: z.string(),
resourceId: z.string(),
resourceUri: z.string(),
expiration: z.string(),
});
@@ -0,0 +1,124 @@
import type { Prisma } from "@prisma/client";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import type { Calendar } from "@calcom/types/Calendar";
import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface";
import { watchCalendarSchema } from "./calendar-cache.repository.schema";
const log = logger.getSubLogger({ prefix: ["CalendarCacheRepository"] });
/** Enable or disable the expanded cache. Enabled by default. */
const ENABLE_EXPANDED_CACHE = process.env.ENABLE_EXPANDED_CACHE !== "0";
const MS_PER_DAY = 24 * 60 * 60 * 1000;
const ONE_MONTH_IN_MS = 30 * MS_PER_DAY;
const CACHING_TIME = ONE_MONTH_IN_MS;
/** Expand the start date to the start of the month */
function getTimeMin(timeMin: string) {
const dateMin = new Date(timeMin);
return new Date(dateMin.getFullYear(), dateMin.getMonth(), 1, 0, 0, 0, 0).toISOString();
}
/** Expand the end date to the end of the month */
function getTimeMax(timeMax: string) {
const dateMax = new Date(timeMax);
return new Date(dateMax.getFullYear(), dateMax.getMonth() + 1, 0, 0, 0, 0, 0).toISOString();
}
export function parseKeyForCache(args: FreeBusyArgs): string {
const { timeMin: _timeMin, timeMax: _timeMax, items } = args;
const { timeMin, timeMax } = handleMinMax(_timeMin, _timeMax);
const key = JSON.stringify({ timeMin, timeMax, items });
return key;
}
/**
* By expanding the cache to whole months, we can save round trips to the third party APIs.
* In this case we already have the data in the database, so we can just return it.
*/
function handleMinMax(min: string, max: string) {
if (!ENABLE_EXPANDED_CACHE) return { timeMin: min, timeMax: max };
return { timeMin: getTimeMin(min), timeMax: getTimeMax(max) };
}
type FreeBusyArgs = { timeMin: string; timeMax: string; items: { id: string }[] };
export class CalendarCacheRepository implements ICalendarCacheRepository {
calendar: Calendar | null;
constructor(calendar: Calendar | null = null) {
this.calendar = calendar;
}
async watchCalendar(args: { calendarId: string }) {
const { calendarId } = args;
if (typeof this.calendar?.watchCalendar !== "function") {
log.info(
'[handleWatchCalendar] Skipping watching calendar due to calendar not having "watchCalendar" method'
);
return;
}
const response = await this.calendar?.watchCalendar({ calendarId });
const parsedResponse = watchCalendarSchema.safeParse(response);
if (!parsedResponse.success) {
log.info(
"[handleWatchCalendar] Received invalid response from calendar.watchCalendar, skipping watching calendar"
);
return;
}
return parsedResponse.data;
}
async unwatchCalendar(args: { calendarId: string }) {
const { calendarId } = args;
if (typeof this.calendar?.unwatchCalendar !== "function") {
log.info(
'[unwatchCalendar] Skipping watching calendar due to calendar not having "watchCalendar" method'
);
return;
}
const response = await this.calendar?.unwatchCalendar({ calendarId });
return response;
}
async getCachedAvailability(credentialId: number, args: FreeBusyArgs) {
const key = parseKeyForCache(args);
log.info("Getting cached availability", key);
const cached = await prisma.calendarCache.findUnique({
where: {
credentialId_key: {
credentialId,
key,
},
expiresAt: { gte: new Date(Date.now()) },
},
});
return cached;
}
async upsertCachedAvailability(
credentialId: number,
args: FreeBusyArgs,
value: Prisma.JsonNullValueInput | Prisma.InputJsonValue
) {
const key = parseKeyForCache(args);
await prisma.calendarCache.upsert({
where: {
credentialId_key: {
credentialId,
key,
},
},
update: {
value,
expiresAt: new Date(Date.now() + CACHING_TIME),
},
create: {
value,
credentialId,
key,
expiresAt: new Date(Date.now() + CACHING_TIME),
},
});
}
}
@@ -0,0 +1,28 @@
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import prisma from "@calcom/prisma";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import type { Calendar } from "@calcom/types/Calendar";
import { CalendarCacheRepository } from "./calendar-cache.repository";
import type { ICalendarCacheRepository } from "./calendar-cache.repository.interface";
import { CalendarCacheRepositoryMock } from "./calendar-cache.repository.mock";
export class CalendarCache {
static async initFromCredentialId(credentialId: number): Promise<ICalendarCacheRepository> {
const credential = await prisma.credential.findUnique({
where: { id: credentialId },
select: credentialForCalendarServiceSelect,
});
const calendar = await getCalendar(credential);
return await CalendarCache.init(calendar);
}
static async init(calendar: Calendar | null): Promise<ICalendarCacheRepository> {
const featureRepo = new FeaturesRepository();
const isCalendarCacheEnabledGlobally = await featureRepo.checkIfFeatureIsEnabledGlobally(
"calendar-cache"
);
if (isCalendarCacheEnabledGlobally) return new CalendarCacheRepository(calendar);
return new CalendarCacheRepositoryMock();
}
}
@@ -28,6 +28,8 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
const body = {
integration: type,
externalId: externalId,
// new URLSearchParams does not accept numbers
credentialId: String(credentialId),
};
if (isOn) {
@@ -36,7 +38,7 @@ const CalendarSwitch = (props: ICalendarSwitchProps) => {
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...body, credentialId }),
body: JSON.stringify(body),
});
if (!res.ok) {
@@ -5,6 +5,7 @@ import { useSession, signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { uuid } from "short-uuid";
import { deriveOrgNameFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
import { deriveSlugFromEmail } from "@calcom/ee/organizations/components/CreateANewOrganizationForm";
@@ -89,7 +90,7 @@ const CreateANewPlatformFormChild = ({ session }: { session: Ensure<SessionConte
setServerErrorMessage(null);
createOrganizationMutation.mutate({
...v,
slug: `${v.name.toLocaleLowerCase()}_platform`,
slug: `${v.name.toLocaleLowerCase()}-platform-${uuid().substring(0, 20)}`,
});
}
}}>
@@ -177,6 +177,7 @@ export default function MemberList(props: Props) {
// TODO (SEAN): Make Column filters a trpc query param so we can fetch serverside even if the data is not loaded
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
const removeMemberFromCache = ({
utils,
@@ -602,12 +603,15 @@ export default function MemberList(props: Props) {
},
state: {
columnFilters,
rowSelection,
},
onColumnFiltersChange: setColumnFilters,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
getRowId: (row) => `${row.id}`,
});
const fetchMoreOnBottomReached = useFetchMoreOnBottomReached(
@@ -111,6 +111,7 @@ const AddMembersWithSwitch = ({
placeholder = "",
containerClassName = "",
isRRWeightsEnabled,
...rest
}: {
value: Host[];
onChange: (hosts: Host[]) => void;
@@ -123,6 +124,7 @@ const AddMembersWithSwitch = ({
placeholder?: string;
containerClassName?: string;
isRRWeightsEnabled?: boolean;
"data-testid"?: string;
}) => {
const { t } = useLocale();
const { setValue } = useFormContext<FormValues>();
@@ -144,6 +146,7 @@ const AddMembersWithSwitch = ({
)}
{!assignAllTeamMembers || !automaticAddAllEnabled ? (
<CheckedHostField
data-testid={rest["data-testid"]}
value={value}
onChange={onChange}
isFixed={isFixed}
@@ -157,6 +157,7 @@ const FixedHosts = ({
</>
) : (
<SettingsToggle
data-testid="fixed-hosts-switch"
toggleSwitchAtTheEnd={true}
title={t("fixed_hosts")}
description={FixedHostHelper}
@@ -175,6 +176,7 @@ const FixedHosts = ({
childrenClassName="lg:ml-0">
<div className="border-subtle flex flex-col gap-6 rounded-bl-md rounded-br-md border border-t-0 px-6">
<AddMembersWithSwitch
data-testid="fixed-hosts-select"
teamMembers={teamMembers}
value={value}
onChange={onChange}
@@ -76,6 +76,10 @@ export default function InstantEventController({
const [instantEventState, setInstantEventState] = useState<boolean>(eventType?.isInstantEvent ?? false);
const formMethods = useFormContext<FormValues>();
const [parameters, setParameters] = useState<string[]>(
formMethods.getValues("instantMeetingParameters") || []
);
const { shouldLockDisableProps } = useLockedFieldsManager({
eventType,
translate: t,
@@ -171,6 +175,56 @@ export default function InstantEventController({
);
}}
/>
<div>
<Label>{t("only_show_if_parameter_set")}</Label>
<div className="space-y-2">
{parameters.map((parameter, index) => (
<div key={index} className="flex gap-2">
<TextField
required
name={`parameter-${index}`}
labelSrOnly
type="text"
value={parameter}
containerClassName="flex-1 max-w-80"
onChange={(e) => {
const newParameters = [...parameters];
newParameters[index] = e.target.value;
setParameters(newParameters);
formMethods.setValue("instantMeetingParameters", newParameters, {
shouldDirty: true,
});
}}
/>
<Button
type="button"
color="destructive"
variant="icon"
StartIcon="trash"
onClick={() => {
const newParameters = parameters.filter((_, i) => i !== index);
setParameters(newParameters);
formMethods.setValue("instantMeetingParameters", newParameters, {
shouldDirty: true,
});
}}
/>
</div>
))}
</div>
<Button
color="minimal"
StartIcon="plus"
onClick={() => {
const newParameters = [...parameters, ""];
setParameters(newParameters);
formMethods.setValue("instantMeetingParameters", newParameters, {
shouldDirty: true,
});
}}>
{t("add_parameter")}
</Button>
</div>
<Controller
name="instantMeetingExpiryTimeOffsetInSeconds"
render={({ field: { value, onChange } }) => (
@@ -1,3 +1,4 @@
import { useSession } from "next-auth/react";
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
@@ -20,6 +21,7 @@ export type EventSetupTabProps = Pick<
>;
export const EventSetupTab = (props: EventSetupTabProps & { urlPrefix: string; hasOrgBranding: boolean }) => {
const { t } = useLocale();
const session = useSession();
const isPlatform = useIsPlatform();
const formMethods = useFormContext<FormValues>();
const { eventType, team, urlPrefix, hasOrgBranding } = props;
@@ -29,6 +31,7 @@ export const EventSetupTab = (props: EventSetupTabProps & { urlPrefix: string; h
const [firstRender, setFirstRender] = useState(true);
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const autoTranslateDescriptionEnabled = formMethods.watch("autoTranslateDescriptionEnabled");
const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
@@ -95,6 +98,17 @@ export const EventSetupTab = (props: EventSetupTabProps & { urlPrefix: string; h
</>
)}
</div>
<div className="[&_label]:my-1 [&_label]:font-normal">
<SettingsToggle
title={t("translate_description_button")}
checked={!!autoTranslateDescriptionEnabled}
onCheckedChange={(value) => {
formMethods.setValue("autoTranslateDescriptionEnabled", value, { shouldDirty: true });
}}
disabled={!session.data?.user.org?.id}
tooltip={!session.data?.user.org?.id ? t("orgs_upgrade_to_enable_feature") : undefined}
/>
</div>
<TextField
required
label={isPlatform ? "Slug" : t("URL")}
@@ -57,6 +57,7 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
eventName: true,
slug: true,
isInstantEvent: true,
instantMeetingParameters: true,
aiPhoneCallConfig: true,
schedulingType: true,
length: true,
@@ -66,6 +67,13 @@ const publicEventSelect = Prisma.validator<Prisma.EventTypeSelect>()({
metadata: true,
lockTimeZoneToggleOnBookingPage: true,
requiresConfirmation: true,
autoTranslateDescriptionEnabled: true,
fieldTranslations: {
select: {
translatedText: true,
targetLang: true,
},
},
requiresBookerEmailVerification: true,
recurringEvent: true,
price: true,
@@ -292,7 +300,10 @@ export const getPublicEvent = async (
logoUrl: null,
},
isInstantEvent: false,
instantMeetingParameters: [],
showInstantEventConnectNowModal: false,
autoTranslateDescriptionEnabled: false,
fieldTranslations: [],
};
}
@@ -446,7 +457,6 @@ export const getPublicEvent = async (
if (event.team?.isPrivate && !isTeamAdminOrOwner && !isOrgAdminOrOwner) {
users = [];
}
return {
...eventWithUserProfiles,
bookerLayouts: bookerLayoutsSchema.parse(eventMetaData?.bookerLayouts || null),
@@ -486,6 +496,7 @@ export const getPublicEvent = async (
isDynamic: false,
isInstantEvent: eventWithUserProfiles.isInstantEvent,
showInstantEventConnectNowModal,
instantMeetingParameters: eventWithUserProfiles.instantMeetingParameters,
aiPhoneCallConfig: eventWithUserProfiles.aiPhoneCallConfig,
assignAllTeamMembers: event.assignAllTeamMembers,
};
@@ -2,6 +2,7 @@ import type { z } from "zod";
import type { EventLocationType } from "@calcom/core/location";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import type { EventTypeTranslation } from "@calcom/prisma/client";
import type { PeriodType, SchedulingType } from "@calcom/prisma/enums";
import type { BookerLayoutSettings, EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { customInputSchema } from "@calcom/prisma/zod-utils";
@@ -70,6 +71,7 @@ export type FormValues = {
eventName: string;
slug: string;
isInstantEvent: boolean;
instantMeetingParameters: string[];
instantMeetingExpiryTimeOffsetInSeconds: number;
length: number;
offsetStart: number;
@@ -109,6 +111,8 @@ export type FormValues = {
seatsShowAttendees: boolean | null;
seatsShowAvailabilityCount: boolean | null;
seatsPerTimeSlotEnabled: boolean;
autoTranslateDescriptionEnabled: boolean;
fieldTranslations: EventTypeTranslation[];
scheduleName: string;
minimumBookingNotice: number;
minimumBookingNoticeInDurationType: number;
@@ -1,4 +1,7 @@
import type { AppFlags } from "./config";
export interface IFeaturesRepository {
checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags): Promise<boolean>;
checkIfUserHasFeature(userId: number, slug: string): Promise<boolean>;
checkIfTeamHasFeature(teamId: number, slug: string): Promise<boolean>;
checkIfTeamOrUserHasFeature(args: { teamId?: number; userId?: number }, slug: string): Promise<boolean>;
@@ -2,9 +2,19 @@ import { captureException } from "@sentry/nextjs";
import db from "@calcom/prisma";
import type { AppFlags } from "./config";
import type { IFeaturesRepository } from "./features.repository.interface";
import { getFeatureFlag } from "./server/utils";
export class FeaturesRepository implements IFeaturesRepository {
async checkIfFeatureIsEnabledGlobally(slug: keyof AppFlags) {
try {
return await getFeatureFlag(db, slug);
} catch (err) {
captureException(err);
throw err;
}
}
async checkIfTeamHasFeature(teamId: number, slug: string) {
try {
const teamFeature = await db.teamFeatures.findUnique({
+20 -3
View File
@@ -1,9 +1,26 @@
import type { AppFlags } from "@calcom/features/flags/config";
import { trpc } from "@calcom/trpc/react";
const initialData: Partial<AppFlags> = process.env.NEXT_PUBLIC_IS_E2E
? { organizations: true, teams: true }
: {};
const initialData: AppFlags = {
organizations: false,
teams: false,
"calendar-cache": false,
emails: false,
insights: false,
webhooks: false,
workflows: false,
"email-verification": false,
"google-workspace-directory": false,
"disable-signup": false,
attributes: false,
"organizer-request-email-v2": false,
};
if (process.env.NEXT_PUBLIC_IS_E2E) {
initialData.organizations = true;
initialData.teams = true;
}
export function useFlags(): Partial<AppFlags> {
const query = trpc.viewer.features.map.useQuery(undefined, {
initialData,
+8 -2
View File
@@ -2,15 +2,21 @@ import type { PrismaClient } from "@calcom/prisma";
import type { AppFlags } from "../config";
// This is a temporary cache to avoid hitting the database on every lambda invocation
let TEMP_CACHE: AppFlags | null = null;
export async function getFeatureFlagMap(prisma: PrismaClient) {
// If we've already fetched the flags, return them
if (TEMP_CACHE) return TEMP_CACHE;
const flags = await prisma.feature.findMany({
orderBy: { slug: "asc" },
cacheStrategy: { swr: 300, ttl: 300 },
});
return flags.reduce((acc, flag) => {
TEMP_CACHE = flags.reduce((acc, flag) => {
acc[flag.slug as keyof AppFlags] = flag.enabled;
return acc;
}, {} as Partial<AppFlags>);
}, {} as AppFlags);
return TEMP_CACHE;
}
interface CacheEntry {
@@ -216,7 +216,11 @@ function BookingStatusCell({
export type RoutingFormTableType = ReturnType<typeof useReactTable<RoutingFormTableRow>>;
export function RoutingFormResponsesTable() {
export function RoutingFormResponsesTable({
children,
}: {
children?: React.ReactNode | ((table: RoutingFormTableType) => React.ReactNode);
}) {
const { t } = useLocale();
const { filter } = useFilterContext();
const tableContainerRef = useRef<HTMLDivElement>(null);
@@ -442,8 +446,9 @@ export function RoutingFormResponsesTable() {
fetchMoreOnBottomReached(e.target as HTMLDivElement);
}
}}
isPending={isFetching && !data}
/>
isPending={isFetching && !data}>
{typeof children === "function" ? children(table) : children}
</DataTable>
</div>
);
}

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