Compare commits

..
35 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
Alex van Andel fc1d553b74 chore: Release v4.7.2 (#17692)
release.yml / release (push) Canceled after 0s
2024-11-16 18:36:43 +00:00
Calcom BotandReplexica b8c2fdac44 feat: update translations via @replexica (#17691)
Co-authored-by: Replexica <support@replexica.com>
2024-11-16 18:32:52 +00:00
Abul Hassan SathuliandPrashant Varma e24c40f518 fix: how-to-setup-api-in-a-local-instance.mdx in docs with correction after api-v2 (#17667)
* Updating how-to-setup-api-in-a-local-instance.mdx with correction after api v2

After the implementation of api v2, some of the documents are not yet updated and could you make developers spend too much time on finding these things.

* fix: typo

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-11-16 18:27:02 +00:00
Alex van Andel c5cefc3e61 chore: Found a critical index missing on profileId (#17690) 2024-11-16 18:26:50 +00:00
Omar López 2c57ad0288 perf: optimize organization admin check (#17683) 2024-11-16 18:26:23 +00:00
sean-brydon af9bf14d9e fix: routing column filters (#17688)
* remove column filters

* remove column filters

* use a loading state while loading columns

* fix
2024-11-16 18:05:36 +00:00
sean-brydon b1a0cdc8e2 fix: large org routing insights UI issues (#17687)
* fix overflow colour in darkmode

* truncate/show overflow scroll indicator when overflowing on response values

* remove total created count

* overflow Y on failed response container

* fix darkmode styles on barlist

* radix hovercards to properly allow overflow in container

* column visible state

* remove constrained width on dropdowns to w-fit

* export type
2024-11-16 11:01:26 -03:00
Alex van AndelandPeer Richelsen 7fef3e7b85 chore: Release v4.7.1 (#17686)
release.yml / release (push) Canceled after 0s
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-11-16 10:00:16 +00:00
Hariom Balhara 384deab0ce test: findTeamMemberEmailFromCrm (#17685)
* We need to choose contact owner when crm app slug is not present

* Add unit tests for getTeamMemberEmailFromCrm module
2024-11-16 09:32:33 +00:00
Hariom Balhara 22da681f8c We need to choose contact owner when crm app slug is not present (#17684) 2024-11-16 09:02:24 +00:00
Omar López f4f4afad39 perf: optimize team admin check queries (#17681)
## What does this PR do?

Optimizes team and organization admin checks by:
- Removing unnecessary member data fetching in profile repository
- Simplifying team admin verification query to directly check membership
- Refactoring organization admin check to use the optimized team verification method

## Mandatory Tasks

- [x] I have self-reviewed the code
- [x] I have updated the developer docs in /docs
- [ ] I confirm automated tests are in place

## How should this be tested?

1. Log in as a team admin/owner
2. Verify team admin privileges still work correctly
3. Log in as an organization admin
4. Confirm organization admin permissions are maintained
5. Check that admin-only features remain accessible

Expected behavior:
- Team admins/owners should retain all their existing permissions
- Organization admins should maintain their administrative capabilities
- No degradation in admin functionality while improving query performance

## Checklist

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2024-11-15 19:00:18 -07:00
Keith Williams 1c05952077 chore: Remove playwright install for API v2 E2E (#17677) 2024-11-15 23:47:26 +00:00
162 changed files with 14327 additions and 1012 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
View File
@@ -65,7 +65,6 @@ jobs:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/yarn-playwright-install
- uses: ./.github/actions/cache-db
- name: Run Tests
working-directory: apps/api/v2
+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"),
@@ -0,0 +1,410 @@
import prismock from "../../../../tests/libs/__mocks__/prisma";
import { v4 } from "uuid";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import bookingFormHandlers from "@calcom/app-store/routing-forms/appBookingFormHandler";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import { SchedulingType } from "@calcom/prisma/enums";
import { getTeamMemberEmailForResponseOrContactUsingUrlQuery } from "../getTeamMemberEmailFromCrm";
vi.mock("@calcom/app-store/routing-forms/appBookingFormHandler", () => ({
default: {
salesforce: vi.fn(),
},
}));
vi.mock("@calcom/app-store/_utils/CRMRoundRobinSkip", () => ({
getCRMContactOwnerForRRLeadSkip: vi.fn(),
}));
function mockGetCRMContactOwnerForRRLeadSkip({
bookerEmail,
teamMemberEmail,
}: {
bookerEmail: string;
teamMemberEmail: string;
}) {
vi.mocked(getCRMContactOwnerForRRLeadSkip).mockImplementation((_bookerEmail, _eventMetadata) => {
if (_bookerEmail === bookerEmail) {
return Promise.resolve(teamMemberEmail);
}
return Promise.resolve(undefined);
});
}
function mockBookingFormHandler({
bookerEmail,
teamMemberEmail,
}: {
bookerEmail: string;
teamMemberEmail: string;
}) {
vi.mocked(bookingFormHandlers.salesforce).mockImplementation(
(_bookerEmail, _attributeRoutingConfig, _eventTypeId) => {
if (_bookerEmail === bookerEmail) {
return Promise.resolve({ email: teamMemberEmail });
}
return Promise.resolve({ email: null });
}
);
}
async function createRoutingFormWithResponse({
formData,
}: {
formData: {
routes: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
queryValue?: any;
action: {
eventTypeId: number;
type: RouteActionType;
value: string;
};
attributeRoutingConfig: {
skipContactOwner: boolean;
};
}[];
};
}) {
const user = await prismock.user.create({
data: {
name: "Routing Form User",
username: "routing-form-user",
email: "routing-form-user@example.com",
},
});
const form = await prismock.app_RoutingForms_Form.create({
data: {
...formData,
name: "Test Form",
routes: formData.routes.map((route) => ({
queryValue: {
id: v4(),
type: "group",
children1: [],
properties: {},
},
id: v4(),
...route,
})),
user: {
connect: {
id: user.id,
},
},
},
});
const responseRecord = await prismock.app_RoutingForms_FormResponse.create({
data: {
response: {},
form: {
connect: {
id: form.id,
},
},
},
});
return { form, responseRecord };
}
async function createHostForEvent({
eventTypeData,
userData,
}: {
eventTypeData: { id: number } & Partial<{
title: string;
slug: string;
length: number;
}>;
userData: {
email: string;
} & Partial<{
name: string;
username: string;
}>;
}) {
const user = await prismock.user.create({
data: {
name: "Test User",
username: "testuser",
...userData,
},
});
await prismock.eventType.create({
data: {
title: "Title",
slug: "slug",
length: 30,
...eventTypeData,
hosts: {
create: {
userId: user.id,
isFixed: true,
},
},
},
});
}
describe("getTeamMemberEmailForResponseOrContactUsingUrlQuery", () => {
beforeEach(() => {
vi.resetAllMocks();
});
const mockEventData = {
id: 1,
isInstantEvent: false,
schedulingType: SchedulingType.ROUND_ROBIN,
metadata: null,
length: 30,
};
it("should return null when email is not provided in query", async () => {
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {},
eventData: mockEventData,
});
expect(result).toBeNull();
});
it("should return null when scheduling type is not ROUND_ROBIN", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "teamMember@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: bookerEmail },
eventData: { ...mockEventData, schedulingType: SchedulingType.COLLECTIVE },
});
expect(result).toBeNull();
});
it("should return CRM owner email when valid", async () => {
const ownerEmail = "owner@example.com";
const bookerEmail = "booker@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: ownerEmail,
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: "booker@example.com" },
eventData: mockEventData,
});
expect(result).toBe(ownerEmail);
});
it("should return null when CRM owner is not found", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "teamMember@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail });
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: bookerEmail },
eventData: mockEventData,
});
expect(result).toBeNull();
});
it("should return null when CRM owner is not part of event type", async () => {
const ownerEmail = "owner@example.com";
const bookerEmail = "booker@example.com";
mockGetCRMContactOwnerForRRLeadSkip({ bookerEmail, teamMemberEmail: ownerEmail });
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: { email: bookerEmail },
eventData: mockEventData,
});
expect(result).toBeNull();
});
describe("Booking form handler", () => {
it("should return teamMember email through booking form handler when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result).toBe(teamMemberEmail);
});
it("should return null when skipContactOwner is true even when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: true },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
// Because skipContactOwner is true, the booking form handler should return null
expect(result).toBe(null);
});
it("should return null when when cal.routingFormResponseId and cal.salesforce.xxxx=true is provided but the returned email isn't an event member", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
const SOME_OTHER_EVENT_ID = 200;
await createHostForEvent({
eventTypeData: {
id: SOME_OTHER_EVENT_ID,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.salesforce.xxx": "true",
},
eventData: mockEventData,
});
expect(result).toBe(null);
});
it("should return null when cal.routingFormResponseId is provided but cal.salesforce.xxxx is not", async () => {
const bookerEmail = "booker@example.com";
const teamMemberEmail = "owner@example.com";
mockBookingFormHandler({ bookerEmail, teamMemberEmail });
await createHostForEvent({
eventTypeData: {
id: mockEventData.id,
},
userData: {
email: teamMemberEmail,
},
});
const { responseRecord } = await createRoutingFormWithResponse({
formData: {
routes: [
{
action: {
eventTypeId: 1,
type: RouteActionType.EventTypeRedirectUrl,
value: "/team/sales",
},
attributeRoutingConfig: { skipContactOwner: false },
},
],
},
});
const result = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query: {
email: bookerEmail,
[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: responseRecord.id.toString(),
"cal.somethingelse.xxx": "true",
},
eventData: mockEventData,
});
expect(result).toBe(null);
});
});
});
+22 -27
View File
@@ -21,6 +21,21 @@ interface EventData {
length: number;
}
async function findUserByEmailWhoIsAHostOfEventType({
email,
eventTypeId,
}: {
email: string;
eventTypeId: number;
}) {
return prisma.user.findFirst({
where: {
email,
hosts: { some: { eventTypeId } },
},
});
}
function getRoutingFormResponseIdFromQuery(query: ParsedUrlQuery) {
const routingFormResponseIdAsNumber = Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]);
const routingFormResponseId = isNaN(routingFormResponseIdAsNumber) ? null : routingFormResponseIdAsNumber;
@@ -99,16 +114,11 @@ function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) {
async function getOwnerEmailFromCrm(eventData: EventData, email: string): Promise<string | null> {
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
if (!crmContactOwnerEmail) return null;
// Determine if the contactOwner is a part of the event type
const contactOwnerQuery = await prisma.user.findFirst({
where: {
email: crmContactOwnerEmail,
hosts: {
some: {
eventTypeId: eventData.id,
},
},
},
const contactOwnerQuery = await findUserByEmailWhoIsAHostOfEventType({
email: crmContactOwnerEmail,
eventTypeId: eventData.id,
});
if (!contactOwnerQuery) return null;
return crmContactOwnerEmail;
@@ -138,7 +148,6 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
.default;
const appHandler = appBookingFormHandler[crmAppSlug];
if (!appHandler) return nullReturnValue;
const { email: userEmail } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId);
@@ -146,16 +155,7 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
if (!userEmail) return nullReturnValue;
// Determine if the user is a part of the event type
const userQuery = await prisma.user.findFirst({
where: {
email: userEmail,
hosts: {
some: {
eventTypeId: eventTypeId,
},
},
},
});
const userQuery = await findUserByEmailWhoIsAHostOfEventType({ email: userEmail, eventTypeId });
if (!userQuery) return nullReturnValue;
@@ -164,22 +164,21 @@ async function getTeamMemberEmailUsingRoutingFormHandler({
async function getTeamMemberEmailForResponseOrContact({
bookerEmail,
eventTypeId,
eventData,
routingFormResponseId,
chosenRoute,
crmAppSlug,
}: {
bookerEmail: string;
eventTypeId: number;
eventData: EventData;
routingFormResponseId?: number | null;
/**
* If provided, we won't go look for the route from DB.
*/
chosenRoute?: LocalRoute;
crmAppSlug: string;
crmAppSlug?: string;
}) {
const eventTypeId = eventData.id;
if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
const attributeRoutingConfigGetterData = routingFormResponseId
@@ -214,12 +213,10 @@ async function getTeamMemberEmailForResponseOrContact({
export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventTypeId,
eventData,
chosenRoute,
}: {
query: ParsedUrlQuery;
eventTypeId: number;
eventData: EventData;
chosenRoute?: LocalRoute;
}) {
@@ -231,13 +228,11 @@ export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
log.debug("getTeamMemberEmailForResponseOrContactUsingUrlQuery", safeStringify({ query }));
const crmAppSlug = getEnabledRoutingFormAppSlugFromQuery(query);
if (!crmAppSlug) return null;
const routingFormResponseId = getRoutingFormResponseIdFromQuery(query);
return await getTeamMemberEmailForResponseOrContact({
bookerEmail: query.email,
eventTypeId,
eventData,
routingFormResponseId,
chosenRoute,
@@ -114,7 +114,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
orgBannerUrl: team.parent?.bannerUrl ?? "",
teamMemberEmail: await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventTypeId,
eventData,
}),
},
@@ -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.0",
"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": "رؤية كل الأوقات المتاحة",
@@ -2779,12 +2781,15 @@
"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": "تعظيم التوفر",
@@ -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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Rezervasiya edən",
"routing_form_insights_created_at": "Yaradılma tarixi",
"routing_form_insights_booking_status": "Rezervasiya statusu",
"routing_form_insights_booking_at": "Rezervasiya tarixi",
"routing_form_insights_submitted_at": "Göndərilmə tarixi",
"access_denied": "Giriş qadağandır",
"salesforce_route_to_owner": "Əlaqə sahibi mövcud olduqda Round Robin ev sahibi olacaq",
"salesforce_do_not_route_to_owner": "Əlaqə sahibi məcbur edilməyəcək (əgər atributlara və Round Robin meyarlarına uyğun gəlirsə, yenə də ev sahibi ola bilər)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Rezervoval/a",
"routing_form_insights_created_at": "Vytvořeno",
"routing_form_insights_booking_status": "Stav rezervace",
"routing_form_insights_booking_at": "Čas rezervace",
"routing_form_insights_submitted_at": "Čas odeslání",
"access_denied": "Přístup odepřen",
"salesforce_route_to_owner": "Vlastník kontaktu bude hostem Round Robin, pokud je k dispozici",
"salesforce_do_not_route_to_owner": "Vlastník kontaktu nebude nucen (stále může být hostem, pokud odpovídá atributům a kritériím Round Robin)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Booket af",
"routing_form_insights_created_at": "Oprettet den",
"routing_form_insights_booking_status": "Bookingstatus",
"routing_form_insights_booking_at": "Booket den",
"routing_form_insights_submitted_at": "Indsendt den",
"access_denied": "Adgang nægtet",
"salesforce_route_to_owner": "Kontaktens ejer vil være Round Robin-vært, hvis tilgængelig",
"salesforce_do_not_route_to_owner": "Kontaktens ejer vil ikke blive tvunget (kan stadig være vært, hvis det matcher attributterne og Round Robin-kriterierne)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Gebucht von",
"routing_form_insights_created_at": "Erstellt am",
"routing_form_insights_booking_status": "Buchungsstatus",
"routing_form_insights_booking_at": "Gebucht am",
"routing_form_insights_submitted_at": "Eingereicht am",
"access_denied": "Zugriff verweigert",
"salesforce_route_to_owner": "Der Kontaktinhaber wird der Round Robin Host sein, falls verfügbar",
"salesforce_do_not_route_to_owner": "Der Kontaktinhaber wird nicht erzwungen (kann trotzdem Host sein, wenn die Attribute und Round Robin Kriterien übereinstimmen)",
"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",
@@ -2783,12 +2785,15 @@
"routing_form_insights_booked_by": "Booked by",
"routing_form_insights_created_at": "Created at",
"routing_form_insights_booking_status": "Booking Status",
"routing_form_insights_booking_at": "Booking At",
"routing_form_insights_submitted_at": "Submitted At",
"access_denied": "Access Denied",
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Reservado por",
"routing_form_insights_created_at": "Creado el",
"routing_form_insights_booking_status": "Estado de la reserva",
"routing_form_insights_booking_at": "Reservado el",
"routing_form_insights_submitted_at": "Enviado el",
"access_denied": "Acceso denegado",
"salesforce_route_to_owner": "El propietario del contacto será el anfitrión de Round Robin si está disponible",
"salesforce_do_not_route_to_owner": "El propietario del contacto no será forzado (aún puede ser anfitrión si coincide con los atributos y criterios de Round Robin)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Reservado por",
"routing_form_insights_created_at": "Creado el",
"routing_form_insights_booking_status": "Estado de la reserva",
"routing_form_insights_booking_at": "Fecha de reserva",
"routing_form_insights_submitted_at": "Fecha de envío",
"access_denied": "Acceso denegado",
"salesforce_route_to_owner": "El propietario del contacto será el anfitrión de Round Robin si está disponible",
"salesforce_do_not_route_to_owner": "No se forzará al propietario del contacto (aún puede ser anfitrión si coincide con los atributos y criterios de Round Robin)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Broneeris",
"routing_form_insights_created_at": "Loodud",
"routing_form_insights_booking_status": "Broneeringu staatus",
"routing_form_insights_booking_at": "Broneeritud",
"routing_form_insights_submitted_at": "Esitatud",
"access_denied": "Juurdepääs keelatud",
"salesforce_route_to_owner": "Kontakti omanik on round robin host, kui saadaval",
"salesforce_do_not_route_to_owner": "Kontakti omanikku ei sunnita (võib siiski olla host, kui vastab atribuutidele ja round robin kriteeriumitele)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Réservé par",
"routing_form_insights_created_at": "Créé le",
"routing_form_insights_booking_status": "Statut de la réservation",
"routing_form_insights_booking_at": "Réservé le",
"routing_form_insights_submitted_at": "Soumis le",
"access_denied": "Accès refusé",
"salesforce_route_to_owner": "Le propriétaire du contact sera l'hôte Round Robin si disponible",
"salesforce_do_not_route_to_owner": "Le propriétaire du contact ne sera pas forcé (peut toujours être l'hôte si cela correspond aux attributs et aux critères Round Robin)",
"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": "לצפייה בכל המועדים הפנויים",
@@ -2779,12 +2781,15 @@
"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": "מקסום זמינות",
@@ -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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Foglalva általa",
"routing_form_insights_created_at": "Létrehozva",
"routing_form_insights_booking_status": "Foglalás állapota",
"routing_form_insights_booking_at": "Foglalás időpontja",
"routing_form_insights_submitted_at": "Beküldés időpontja",
"access_denied": "Hozzáférés megtagadva",
"salesforce_route_to_owner": "A kapcsolattartó tulajdonosa lesz a round robin házigazda, ha elérhető",
"salesforce_do_not_route_to_owner": "A kapcsolattartó tulajdonosa nem lesz kötelező (továbbra is lehet házigazda, ha megfelel a tulajdonságoknak és a round robin kritériumoknak)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Prenotato da",
"routing_form_insights_created_at": "Creato il",
"routing_form_insights_booking_status": "Stato della prenotazione",
"routing_form_insights_booking_at": "Data prenotazione",
"routing_form_insights_submitted_at": "Data invio",
"access_denied": "Accesso negato",
"salesforce_route_to_owner": "Il proprietario del contatto sarà l'host del Round Robin se disponibile",
"salesforce_do_not_route_to_owner": "Il proprietario del contatto non sarà obbligato (può comunque essere l'host se corrisponde agli attributi e ai criteri del Round Robin)",
"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": "出席できる時間帯をすべて表示",
@@ -2779,12 +2781,15 @@
"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": "可能な場合、取引先責任者の所有者がラウンドロビンのホストになります",
"salesforce_do_not_route_to_owner": "取引先責任者の所有者は強制されません(属性とラウンドロビンの基準に一致する場合はホストになる可能性があります)",
"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": "មើលពេលវេលាទាំងអស់ដែលអាចប្រើបាន",
@@ -2779,12 +2781,15 @@
"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": "បកប្រែការពិពណ៌នាទៅជាភាសាកម្មវិធីរុករករបស់ភ្ញៀវដោយប្រើ 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": "모든 사용 가능한 시간 보기",
@@ -2779,12 +2781,15 @@
"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": "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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Geboekt door",
"routing_form_insights_created_at": "Aangemaakt op",
"routing_form_insights_booking_status": "Boekingsstatus",
"routing_form_insights_booking_at": "Geboekt op",
"routing_form_insights_submitted_at": "Ingediend op",
"access_denied": "Toegang geweigerd",
"salesforce_route_to_owner": "Contacteigenaar zal de Round Robin host zijn indien beschikbaar",
"salesforce_do_not_route_to_owner": "Contacteigenaar wordt niet geforceerd (kan nog steeds host zijn als het overeenkomt met de attributen en Round Robin criteria)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Bestilt av",
"routing_form_insights_created_at": "Opprettet den",
"routing_form_insights_booking_status": "Bestillingsstatus",
"routing_form_insights_booking_at": "Booket",
"routing_form_insights_submitted_at": "Sendt inn",
"access_denied": "Tilgang nektet",
"salesforce_route_to_owner": "Kontaktens eier vil være Round Robin-vert hvis tilgjengelig",
"salesforce_do_not_route_to_owner": "Kontaktens eier vil ikke bli tvunget (kan fortsatt være vert hvis det samsvarer med attributtene og Round Robin-kriteriene)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Zarezerwowane przez",
"routing_form_insights_created_at": "Utworzono",
"routing_form_insights_booking_status": "Status rezerwacji",
"routing_form_insights_booking_at": "Data rezerwacji",
"routing_form_insights_submitted_at": "Data przesłania",
"access_denied": "Odmowa dostępu",
"salesforce_route_to_owner": "Właściciel kontaktu będzie hostem Round Robin, jeśli jest dostępny",
"salesforce_do_not_route_to_owner": "Właściciel kontaktu nie będzie wymuszony (może nadal być hostem, jeśli spełnia atrybuty i kryteria Round Robin)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Agendado por",
"routing_form_insights_created_at": "Criado em",
"routing_form_insights_booking_status": "Status do Agendamento",
"routing_form_insights_booking_at": "Agendado em",
"routing_form_insights_submitted_at": "Enviado em",
"access_denied": "Acesso negado",
"salesforce_route_to_owner": "O proprietário do contato será o anfitrião do Round Robin se disponível",
"salesforce_do_not_route_to_owner": "O proprietário do contato não será forçado (ainda pode ser anfitrião se corresponder aos atributos e critérios do Round Robin)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Agendado por",
"routing_form_insights_created_at": "Criado em",
"routing_form_insights_booking_status": "Status do agendamento",
"routing_form_insights_booking_at": "Agendado em",
"routing_form_insights_submitted_at": "Enviado em",
"access_denied": "Acesso negado",
"salesforce_route_to_owner": "O proprietário do contato será o anfitrião do Round Robin se disponível",
"salesforce_do_not_route_to_owner": "O proprietário do contato não será forçado (ainda pode ser anfitrião se corresponder aos atributos e critérios do Round Robin)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Programat de",
"routing_form_insights_created_at": "Creat la",
"routing_form_insights_booking_status": "Status programare",
"routing_form_insights_booking_at": "Programat la",
"routing_form_insights_submitted_at": "Trimis la",
"access_denied": "Acces refuzat",
"salesforce_route_to_owner": "Proprietarul contactului va fi gazda Round Robin dacă este disponibil",
"salesforce_do_not_route_to_owner": "Proprietarul contactului nu va fi forțat (poate fi totuși gazdă dacă se potrivesc atributele și criteriile Round Robin)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Rezervoval",
"routing_form_insights_created_at": "Vytvorené",
"routing_form_insights_booking_status": "Stav rezervácie",
"routing_form_insights_booking_at": "Čas rezervácie",
"routing_form_insights_submitted_at": "Čas odoslania",
"access_denied": "Prístup odmietnutý",
"salesforce_route_to_owner": "Vlastník kontaktu bude hostiteľom metódou Round Robin, ak je k dispozícii",
"salesforce_do_not_route_to_owner": "Vlastník kontaktu nebude nútený (môže byť hostiteľom, ak zodpovedá atribútom a kritériám Round Robin)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Rezervisao/la",
"routing_form_insights_created_at": "Kreirano",
"routing_form_insights_booking_status": "Status rezervacije",
"routing_form_insights_booking_at": "Vreme rezervacije",
"routing_form_insights_submitted_at": "Vreme podnošenja",
"access_denied": "Pristup odbijen",
"salesforce_route_to_owner": "Vlasnik kontakta će biti Round Robin domaćin ako je dostupan",
"salesforce_do_not_route_to_owner": "Vlasnik kontakta neće biti primoran (može i dalje biti domaćin ako odgovara atributima i Round Robin kriterijumima)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Bokad av",
"routing_form_insights_created_at": "Skapad",
"routing_form_insights_booking_status": "Bokningsstatus",
"routing_form_insights_booking_at": "Bokad den",
"routing_form_insights_submitted_at": "Skickad den",
"access_denied": "Åtkomst nekad",
"salesforce_route_to_owner": "Kontaktägaren blir Round Robin-värd om tillgänglig",
"salesforce_do_not_route_to_owner": "Kontaktägaren kommer inte att tvingas (kan fortfarande vara värd om det matchar attributen och Round Robin-kriterierna)",
"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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Rezervasyon yapan",
"routing_form_insights_created_at": "Oluşturulma tarihi",
"routing_form_insights_booking_status": "Rezervasyon durumu",
"routing_form_insights_booking_at": "Rezervasyon tarihi",
"routing_form_insights_submitted_at": "Gönderilme tarihi",
"access_denied": "Erişim Reddedildi",
"salesforce_route_to_owner": "Mümkünse kişi sahibi round robin ev sahibi olacak",
"salesforce_do_not_route_to_owner": "Kişi sahibi zorlanmayacak (özellikler ve round robin kriterlerine uyuyorsa yine de ev sahibi olabilir)",
"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": "Переглянути доступні часові проміжки",
@@ -2779,12 +2781,15 @@
"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": "Максимізувати доступність",
@@ -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",
@@ -2779,12 +2781,15 @@
"routing_form_insights_booked_by": "Được đặt bởi",
"routing_form_insights_created_at": "Được tạo vào",
"routing_form_insights_booking_status": "Trạng thái đặt chỗ",
"routing_form_insights_booking_at": "Thời gian đặt lịch",
"routing_form_insights_submitted_at": "Thời gian gửi",
"access_denied": "Từ chối truy cập",
"salesforce_route_to_owner": "Người sở hữu liên hệ sẽ là người chủ trì Round Robin nếu khả dụng",
"salesforce_do_not_route_to_owner": "Người sở hữu liên hệ sẽ không bị bắt buộc (vẫn có thể là người chủ trì nếu phù hợp với các thuộc tính và tiêu chí Round Robin)",
"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": "查看所有可预约时间",
@@ -2779,12 +2781,15 @@
"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": "如果联系人所有者可用,将作为轮询主持人",
"salesforce_do_not_route_to_owner": "不会强制指定联系人所有者(如果符合属性和轮询标准,仍可成为主持人)",
"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": "查看所有可預約時段",
@@ -2779,12 +2781,15 @@
"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": "使用 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 * * *"
@@ -6,7 +6,7 @@ To test API in your local instance, you have the following pre-requisites:
<Steps>
<Step title="Clone the cal.com repository">
Please close the cal.com repository. You can do it by following the instructions provided in [Installation](/developing/local-development)
Please clone the cal.com repository. You can do it by following the instructions provided in [Installation](/developing/local-development)
</Step>
<Step title="Add license key in .env">
Add a staging license key that goes in as value for `CALCOM_LICENSE_KEY` in your root `.env` file.
@@ -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": {
+5
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
@@ -2781,12 +2783,15 @@ checksums:
routing_form_insights_booked_by: 7574cbcc0dd68a6469b6715452e035cc
routing_form_insights_created_at: f98a3c3bc73494df9bbc14e4e47a018c
routing_form_insights_booking_status: 08b945804d902b55184884401a840d57
routing_form_insights_booking_at: ca8a804eb7068845bede9adbb6bb7d3b
routing_form_insights_submitted_at: b95063c78796a32900c35743237512c4
access_denied: 589456a5c4103e29c44e9df16a4eaf82
salesforce_route_to_owner: 39a88a7afb1c1e8fb9215e27ae7a7033
salesforce_do_not_route_to_owner: bcae6e5615c17b809c2334a541f75e78
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;

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