Compare commits

..
149 Commits
Author SHA1 Message Date
GitHub Actions 93489709a8 chore: release v4.7.11
Post release / release (push) Canceled after 0s
2024-11-25 16:35:44 +00:00
Amit Sharma 050102fde2 fix: OOO redirect, email wrong formatting (#17829) 2024-11-25 16:11:44 +00:00
Morgan c1112ab2ab fix: extract clientId header manually apiv2 (Revert) (#17832)
This reverts commit b0bf87f3ed.
2024-11-25 15:59:39 +00:00
Morgan 34d4c3d775 fix: api v2 webhook issue (#17831) 2024-11-25 15:52:00 +00:00
Somay Chauhan e477e5892c feat: added customClassnames for event-setup tab (#17240)
* feat: added customClassnames for event-setup tab

* Update EventTypePlatformWrapper.tsx

* better classnames naming

* added classNames to common types

* updated classnames to use the common exported types

* udpate

* better classnames

* better classname names

* classnames -> classNames

* added default styling

* Update Locations.tsx

* Update event-types.tsx

* update

* locationSelectCustomClassNames -> LocationSelectCustomClassNames

* Update documentation.json

* fix: better classnames

* Update types.ts
2024-11-25 09:59:09 +00:00
ad44ca9f2a fix: change order of eventtypes and routingforms (#17615)
* change order of eventtypes and routingforms

* for workflows

* for filtered workflows

* update slug of first expected event-type

* updated due to latest merges

* test to ensure new eventtype added is first in the list

* test for workflows

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-11-25 06:25:05 -03:00
Somay ChauhanandMorgan 868cf91c35 refactor: conferencing apps atoms web wrapper (#17732)
* conferencing apps atoms web wrapper

* Update DisconnectIntegrationModal.tsx

* formating changes

* handleBulkUpdateDefaultLocation -> bulkUpdateFunction

* fix: type error for handleRemoveApp

* fix: type error for handleRemoveApp

* refactor: removed ConferencingView component and migrated its functionality to ConferencingAppsViewWebWrapper

* refactor: moved AppList, AppListCard to packages/features

* fix: type changes

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-11-25 09:53:38 +02:00
Lauris Skraucis 8b9693ea60 fix: v2 booking location (#17817)
* fix: create-booking.input dont require location to be url

* fix: return video call url for booking location

* try to debug test

* remove test file
2024-11-25 07:34:59 +00:00
Somay Chauhan 935115d7c1 feat: added custom classnames for event-limits tab (#17478)
* added custom classnames for event limits tab

* added classNames to common types

* updated classnames to use the common exported types

* undo

* Update EventTypePlatformWrapper.tsx

* better clasnames naming

* added example classnames for demo

* fix: better calssnames

bookingFrequencyLimitSection-> bookingFrequencyLimit
totalDurationLimitSection-> totalDurationLimit
futureBookingLimitSection-> futureBookingLimit
offsetStartTimesSection-> offsetStartTimes

* suffix->addOn

* Update types.ts
2024-11-25 07:13:58 +00:00
Somay Chauhan eae74c568c feat: added custom classnames for event-advanced tab (#17479)
* feat: added custom classnames to event-advanced tab

* added more calssnames

* added classNames to common types

* undo extra changes

* Update EventTypePlatformWrapper.tsx

* Update types.ts

* Update EventTypePlatformWrapper.tsx

* better classnames

* Update EventTypePlatformWrapper.tsx

* addeed CustomEventTypeModalClassNames

* added example classnames for demo and some changes

* fix: better classnames

* timezoneLockToggle -> timezoneLock

* Update types.ts

* Update CustomEventTypeModal.tsx
2024-11-25 12:11:27 +05:30
Somay Chauhan 9e9dd6070d feat: added custom classnames to event-assignment tab atom (#17527)
* added classNames to common types

* Update types.ts

* feat: added custom classnames to event-assignment tab atom

* Update EventTypePlatformWrapper.tsx

* update: better classnames

* added example clasnames for demo

* fix: classnames

* fix: type check

* fixed classnames

* fix: better calssnames

assingAllTeamMembersToggle -> assingAllTeamMembers
enableWeightsToggle -> enableWeights
assingAllTeamMembersToggle -> assingAllTeamMembers
2024-11-25 06:10:22 +01:00
Amit SharmaandPeer Richelsen 5af10a2f61 feat: past and overlapping ooo (#17373)
* feat: past and overlapping ooo

* use existing props

* added e2e test

* disallow duplicates added tests

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-11-24 18:58:22 +00:00
Peer Richelsen 3936a0ae94 target blank for settings link (#17815) 2024-11-23 21:12:44 +00:00
Morgan c2a3d8b8df fix: extract trpc and react-awesome-query-builder from atoms (#17801)
* wip

* fix: externalize react-awesome-query-builder

* fix: move useSession to webWrapper

* fixup! Merge branch 'main' into fix-platform-event-types-segment

* fixup! fixup! Merge branch 'main' into fix-platform-event-types-segment

* fixup! fixup! fixup! Merge branch 'main' into fix-platform-event-types-segment

* fixup! fixup! fixup! fixup! Merge branch 'main' into fix-platform-event-types-segment
2024-11-23 12:29:16 +00:00
GitHub Actions 9bb66035c8 chore: release v4.7.10
Post release / release (push) Canceled after 0s
2024-11-22 23:01:45 +00:00
Joe Au-Yeung 226325062e Conditionally get the first assignment reason (#17808) 2024-11-22 22:23:15 +00:00
Carina Wollendorfer b61661214a fix: improvements for canceling workflow reminders (#17807) 2024-11-22 13:55:30 -07:00
Omar López 9f2047f203 fix: Got back to access token for release action
Signed-off-by: Omar López <zomars@me.com>
2024-11-22 13:53:19 -07:00
Omar López 9b0db345b0 Revert "Add new release actions"
This reverts commit 05c8b53444.
2024-11-22 13:50:27 -07:00
GitHub Actions a84a2b3152 chore: release 4.7.9
Post release / release (push) Canceled after 0s
2024-11-22 20:43:40 +00:00
Omar López 087dbefec2 fix: add missing version plugin for release action 2024-11-22 13:35:52 -07:00
Omar López 46ba793521 fix: draft release setup 2024-11-22 13:32:29 -07:00
Omar López 21ab9bf109 fix: draft release setup 2024-11-22 13:30:15 -07:00
Omar López 05c8b53444 Add new release actions
Signed-off-by: Omar López <zomars@me.com>
2024-11-22 13:27:33 -07:00
Joe Au-Yeung e7495e311b feat: Render assignment reason on routing form report (#17791) 2024-11-22 10:57:16 -07:00
Omar López 9c10ecf8ea ci: Add new release actions (#17789) 2024-11-22 10:08:26 -07:00
Lauris Skraucis 9fa73aca86 fix: v2 booking confirmation emails not sent (#17764)
* fix: round robin recurring don't send email if noEmail

* fix: noEmail: false for non platform requests

* fix: booking e2e tests

* refactor: move bookings e2e tests in folder and uniform name

* test: emails

* test: team emails

* reschedule and cancel emails with api key

* test: round robin reassign emails

* test: confirm and decline emails

* test: confirm and decline emails

* fix tests

* test: recurring booking emails

* remove unused imports and spies

* fix test

* remove log

* chore: bump libraries

* try to fix ci tests
2024-11-22 18:19:56 +02:00
Alex van Andel 8e7ce1216c Revert "feat: install gcal + gvideo by default for google signups (#17773)" (#17803)
release.yml / release (push) Canceled after 0s
This reverts commit fcd06c80e3.
2024-11-22 15:55:11 +00:00
Alex van Andel 4ddbdde553 chore: Release v4.7.8 2024-11-22 15:54:42 +00:00
Keith Williams cd24067f17 chore: Add ability to disable Sentry source maps (#17699)
* chore: Remove Sentry source maps

* Using .env to control Sentry source maps

* Trying with sourcemaps property

* Turned silent off for testing

* Completely turned off devtool
2024-11-22 15:19:52 +00:00
Alex van Andel 040adb2ddf chore: bump lockfile (#17802) 2024-11-22 15:14:39 +00:00
Somay Chauhan 4d578d2cdf feat: added customClassnames to event-availability tab (#17268)
* feat: added customClassnames to event-availability tab

* added classNames to common types

* use common classnames type

* Update types.ts

* update name

* fix: better classnames

* better calssnames

* Update types.ts
2024-11-22 10:35:20 +01:00
Anwar Sadath 1cf84a270c feat: Expose booking created at in v2 API response (#17452)
* feat: Expose booking created at in booking detailed view and v2 API response

* Fix tests

* Fix spacing

* Add API v2 tests to verify createdAt is present while creating a booking and fetching a booking

* Add test to validate createdAt after rescheduling a booking
2024-11-22 13:50:11 +05:30
Somay Chauhan d03651312e feat: added customClassnames for event-recurring tab (#17551)
* added classNames to common types

* Update types.ts

* feat: added custom classnames to event-recurring tab

* Update EventTypePlatformWrapper.tsx

* customClassnames -> customClassNames

* added example classnames for demo

* fixed classnames
2024-11-22 12:30:02 +05:30
Alex van Andel 4b34aec13d chore: Release v4.7.7 (#17786)
release.yml / release (push) Canceled after 0s
2024-11-21 22:47:11 +00:00
Anik Dhabal Babu 7ab8fdbcb4 fix: short text booking questions does not save we we click add (#17778) 2024-11-21 22:46:44 +00:00
Morgan b0bf87f3ed fix: extract clientId header manually apiv2 (#17783) 2024-11-21 22:46:13 +00:00
Joe Au-Yeung 1490642260 feat: Record Salesforce ownership assignment (#17775) 2024-11-21 14:22:39 -07:00
Udit Takkar 04b49acbb0 chore: add rescheduled by property (#17631)
* refactor: add rescheduledBy

* chore

* fix: bug

* test: add test for rescheduled booking

* fix: test
2024-11-21 11:51:19 -05:00
Joe Au-Yeung ff79e5c70e feat: Record assignment reasons for routing form and reassignment (#17767)
* Add assignment reason table

* Increment id

* Update migration

* Record assignment when manual reassignment is used

* Add comment to `getAttributesQueryValue`

* Add `getUsersAttributes`

* Record routing form routing reason

* Type fixes

* Remove unique property from bookingId

* Handle nested values
2024-11-21 16:27:19 +00:00
Alex van Andel 5d51c79739 fix: temporarily revert stricter schema so we can handle invalid DB e… (#17774) 2024-11-20 21:35:43 -07:00
Carina WollendorferandCarinaWolli 8bc2c08288 fix: no availability on reschedule with load balancing (#17770)
* don't filter if reschedule with same host

* check for reschedule

* fix filtering in handleNewBooking

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-11-21 01:22:43 +00:00
Omar López ade144aa20 ci: Re-drafts PR when changes are requested (#17137) 2024-11-21 00:59:11 +00:00
Benny Joo fcd06c80e3 feat: install gcal + gvideo by default for google signups (#17773)
* feat: install gcal + gvideo by default for google signups

* add upsertSelectedCalendar

* fix type check
2024-11-20 23:54:11 +00:00
Omar López 57bbba3d95 chore: v4.7.6 (#17766)
release.yml / release (push) Canceled after 0s
2024-11-20 13:22:47 -07:00
Amit Sharma d952e77b78 feat: Don't remove hosts when disabling 'Add all team members' (#17747)
* feat: Don't remove hosts when disabling 'Add all team members'

* retain existing host weights and priorities

* fix: merge changes, revert onInactive removal

* lint fix

* fix: weight priority dialog value
2024-11-20 17:54:42 +00:00
Anik Dhabal Babu 73989da3b7 fix: common schedule toggle for team event in never uncheckable (#17759) 2024-11-20 11:25:58 -05:00
Hariom Balhara a534d39351 Log error correctly (#17755) 2024-11-20 12:03:07 -03:00
Calcom BotandReplexica c16ef798f5 feat: update translations via @replexica (#17757)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-20 14:03:09 +00:00
Lauris Skraucis 58cac84f4a feat: v2 expose booking metadata (#17708)
* feat: accept metadata and store it in database

* feat: accept metadata and store it in database

* feat: add BookingSeat.metadata column

* feat: handleNewBooking: store booking seat metadata when first time seated booking

* feat: handleSeats, createNewSeat store metadata when 2nd+ seated booking

* feat: return booking metadata in output

* tests: metadata

* test: seated bookings

* swagger

* fix: reassign bookings test

* test: use different metadata in recurring

* fix: test

* test invalid metadata

* refactor: output service

* readd new line

* chore: bump platform libraries

* refactor: metadata doc
2024-11-20 15:15:49 +02:00
Eunjae LeeandUdit Takkar 1404581f78 feat: add text filter on org member list (#17632)
* feat: improve text filters (WIP)

* move function to bottom

* apply some styles

* fix selection of TextFilterOptions

* rename value to operand

* remove unused file

* merge filters/filters into filters/utils

* fix regression of not putting url params correctly

* move makeWhereClause to filters/utils

* fix negative, empty, and not empty operators

* fix initial filtering from search state (url)

* fix type errors

* do not send an empty array to query

* update yarn.lock

* i18n for text filter operators

* extract logic as useColumnFilters()

* add missing import

* fix type error

* revert yarn.lock

* use i18n

* insensitive text match

* move data-table to @calcom/features

* fix type errors

* fix type errors

* fix type errors

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-20 09:38:10 +00:00
Anwar Sadath ff18b9a733 chore: Add support for new docker compose command to run V2 API (#17744) 2024-11-20 08:47:37 +00:00
Hariom BalharaandCarinaWolli 82e52a1948 feat: Attribute filter/Segment for Round Robin hosts (#17361)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-11-20 10:22:53 +05:30
Vijay e3abd35ba7 fix: upgrade prismock to 1.33.4 (#17735)
* upgrade prismock, removed 2 hacks, update 3 tests

* yarnlock changes
2024-11-19 23:20:27 +00:00
sean-brydon dfdb04acf4 chore: routing table nits (#17738)
* portal date preset selection with a high zindex

* increase height of data-table

* skeleton fixes
2024-11-19 23:04:45 +00:00
Omar López 352a6ba8cf fix: Better error handling for user session (#17753) 2024-11-19 22:57:37 +00:00
Eunjae Lee 997d6a75e8 fix: fix wrong dates on OOO edit dialog (#17736)
* fix: fix wrong dates on OOO edit dialog

* do not use utc() on the browser side
2024-11-19 19:54:12 -03:00
NizzyandKeith Williams 757e3a2faf fix: PostHog event tracking (#17653)
* posthog provider fix

* posthog fixes w/ trpc changes

* Update yarn.lock

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-19 22:52:18 +00:00
Omar López 1b457f82a1 chore: v4.7.5 (#17750)
release.yml / release (push) Canceled after 0s
2024-11-19 20:44:58 +00:00
Omar López 16573717b4 fix: identify org-wide features per user (#17010) 2024-11-19 15:21:38 -05:00
Omar LópezandAnik Dhabal Babu 97b920744e feat: adds last active timestamp (#17261)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-20 00:38:20 +05:30
Joe Au-Yeung 6cfe9ff88d feat: Salesforce: Write booking question values to attendee record in Salesforce [CAL-4731] (#17650) 2024-11-19 10:30:34 -07:00
Anik Dhabal Babu 7c5292e13b fix (#17734) 2024-11-19 18:37:39 +05:30
Morgan 3cd4e34406 fix: platform libraries build vite.config (#17743) 2024-11-19 12:05:28 +00:00
sean-brydon a99eda21af chore: booking status reporting (#17733)
* move booking status into new column

* update i18n

* nowrap + badge for submitedAt to provide consistant feel to other dates
2024-11-19 11:24:45 +00:00
Omar López 03e2c6278d fix: cron api key checks (#17730) 2024-11-19 00:18:53 +00:00
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
Keith Williams 1df70e75ed chore: v4.7.0 (#17664)
release.yml / release (push) Canceled after 0s
2024-11-15 20:11:00 -03:00
Udit Takkar 8962f6f08e fix: update current cursor (#17626) 2024-11-15 16:07:17 -07:00
Keith Williams 6272c881c8 revert: "feat: Populate gCal calendar cache via webhooks (#11928)" (#17675)
This reverts commit d294a74aad.
2024-11-15 19:28:30 -03:00
Omar López bdf94c7f48 fix: removed calendar cache admin page (#17676) 2024-11-15 21:46:08 +00:00
b79c5e7c86 feat: update translations via @replexica (#17674)
* feat: update translations via @replexica

* feat: update translations via @replexica

* Delete packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.handler.ts

* Delete packages/app-store/routing-forms/trpc/findTeamMembersMatchingAttributeLogic.schema.ts

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-15 21:34:07 +00:00
3eaccb8738 fix: Preview queue position and contact owner in preview (#17552)
* wip

* Add preview mode in router

* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

* WIP

* remove comment

* update migrations

* get attributes weights and virtual queue data

* use attribute weights and use bookings of virtual queue only

* clean up migrations

* Add shortfall column and add tests

* code clean up from feedback

* wrapper function for getLuckyUser

* code clean up

* fetch routingFormResponse in handleNewBooking

* fix type errors

* fix type errors in tests

* fix getAttributesQueryValue import for tests

* fix totalWeight

* add test for attributes weights and virtual queues

* clean up code

* add test for prepareQueuesAndAttributesData

* remove console.log

* use lazy import

* Add more tests and more columns to matching members queue

* fix issue from merge

* always send usersAndTheirBookingShortfalls

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-15 20:39:46 +00:00
Anik Dhabal Babu 9273226fb4 test: Add test to validate error when 'in' field in where clause is undefine and contain null values (#17670) 2024-11-15 19:55:34 +00:00
sean-brydonandJoe Au-Yeung 7ce5886b4f feat: insights routing forms (#17583)
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-11-15 14:54:51 -05:00
Anik Dhabal Babu e85c0e1a85 fix: unable to lock individual event type creation (#17668) 2024-11-15 15:58:19 -03:00
Omar LópezandUdit Takkar d294a74aad feat: Populate gCal calendar cache via webhooks (#11928)
* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* WIP

* Update CalendarService.ts

* Type fixes

* WIP

* fix: improve cache hits

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Update CalendarService.ts

* Feedback

* Update CalendarService.ts

* Update CalendarService.ts

* Update _router.ts

* feedback

* WIP

* WIP

* Update schema.prisma

* feedback

* typefixes

* Update Calendar.d.ts

* fix: watches when adding a calendar

* Discard changes to packages/app-store/googlecalendar/api/add.ts

* WIP

Signed-off-by: Omar López <zomars@me.com>

* WIP

Signed-off-by: Omar López <zomars@me.com>

* WP

Signed-off-by: Omar López <zomars@me.com>

* Update calendar.ts

* Update calendar.ts

* Update callback.ts

* Update callback.ts

* Conflicts

* WIP

* WIP

* Update CalendarService.ts

* Cleanup

* Discard changes to packages/features/settings/layouts/SettingsLayout.tsx

* Update calendar-cache.repository.ts

* WIP

* Update getSelectedCalendarsToWatch.sql

* WIP

* Update CalendarService.ts

* Cleanup

* Discard changes to packages/app-store/googlecalendar/lib/CalendarService.ts

* Create CalendarService.wip.ts

* WIP

* Update CalendarService.ts

* Update getSelectedCalendarsToWatch.sql

* Delete CalendarService.wip.ts

* test updates

* cleanup

Signed-off-by: Omar López <zomars@me.com>

* Update CalendarService.test.ts

* Update CalendarService.ts

* type fixes

* Update OAuthManager.ts

* Update CalendarService.ts

* Almost there

* Update CalendarService.test.ts

* Update calendar.ts

* Update callback.ts

* Update toggleFeatureFlag.handler.ts

* fix: feedback

* Update getSelectedCalendarsToWatch.sql

* Fix unit tests

---------

Signed-off-by: Omar López <zomars@me.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-15 14:59:37 -03:00
5012a16794 feat: attribute weights and virtual queues (#17567)
* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

* WIP

* remove comment

* update migrations

* get attributes weights and virtual queue data

* use attribute weights and use bookings of virtual queue only

* clean up migrations

* code clean up from feedback

* wrapper function for getLuckyUser

* code clean up

* fetch routingFormResponse in handleNewBooking

* fix type errors

* fix type errors in tests

* fix getAttributesQueryValue import for tests

* fix totalWeight

* add test for attributes weights and virtual queues

* clean up code

* add test for prepareQueuesAndAttributesData

* remove console.log

* use lazy import

* use findUnique

* add fallback weight

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-15 17:36:46 +00:00
Calcom BotandReplexica 322ca2a12c feat: update translations via @replexica (#17666)
* feat: update translations via @replexica

* feat: update translations via @replexica

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-11-15 16:50:57 +00:00
Udit Takkar 9b79c037c2 fix: remove cal video logo from select (#17665)
* fix: remove cal video logo from managed event types

* fix: do not fetch cal video logo
2024-11-15 16:45:44 +00:00
Alex van Andel f76696032e feat: Implements maxLeadThreshold (#17643)
* feat: Implements maxLeadThreshold which puts a maximum to the amount of lead a given host is allowed to be ahead

* Refactor getLuckyUser + implement in handleNewBooking + move logic to repo

* Decoupled @prisma from findQualifiedHosts

* Fix deep type error

* Add maxLeadThreshold to builder.ts

* Removed a console.log :O + fix handleChildrenEventTypes input

* Add distribution UI to enable balanced distribution/maximize availability

* 0-1-2 instead of 0-1-2-3 (3 bookings instead of 4)
2024-11-15 15:54:02 +00:00
Udit Takkar c6aa57177a fix: filter cal video logo (#17663)
* fix: filter cal video logo

* chore: message
2024-11-15 14:38:28 +00:00
Anik Dhabal Babu 7f2db48c72 fix: meeting not created on hubspot for contacts (#17661) 2024-11-15 13:18:17 +00:00
c59e733b5a feat: exclude emails and domains (#17591)
* feat: add constants

* feat: add excludeEmails field in baseSchema

* feat: add ExcludeEmails Input field in FieldEditDialog

* feat: add excludedEmails check in FormBuilderField

* chore: move validations in getBookingResponsesSchema

* Update i18n string to be more generic

* Update apps/web/public/static/locales/en/common.json

* feat: add validation on excludeEmails field

* feat: add fieldSchema as resolver

* chore: remove comment

* chore: format comments

* feat: add tests for excluded email or domains

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-15 10:46:25 +00:00
sean-brydon e0713d78b3 fix: z index issue on dropdown menu (#17656)
* portal content

* fix(dropdown-z-index-issue): Fixes dropdown z index issue by portaling
2024-11-15 10:51:39 +01:00
Max Prilutskiy d868b325c4 feat(CAL-4730): add sk-SK language (#17652) 2024-11-15 09:41:36 +00:00
Keith Williams f8f6c31e40 chore: v4.6.24 (#17639)
release.yml / release (push) Canceled after 0s
2024-11-14 18:06:09 -03:00
Anik Dhabal Babu 7246fa4084 fix: unable to update host schedules (#17649) 2024-11-14 15:37:19 -05:00
Benny Joo 039a0b7a85 chore: remove wrong logs (#17648) 2024-11-14 18:57:51 +00:00
sean-brydon 12c5005305 chore: routingform org attribute seed (#17510)
* seed to org

* more org+routing form seed improvments

* add routing responses

* check if attributes already exist to prevent crash

* remove files not for PR

* fix type error

* fix connection

* assign manually

* fix seed

* update to match integration tests

* fix integration test to new user count
2024-11-14 14:50:26 -03:00
Sachin Siddhuandunknown 1ff6465384 fix: resolved troubleshoot-sidbar bug (#17613)
* resolved troubleshoot-sidbar bug

* fulfilled review changes

---------

Co-authored-by: unknown <sachinsiddhu112@gmail.com>
2024-11-14 13:26:55 -03:00
ae706bb6d6 feat: update translations via @replexica (#17629)
* feat: update translations via @replexica

* feat: update translations via @replexica

* Delete packages/ui/components/data-table/tanstack.d.ts

* Delete packages/lib/sync/services/CloseComService.ts

* Delete packages/lib/sync/SyncServiceManager.ts

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-14 16:19:03 +00:00
Lauris Skraucis 538a39328e fix: useOAuthClient is not initialized (#17637) 2024-11-14 15:35:45 +00:00
Joe Au-Yeung 3589767099 feat: Salesforce when create contacts under an account if creating leads is chosen [CAL-4713] (#17630) 2024-11-14 10:29:21 -05:00
Kartik SainiandUdit Takkar ff2754f663 feat: allow marking no show after booking has started (#17625)
* feat: allow marking no show after booking has started

* fix

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-14 09:55:39 +00:00
Rajiv Sahal 65484e7d01 fix: BookerEmbed atom localStorage issues (#17611) 2024-11-14 08:07:47 +00:00
Peer Richelsen 4d942a454a chore: typo in webhook (#17628) 2024-11-14 02:57:03 +00:00
Keith WilliamsandUdit Takkar 7dbcb62724 perf: Move /me stats to separate tRPC handler (#17206)
* perf: Move /me stats to separate tRPC handler

* Update packages/trpc/server/routers/loggedInViewer/meStats.handler.ts

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* Rename and skipping batch for myStats

* Refactor of counting team events

* Fixed type issues

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-13 20:55:57 -03:00
Benny Joo c197c33fd0 chore: upgrade @vercel/og lib (#17503)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- housekeeping: having important packages up to date

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] N/A - I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- 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-13 13:50:21 -07:00
Keith Williams 815065d67c feat: Add API v2 logs to axiom (#17595)
## What does this PR do?

Adds ability to send API v2 logs to Axiom.

## Mandatory Tasks (DO NOT REMOVE)

- [x] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [x] N/A - I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [x] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

- Add the AXIOM_DATASET and AXIOM_TOKEN variables and run the API locally. Ensure the logs flow through to Axiom.
2024-11-13 13:18:53 -07:00
Omar López 02cecd6707 chore: Cleanup Close.com (#16929) 2024-11-13 15:14:31 -05:00
Keith Williams 81386d4ec0 chore: v4.6.23 (#17620)
release.yml / release (push) Canceled after 0s
2024-11-13 14:40:22 -03:00
Eunjae LeeandKeith Williams 204e411585 fix: make actions column sticky in members list (#17554)
* fix: make actions column sticky in members list

* clean up import

* change type definition

* remove old export

* include tanstack.d.ts

* fix declaration file

* move type def to packages/types/tanstack-table.d.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-13 16:36:01 +00:00
Chiranjeev VishnoiandAnik Dhabal Babu 1ec1554cb5 change telly url (#17618)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-13 16:21:53 +00:00
Udit Takkar 241579eba6 refactor: change title (#17623)
* chore: change title

* chore: translation
2024-11-13 16:19:58 +00:00
Udit Takkar 8ccad29ab6 fix: skip triggering webhook in case of duplicate email (#17612)
* fix: skip sending webhook in case of duplicate response

* fix: type err

* fix: tests

* refactor: only check email

* fix: type err

* chore: changed it to 60 min

* fix: use lowercase
2024-11-13 15:50:26 +00:00
74b4d7a94a chore: Automated API v2 OpenAPI spec generation (#17046)
* chore: Automated API v2 OpenAPI spec generation

* chore: update docs

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-11-13 15:28:25 +00:00
5c6e60b726 fix: fetch next batch if event types are empty and cursor exists (#17109)
* fix: fetch next batch if event types are empty and cursor exists

* refactor: improve

* fix: add isFetchingForFirstTime

* chore: add number type

* fix: type err

* chore: add logs

* fix: type err

* fix: conflict

* fix: bug

* fix: type err

* chore: try changing type

* chore: add nul

* Update packages/trpc/server/routers/viewer/eventTypes/getEventTypesFromGroup.handler.ts

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* chore: use log.info

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-11-13 14:37:03 +00:00
Alex van Andel 18c7c3c940 fix: Refactor publicPageUrl to use getBookerBaseUrlSync like elsewhere (#17607) 2024-11-13 08:55:01 -05:00
fbd113b976 feat: update translations via @replexica (#17619)
* feat: update translations via @replexica

* feat: update translations via @replexica

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-13 13:53:51 +00:00
Udit Takkar c7f11c6795 fix: mark guest are no show (#17588)
* fix: mark guest are no show

* chore: missing import

* nit

* fix: type err
2024-11-13 19:03:55 +05:30
Peer Richelsen 84fb9bb371 chore: update Instant Meeting Error Message (#17617)
* Update common.json

* small copy changes
2024-11-13 13:08:05 +00:00
Lauris Skraucis 1aac70f643 feat: v2 confirm / decline booking endpoints (#17578)
* feat: v2 confirm / decline booking endpoints

* chore: bump platform libraries

* fix: type export

* tests: accepting and declining a booking

* refactor: sent emails abstract renderEmail with getHtml that is mocked in test
2024-11-13 11:20:43 +02:00
Lauris Skraucis 0dde8c1db9 feat: v2 multiple length event types and their bookings (#17598)
* feat: input lengthInMinutesOptions

* Revert "feat: input lengthInMinutesOptions"

This reverts commit f23fd8a3861097f0972cc6210b47f25511d85183.

* feat: handle lengthInMinutesOptions input

* feat: handle lengthInMinutesOptions event output

* fix: remove it.only

* feat: variable length bookings
2024-11-13 11:13:11 +02:00
Ankit YadavandOmar López 0da3a9fa11 fix: #17602 (#17604)
Co-authored-by: Omar López <zomars@me.com>
2024-11-12 20:20:38 +00:00
508 changed files with 39366 additions and 13838 deletions
+9
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=
@@ -143,6 +147,8 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
# Used for capturing exceptions and logging messages
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DEBUG=
SENTRY_DISABLE_CLIENT_SOURCE_MAPS=
SENTRY_DISABLE_SERVER_SOURCE_MAPS=
SENTRY_MAX_SPANS=
SENTRY_TRACES_SAMPLE_RATE=
@@ -408,3 +414,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=
+50
View File
@@ -0,0 +1,50 @@
name: Draft release
run-name: Draft release ${{ inputs.next_version }}
on:
workflow_dispatch:
inputs:
next_version:
required: true
type: string
description: 'Version name'
permissions:
contents: write
jobs:
draft_release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: 'main'
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
- uses: ./.github/actions/yarn-install
- name: Bump version
run: |
cd apps/web
yarn version ${{ inputs.next_version }}
- name: Commit changes
run: |
git add .
git commit -m "chore: release v${{ inputs.next_version }}"
git push
- name: Draft release
run: gh release create v$VERSION --generate-notes --draft
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ inputs.next_version }}
-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
@@ -0,0 +1,21 @@
name: Changes Requested
on:
pull_request_review:
types: [submitted]
jobs:
changes-requested:
if: github.event.review.state == 'changes_requested'
runs-on: ubuntu-latest
steps:
- name: Explanation
run: echo "This workflow triggers a workflow_run; it's necessary because otherwise the repo secrets aren't available for 'pull_request_review' events from externally forked pull requests"
- name: Save PR number to context.json
run: |
printf '{
"pr_number": ${{ github.event.pull_request.number }}
}' >> context.json
- uses: actions/upload-artifact@v3
with:
name: context.json
path: ./
+25
View File
@@ -0,0 +1,25 @@
name: Post release
on:
workflow_dispatch:
push:
# Pattern matched against refs/tags
tags:
- "*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
ref: 'main'
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Configure git
run: |
git config --local user.email "github-actions@github.com"
git config --local user.name "GitHub Actions"
- run: git push origin +main:production
+104
View File
@@ -0,0 +1,104 @@
name: Re-draft
on:
workflow_run:
workflows:
- Changes Requested
types:
- completed
permissions:
contents: write
issues: write
pull-requests: write
statuses: write
jobs:
download-context:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: 'Download artifact'
uses: actions/github-script@v7
with:
script: |
let allArtifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: context.payload.workflow_run.id,
});
let matchArtifact = allArtifacts.data.artifacts.filter((artifact) => {
return artifact.name == "context.json"
})[0];
let download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: matchArtifact.id,
archive_format: 'zip',
});
let fs = require('fs');
fs.writeFileSync(`${process.env.GITHUB_WORKSPACE}/context.zip`, Buffer.from(download.data));
- name: 'Unzip artifact'
run: unzip context.zip
- name: 'Return Parsed JSON'
uses: actions/github-script@v7
id: return-parsed-json
with:
script: |
let fs = require('fs');
let data = fs.readFileSync('./context.json');
return JSON.parse(data);
outputs:
pr_number: ${{fromJSON(steps.return-parsed-json.outputs.result).pr_number}}
re-draft:
needs:
- download-context
permissions:
contents: write
issues: write
pull-requests: write
statuses: write
runs-on: ubuntu-latest
steps:
- run: echo "This PR was rejected"
- name: Convert PR to draft when changes are requested
uses: actions/github-script@v7
with:
script: |
async function getPullRequestId() {
const pull_number = ${{ needs.download-context.outputs.pr_number }};
const pullRequest = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number,
});
if (!pullRequest.data.node_id) throw new Error(`pullRequestId no found for '${pull_number}'`);
return pullRequest.data.node_id;
}
const query = `
mutation($id: ID!) {
convertPullRequestToDraft(input: { pullRequestId: $id }) {
pullRequest {
id
number
isDraft
}
}
}
`;
const pullRequestId = await getPullRequestId();
const variables = {
id: pullRequestId,
}
const response = await github.graphql(query, variables)
if (!response.convertPullRequestToDraft) {
throw new Error("Failed to convert pull request to draft");
}
console.info("Pull request successfully converted to draft.");
console.info(`Draft conversion response: ${JSON.stringify(response, null, 2)}`);
-17
View File
@@ -1,17 +0,0 @@
on:
workflow_dispatch:
push:
# Pattern matched against refs/tags
tags:
- "*" # Push events to every tag not containing /
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: main # Always checkout main even for tagged releases
fetch-depth: 0
token: ${{ secrets.GH_ACCESS_TOKEN }}
- run: git push origin +main:production
File diff suppressed because one or more lines are too long
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+2
View File
@@ -5,5 +5,7 @@ plugins:
spec: "@yarnpkg/plugin-interactive-tools"
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
- path: .yarn/plugins/@yarnpkg/plugin-version.cjs
spec: "@yarnpkg/plugin-version"
yarnPath: .yarn/releases/yarn-3.4.1.cjs
@@ -81,7 +81,7 @@ describe("retrieveScopedAccessibleUsers tests", () => {
where: { email: "member2-acme@example.com" },
});
expect(accessibleUserIds.length).toBe(3);
expect(accessibleUserIds.length).toBe(11);
expect(accessibleUserIds).toContain(memberOneUser.id);
expect(accessibleUserIds).toContain(memberTwoUser.id);
expect(accessibleUserIds).toContain(adminUser.id);
+4
View File
@@ -28,3 +28,7 @@ API_KEY_PREFIX=cal_
GET_LICENSE_KEY_URL="https://console.cal.com/api/license"
IS_E2E=false
DOCS_URL=
# Axiom logging
AXIOM_DATASET=
AXIOM_TOKEN=
+1 -1
View File
@@ -10,7 +10,7 @@
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 2
+6 -3
View File
@@ -11,7 +11,7 @@
"start": "nest start",
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-enums build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build ",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"dev": "yarn dev:build && ts-node scripts/docker-start.ts && yarn copy-swagger-module && yarn start --watch",
"dev:no-docker": "yarn dev:build && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node ./dist/apps/api/v2/src/main.js",
@@ -26,9 +26,10 @@
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts"
},
"dependencies": {
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.58",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.63",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -69,7 +70,8 @@
"rxjs": "^7.8.1",
"stripe": "^15.3.0",
"uuid": "^8.3.2",
"winston": "^3.11.0",
"winston": "^3.13.0",
"winston-transport": "^4.7.0",
"zod": "^3.22.4"
},
"devDependencies": {
@@ -85,6 +87,7 @@
"@types/passport-jwt": "^3.0.13",
"@types/supertest": "^2.0.12",
"jest": "^29.7.0",
"jest-date-mock": "^1.0.10",
"prettier": "^2.8.6",
"source-map-support": "^0.5.21",
"supertest": "^6.3.3",
+35
View File
@@ -0,0 +1,35 @@
import { execSync } from "child_process";
function checkCommandExists(command: string): boolean {
try {
execSync(`${command} --version`, { stdio: "ignore" });
return true;
} catch (e) {
return false;
}
}
try {
// Check if docker is installed
if (!checkCommandExists("docker")) {
throw new Error("Docker is not installed");
}
// Try docker compose first (new syntax)
try {
execSync("docker compose version", { stdio: "ignore" });
console.log("Starting containers with docker compose...");
execSync("docker compose up -d", { stdio: "inherit" });
} catch (e) {
// Fall back to docker-compose if the above fails
if (checkCommandExists("docker-compose")) {
console.log("Starting containers with docker-compose...");
execSync("docker-compose up -d", { stdio: "inherit" });
} else {
throw new Error("Neither 'docker compose' nor 'docker-compose' command is available");
}
}
} catch (error) {
console.error(`Error: ${error instanceof Error ? error.message : "Unknown error occurred"}`);
process.exit(1);
}
@@ -333,7 +333,7 @@ export class BookingsController_2024_04_15 {
}
private async getOAuthClientsParams(clientId: string): Promise<OAuthRequestParams> {
const res = DEFAULT_PLATFORM_PARAMS;
const res = { ...DEFAULT_PLATFORM_PARAMS };
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
// fetch oAuthClient from db and use data stored in db to set these values
@@ -1,160 +0,0 @@
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 { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.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 { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("With api key", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let apiKeyString: string;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let eventTypeId: 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);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
user = await userRepositoryFixture.create({
email: userEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = keyString;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: "peer-coding", length: 60 },
user.id
);
eventTypeId = event.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
describe("create bookings", () => {
it("should create a booking with api key", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Key",
email: "mr_key@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.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(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(eventTypeId);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -53,6 +53,7 @@ import {
CreateBookingInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
DeclineBookingInput_2024_08_13,
} from "@calcom/platform-types";
@Controller({
@@ -190,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,
@@ -281,4 +287,51 @@ export class BookingsController_2024_08_13 {
data: booking,
};
}
@Post("/:bookingUid/confirm")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Confirm booking that requires a confirmation" })
async confirmBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: UserWithProfile
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.confirmBooking(bookingUid, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/decline")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Decline booking that requires a confirmation" })
async declineBooking(
@Param("bookingUid") bookingUid: string,
@Body() body: DeclineBookingInput_2024_08_13,
@GetUser() user: UserWithProfile
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.declineBooking(bookingUid, user, body.reason);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
}
@@ -0,0 +1,291 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
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 { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.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 {
AttendeeScheduledEmail,
OrganizerScheduledEmail,
AttendeeRescheduledEmail,
OrganizerRescheduledEmail,
AttendeeCancelledEmail,
OrganizerCancelledEmail,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
jest.spyOn(AttendeeScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest
.spyOn(AttendeeRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
describe("Bookings Endpoints 2024-08-13", () => {
describe("With api key", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let apiKeyString: string;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let eventTypeId: number;
let createdBooking: BookingOutput_2024_08_13;
let rescheduledBooking: BookingOutput_2024_08_13;
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);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
user = await userRepositoryFixture.create({
email: userEmail,
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = keyString;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: "peer-coding", length: 60 },
user.id
);
eventTypeId = event.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
it("should create a booking with api key", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Key",
email: "mr_key@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.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(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(eventTypeId);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
createdBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should reschedule a booking with api key", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking.uid}/reschedule`)
.send(body)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.reschedulingReason).toEqual(body.reschedulingReason);
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2035, 0, 8, 15, 0, 0)).toISOString());
expect(data.rescheduledFromUid).toEqual(createdBooking.uid);
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual(createdBooking.status);
expect(data.duration).toEqual(createdBooking.duration);
expect(data.eventTypeId).toEqual(createdBooking.eventTypeId);
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
expect(data.location).toEqual(createdBooking.location);
expect(data.absentHost).toEqual(createdBooking.absentHost);
expect(data.metadata).toEqual(createdBooking.metadata);
expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
rescheduledBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should cancel booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
const booking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
expect(booking).toBeDefined();
expect(booking?.status).toEqual("ACCEPTED");
return request(app.getHttpServer())
.post(`/v2/bookings/${rescheduledBooking.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("cancelled");
expect(data.cancellationReason).toEqual(body.cancellationReason);
expect(data.start).toEqual(rescheduledBooking.start);
expect(data.end).toEqual(rescheduledBooking.end);
expect(data.duration).toEqual(rescheduledBooking.duration);
expect(data.eventTypeId).toEqual(rescheduledBooking.eventTypeId);
expect(data.attendees[0]).toEqual(rescheduledBooking.attendees[0]);
expect(data.location).toEqual(rescheduledBooking.location);
expect(data.absentHost).toEqual(rescheduledBooking.absentHost);
const cancelledBooking = await bookingsRepositoryFixture.getByUid(rescheduledBooking.uid);
expect(cancelledBooking).toBeDefined();
expect(cancelledBooking?.status).toEqual("CANCELLED");
expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -0,0 +1,249 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
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 { BookingOutput_2024_08_13, GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Bookings confirmation", () => {
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;
let eventTypeId: number;
const eventTypeSlug = "peer-coding";
let createdBooking1: Booking;
let createdBooking2: Booking;
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,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: eventTypeSlug, length: 60 },
user.id
);
eventTypeId = event.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 be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
it("should confirm a booking", async () => {
const status = "PENDING";
createdBooking1 = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
title: "peer coding",
uid: "peer-coding-one",
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
status,
});
expect(createdBooking1.status).toEqual("PENDING");
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking1.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.uid).toBeDefined();
expect(data.status).toEqual("accepted");
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
expect(dbBooking?.status).toEqual("ACCEPTED");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should decline a booking", async () => {
const status = "PENDING";
createdBooking2 = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 10, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 11, 0, 0)),
title: "peer coding",
uid: "peer-coding-two",
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
status,
});
expect(createdBooking2.status).toEqual("PENDING");
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking2.uid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.uid).toBeDefined();
expect(data.status).toEqual("rejected");
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
expect(dbBooking?.status).toEqual("REJECTED");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
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 responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,474 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
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 {
OrganizerScheduledEmail,
AttendeeScheduledEmail,
OrganizerRescheduledEmail,
AttendeeRescheduledEmail,
OrganizerCancelledEmail,
AttendeeCancelledEmail,
AttendeeRequestEmail,
OrganizerRequestEmail,
AttendeeDeclinedEmail,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
GetBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
jest
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeRequestEmail.prototype, "getHtmlRequestEmail")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRequestEmail.prototype, "getHtmlRequestEmail")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeDeclinedEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
type EmailSetup = {
user: User;
eventTypeId: number;
createdBookingUid: string;
rescheduledBookingUid: string;
};
describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
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 teamRepositoryFixture: TeamRepositoryFixture;
let emailsEnabledSetup: EmailSetup;
let emailsDisabledSetup: EmailSetup;
const authEmail = `admin-${Math.floor(Math.random() * 1000)}@example.com`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
authEmail,
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" });
await setupEnabledEmails();
await setupDisabledEmails();
await userRepositoryFixture.create({
email: authEmail,
organization: {
connect: {
id: organization.id,
},
},
role: "ADMIN",
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function setupEnabledEmails() {
const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true);
const user = await userRepositoryFixture.create({
email: `alice-${Math.floor(Math.random() * 1000)}@gmail.com`,
platformOAuthClients: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: "peer-coding", length: 60, requiresConfirmation: true },
user.id
);
emailsEnabledSetup = {
user,
eventTypeId: event.id,
createdBookingUid: "",
rescheduledBookingUid: "",
};
}
async function setupDisabledEmails() {
const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false);
const user = await userRepositoryFixture.create({
email: `bob-${Math.floor(Math.random() * 1000)}@gmail.com`,
platformOAuthClients: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: "peer-coding", length: 60, requiresConfirmation: true },
user.id
);
emailsDisabledSetup = {
user,
eventTypeId: event.id,
createdBookingUid: "",
rescheduledBookingUid: "",
};
}
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
areEmailsEnabled: emailsEnabled,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
describe("OAuth client managed user bookings - emails disabled", () => {
beforeEach(async () => {
jest.clearAllMocks();
});
it("should not send an email when creating a booking that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(responseBody.data.status).toEqual("pending");
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
emailsDisabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should not send an email when confirming a booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.createdBookingUid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should not send an email when creating a booking that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(responseBody.data.status).toEqual("pending");
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).not.toHaveBeenCalled();
emailsDisabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should not send an email when declining a booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.createdBookingUid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeDeclinedEmail.prototype.getHtml).not.toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
});
describe("OAuth client managed user bookings - emails enabled", () => {
beforeEach(async () => {
jest.clearAllMocks();
});
it("should send an email when creating a booking that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(responseBody.data.status).toEqual("pending");
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should send an email when confirming a booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should send an email when creating a booking that requires confirmation", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(responseBody.data.status).toEqual("pending");
expect(AttendeeRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
expect(OrganizerRequestEmail.prototype.getHtmlRequestEmail).toHaveBeenCalled();
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should send an email when declining a booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeDeclinedEmail.prototype.getHtml).toHaveBeenCalled();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
});
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.user.email);
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.user.email);
await userRepositoryFixture.deleteByEmail(authEmail);
await bookingsRepositoryFixture.deleteAllBookings(
emailsEnabledSetup.user.id,
emailsEnabledSetup.user.email
);
await bookingsRepositoryFixture.deleteAllBookings(
emailsDisabledSetup.user.id,
emailsDisabledSetup.user.email
);
await app.close();
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,979 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { createParamDecorator, 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 { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
OrganizerScheduledEmail,
AttendeeScheduledEmail,
OrganizerRescheduledEmail,
AttendeeRescheduledEmail,
OrganizerCancelledEmail,
AttendeeCancelledEmail,
OrganizerReassignedEmail,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
jest
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerReassignedEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
type EmailSetup = {
team: Team;
member1: User;
member2: User;
collectiveEventType: {
id: number;
createdBookingUid: string;
rescheduledBookingUid: string;
};
roundRobinEventType: {
id: number;
createdBookingUid: string;
rescheduledBookingUid: string;
currentHostId: number;
};
};
describe("Bookings Endpoints 2024-08-13 team emails", () => {
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 teamRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let emailsEnabledSetup: EmailSetup;
let emailsDisabledSetup: EmailSetup;
const authEmail = "admin@example.com";
beforeAll(async () => {
const moduleRef = await withApiAuth(
authEmail,
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);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
await setupEnabledEmails();
await setupDisabledEmails();
await userRepositoryFixture.create({
email: authEmail,
organization: {
connect: {
id: organization.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function setupEnabledEmails() {
const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true);
const team = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
const member1 = await userRepositoryFixture.create({
email: "alice@gmail.com",
platformOAuthClients: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
const member2 = await userRepositoryFixture.create({
email: "bob@gmail.com",
platformOAuthClients: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(member1.id, userSchedule);
await schedulesService.createUserSchedule(member2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${member1.id}`,
username: member1.email,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: member1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${member2.id}`,
username: member2.email,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: member2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: member1.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: member2.id } },
team: { connect: { id: team.id } },
accepted: true,
});
const collectiveEvent = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: member1.id,
},
},
eventType: {
connect: {
id: collectiveEvent.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: member2.id,
},
},
eventType: {
connect: {
id: collectiveEvent.id,
},
},
});
const roundRobinEvent = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "Round Robin Event Type",
slug: "round-robin-event-type",
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [],
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: member1.id,
},
},
eventType: {
connect: {
id: roundRobinEvent.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: member2.id,
},
},
eventType: {
connect: {
id: roundRobinEvent.id,
},
},
});
emailsEnabledSetup = {
team,
member1: member1,
member2: member2,
collectiveEventType: {
id: collectiveEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
},
roundRobinEventType: {
id: roundRobinEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
currentHostId: 0,
},
};
}
async function setupDisabledEmails() {
const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false);
const team = await teamRepositoryFixture.create({
name: "team 2",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
const member1 = await userRepositoryFixture.create({
email: "charlie@gmail.com",
platformOAuthClients: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
const member2 = await userRepositoryFixture.create({
email: "dean@gmail.com",
platformOAuthClients: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(member1.id, userSchedule);
await schedulesService.createUserSchedule(member2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${member1.id}`,
username: member1.email,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: member1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${member2.id}`,
username: member2.email,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: member2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: member1.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: member2.id } },
team: { connect: { id: team.id } },
accepted: true,
});
const collectiveEvent = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: member1.id,
},
},
eventType: {
connect: {
id: collectiveEvent.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: member2.id,
},
},
eventType: {
connect: {
id: collectiveEvent.id,
},
},
});
const roundRobinEvent = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
title: "Round Robin Event Type",
slug: "round-robin-event-type",
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [],
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: member1.id,
},
},
eventType: {
connect: {
id: roundRobinEvent.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: member2.id,
},
},
eventType: {
connect: {
id: roundRobinEvent.id,
},
},
});
emailsDisabledSetup = {
team,
member1: member1,
member2: member2,
collectiveEventType: {
id: collectiveEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
},
roundRobinEventType: {
id: roundRobinEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
currentHostId: 0,
},
};
}
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
areEmailsEnabled: emailsEnabled,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
beforeEach(async () => {
jest.clearAllMocks();
});
describe("OAuth client team bookings - emails disabled", () => {
describe("book", () => {
it("should not send an email when booking collective event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.collectiveEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.collectiveEventType.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should not send an email when booking round robin event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.roundRobinEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.createdBookingUid = responseBody.data.uid;
emailsDisabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
describe("reschedule", () => {
it("should not send an email when rescheduling collective booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.collectiveEventType.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.collectiveEventType.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should not send an email when rescheduling round robin booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid;
emailsDisabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id;
});
});
});
describe("reassign", () => {
it("should not send an email when manually reassigning round robin booking", async () => {
const reassignToId =
emailsDisabledSetup.roundRobinEventType.currentHostId === emailsDisabledSetup.member1.id
? emailsDisabledSetup.member2.id
: emailsDisabledSetup.member1.id;
return request(app.getHttpServer())
.post(
`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign/${reassignToId}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId;
});
});
it("should not send an email when automatically reassigning round robin booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerReassignedEmail.prototype.getHtml).not.toHaveBeenCalled();
});
});
});
describe("cancel", () => {
it("should not send an email when cancelling a collective booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.collectiveEventType.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
});
});
});
it("should not send an email when cancelling a round robin booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.roundRobinEventType.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
});
});
});
describe("OAuth client team bookings - emails enabled", () => {
beforeEach(async () => {
jest.clearAllMocks();
});
describe("book", () => {
it("should send an email when booking collective event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.collectiveEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.collectiveEventType.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should send an email when booking round robin event", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.roundRobinEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.roundRobinEventType.createdBookingUid = responseBody.data.uid;
emailsEnabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
describe("reschedule", () => {
it("should send an email when rescheduling collective booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 11, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.collectiveEventType.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.collectiveEventType.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should send an email when rescheduling round robin booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 12, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid = responseBody.data.uid;
emailsEnabledSetup.roundRobinEventType.currentHostId = responseBody.data.hosts[0].id;
});
});
});
describe("reassign", () => {
it("should send an email when manually reassigning round robin booking", async () => {
const reassignToId =
emailsEnabledSetup.roundRobinEventType.currentHostId === emailsEnabledSetup.member1.id
? emailsEnabledSetup.member2.id
: emailsEnabledSetup.member1.id;
return request(app.getHttpServer())
.post(
`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign/${reassignToId}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsDisabledSetup.roundRobinEventType.currentHostId = reassignToId;
});
});
it("should send an email when automatically reassigning round robin booking", async () => {
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/reassign`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerReassignedEmail.prototype.getHtml).toHaveBeenCalled();
});
});
});
describe("cancel", () => {
it("should send an email when cancelling a collective booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.collectiveEventType.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
});
});
it("should send an email when cancelling round robin booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.roundRobinEventType.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
});
});
});
});
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.member1.email);
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.member1.email);
await bookingsRepositoryFixture.deleteAllBookings(
emailsEnabledSetup.member1.id,
emailsEnabledSetup.member1.email
);
await bookingsRepositoryFixture.deleteAllBookings(
emailsDisabledSetup.member1.id,
emailsDisabledSetup.member1.email
);
await app.close();
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,482 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
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 { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
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 { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
OrganizerScheduledEmail,
AttendeeScheduledEmail,
OrganizerRescheduledEmail,
AttendeeRescheduledEmail,
OrganizerCancelledEmail,
AttendeeCancelledEmail,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RescheduleBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { CancelBookingInput_2024_08_13 } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
jest
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerScheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerRescheduledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(AttendeeCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
jest
.spyOn(OrganizerCancelledEmail.prototype, "getHtml")
.mockImplementation(() => Promise.resolve("<p>email</p>"));
type EmailSetup = {
user: User;
eventTypeId: number;
recurringEventTypeId: number;
createdBookingUid: string;
rescheduledBookingUid: string;
};
describe("Bookings Endpoints 2024-08-13 user emails", () => {
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 teamRepositoryFixture: TeamRepositoryFixture;
let emailsEnabledSetup: EmailSetup;
let emailsDisabledSetup: EmailSetup;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.overrideGuard(ApiAuthGuard)
.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" });
await setupEnabledEmails();
await setupDisabledEmails();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function setupEnabledEmails() {
const oAuthClientEmailsEnabled = await createOAuthClient(organization.id, true);
const user = await userRepositoryFixture.create({
email: `alice-${Math.floor(Math.random() * 1000)}@gmail.com`,
platformOAuthClients: {
connect: {
id: oAuthClientEmailsEnabled.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: "peer-coding", length: 60 },
user.id
);
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: 3, interval: 1 },
},
user.id
);
emailsEnabledSetup = {
user,
eventTypeId: event.id,
recurringEventTypeId: recurringEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
};
}
async function setupDisabledEmails() {
const oAuthClientEmailsDisabled = await createOAuthClient(organization.id, false);
const user = await userRepositoryFixture.create({
email: `bob-${Math.floor(Math.random() * 1000)}@gmail.com`,
platformOAuthClients: {
connect: {
id: oAuthClientEmailsDisabled.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: "peer-coding", length: 60 },
user.id
);
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: 3, interval: 1 },
},
user.id
);
emailsDisabledSetup = {
user,
eventTypeId: event.id,
recurringEventTypeId: recurringEvent.id,
createdBookingUid: "",
rescheduledBookingUid: "",
};
}
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
areEmailsEnabled: emailsEnabled,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
beforeEach(async () => {
jest.clearAllMocks();
});
describe("OAuth client managed user bookings - emails disabled", () => {
it("should not send an email when creating a booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should not send an email when creating a recurring booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(),
eventTypeId: emailsDisabledSetup.recurringEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsRecurringBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
} else {
throw new Error("Invalid response data - expected booking array but received single booking");
}
});
});
it("should not send an email when rescheduling a booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
emailsDisabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should not send an email when cancelling a booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsDisabledSetup.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).not.toHaveBeenCalled();
});
});
});
describe("OAuth client managed user bookings - emails enabled", () => {
it("should send an email when creating a booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should send an email when creating a recurring booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 9, 0, 0)).toISOString(),
eventTypeId: emailsEnabledSetup.recurringEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
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);
if (responseDataIsRecurringBooking(responseBody.data)) {
expect(AttendeeScheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).toHaveBeenCalled();
} else {
throw new Error("Invalid response data - expected booking array but received single booking");
}
});
});
it("should send an email when rescheduling a booking", async () => {
const body: RescheduleBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2035, 0, 8, 14, 0, 0)).toISOString(),
reschedulingReason: "Flying to mars that day",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.createdBookingUid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(OrganizerScheduledEmail.prototype.getHtml).not.toHaveBeenCalled();
expect(AttendeeRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerRescheduledEmail.prototype.getHtml).toHaveBeenCalled();
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
});
});
it("should send an email when cancelling a booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
cancellationReason: "Going on a vacation",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${emailsEnabledSetup.rescheduledBookingUid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: CancelBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(AttendeeCancelledEmail.prototype.getHtml).toHaveBeenCalled();
expect(OrganizerCancelledEmail.prototype.getHtml).toHaveBeenCalled();
});
});
});
afterAll(async () => {
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(emailsEnabledSetup.user.email);
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.user.email);
await bookingsRepositoryFixture.deleteAllBookings(
emailsEnabledSetup.user.id,
emailsEnabledSetup.user.email
);
await bookingsRepositoryFixture.deleteAllBookings(
emailsDisabledSetup.user.id,
emailsDisabledSetup.user.email
);
await app.close();
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
function responseDataIsRecurringBooking(data: any): data is RecurringBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
});
@@ -1,6 +1,5 @@
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 { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-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";
@@ -25,13 +24,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", () => {
@@ -86,14 +79,19 @@ describe("Bookings Endpoints 2024-08-13", () => {
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
oAuthClient = await createOAuthClient(organization.id);
team = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
oAuthClient = await createOAuthClient(organization.id);
teamUser1 = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
@@ -0,0 +1,706 @@
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,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
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,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
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,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
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();
});
});
});
@@ -93,6 +93,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
@@ -163,6 +168,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "100",
},
};
return request(app.getHttpServer())
@@ -205,6 +213,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
@@ -230,6 +239,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
bookingFieldsResponses: {
codingLanguage: "Rust",
},
metadata: {
userId: "200",
},
};
return request(app.getHttpServer())
@@ -274,6 +286,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: createdSeatedBooking.attendees[0].name,
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
},
metadata: createdSeatedBooking.attendees[0].metadata,
});
const secondAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeTwo);
expect(secondAttendee).toEqual({
@@ -286,6 +299,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
@@ -314,6 +328,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
codingLanguage: "TypeScript",
},
recurrenceCount,
metadata: {
userId: "300",
},
};
return request(app.getHttpServer())
@@ -359,6 +376,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
@@ -392,6 +410,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
@@ -565,6 +584,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: attendee?.name,
...attendee?.bookingFieldsResponses,
},
metadata: attendee?.metadata,
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
@@ -697,6 +717,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
@@ -769,6 +794,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "1000",
},
recurrenceCount,
};
@@ -815,6 +843,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
@@ -848,6 +877,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
@@ -875,6 +905,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "2000",
},
recurrenceCount,
};
@@ -921,6 +954,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
},
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
});
expect(firstBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
@@ -932,6 +966,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
@@ -965,6 +1000,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
},
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
});
expect(secondBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
@@ -976,6 +1012,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
},
metadata: body.metadata,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
@@ -85,30 +85,50 @@ describe("Bookings Endpoints 2024-08-13", () => {
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
team2 = await teamRepositoryFixture.create({
name: "team 2",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
oAuthClient = await createOAuthClient(organization.id);
teamUser = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
name: "orgUser1team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
teamUser2 = await userRepositoryFixture.create({
email: teamUserEmail2,
locale: "es",
name: "orgUser2team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
@@ -14,6 +14,7 @@ import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { advanceTo, clear } from "jest-date-mock";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
@@ -95,6 +96,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
@@ -178,6 +184,136 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
describe("create bookings", () => {
describe("invalid metadata", () => {
it("should not be able to create a booking with metadata with more than 50 keys", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
key1: "1",
key2: "2",
key3: "3",
key4: "4",
key5: "5",
key6: "6",
key7: "7",
key8: "8",
key9: "9",
key10: "10",
key11: "11",
key12: "12",
key13: "13",
key14: "14",
key15: "15",
key16: "16",
key17: "17",
key18: "18",
key19: "19",
key20: "20",
key21: "21",
key22: "22",
key23: "23",
key24: "24",
key25: "25",
key26: "26",
key27: "27",
key28: "28",
key29: "29",
key30: "30",
key31: "31",
key32: "32",
key33: "33",
key34: "34",
key35: "35",
key36: "36",
key37: "37",
key38: "38",
key39: "39",
key40: "40",
key41: "41",
key42: "42",
key43: "43",
key44: "44",
key45: "45",
key46: "46",
key47: "47",
key48: "48",
key49: "49",
key50: "50",
key51: "51",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should not be able to create a booking with metadata with a key longer than 40 characters", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
aaaaaaaaaabbbbbbbbbbccccccccccdddddddddde: "1",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should not be able to create a booking with metadata with a value longer than 500 characters", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
key: `${"a".repeat(501)}`,
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
});
it("should create a booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
@@ -192,14 +328,19 @@ describe("Bookings Endpoints 2024-08-13", () => {
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
@@ -233,6 +374,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
email: body.attendee.email,
...body.bookingFieldsResponses,
});
// Check createdAt date is between the time of the request and after the request
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
expect(data.metadata).toEqual(body.metadata);
createdBooking = data;
} else {
throw new Error(
@@ -253,6 +401,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
metadata: {
userId: "1000",
},
};
return request(app.getHttpServer())
@@ -288,6 +439,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.recurringBookingUid).toBeDefined();
expect(firstBooking.absentHost).toEqual(false);
expect(firstBooking.metadata).toEqual(body.metadata);
const secondBooking = data[1];
expect(secondBooking.id).toBeDefined();
@@ -307,6 +459,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
expect(secondBooking.metadata).toEqual(body.metadata);
const thirdBooking = data[2];
expect(thirdBooking.id).toBeDefined();
@@ -326,6 +479,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
expect(thirdBooking.location).toEqual(body.location);
expect(thirdBooking.absentHost).toEqual(false);
expect(thirdBooking.metadata).toEqual(body.metadata);
createdRecurringBooking = data;
} else {
@@ -362,6 +516,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
expect(data.location).toEqual(createdBooking.location);
expect(data.absentHost).toEqual(createdBooking.absentHost);
expect(data.createdAt).toEqual(createdBooking.createdAt);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
@@ -835,12 +990,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
reschedulingReason: "Flying to mars that day",
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
@@ -860,6 +1017,13 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
expect(data.location).toEqual(createdBooking.location);
expect(data.absentHost).toEqual(createdBooking.absentHost);
expect(data.metadata).toEqual(createdBooking.metadata);
// When a booking is rescheduled, a new booking is created and the old booking is cancelled.
// We want to make sure the createdAt date of the new booking is between the beforeCreate and afterCreate dates.
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
rescheduledBooking = data;
});
@@ -913,6 +1077,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
expect(data.location).toEqual(createdRecurringBooking[0].location);
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
expect(oldBooking).toBeDefined();
@@ -952,6 +1117,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
describe("mark absent", () => {
beforeAll(() => {
advanceTo(new Date(2035, 0, 9, 15, 0, 0));
});
afterAll(() => {
clear();
});
it("should mark host absent", async () => {
const body: MarkAbsentBookingInput_2024_08_13 = {
host: true,
@@ -1018,6 +1191,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
});
describe("cancel bookings", () => {
it("should cancel booking", async () => {
const body: CancelBookingInput_2024_08_13 = {
@@ -1127,293 +1301,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();
});
});
});
@@ -0,0 +1,253 @@
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 { EventType, 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 { 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", () => {
describe("User 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;
let variableLengthEventType: EventType;
const variableLengthEventTypeSlug = "variable-length-event";
let normalEventType: EventType;
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,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
variableLengthEventType = await eventTypesRepositoryFixture.create(
{
title: "variable length event",
slug: variableLengthEventTypeSlug,
length: 15,
metadata: { multipleDuration: [15, 30, 60] },
},
user.id
);
normalEventType = await eventTypesRepositoryFixture.create(
{ title: "normal event", slug: "normal-event", length: 15 },
user.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 be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
describe("create bookings", () => {
it("should not be able to specify length of booking for non variable length event type", async () => {
const lengthInMinutes = 30;
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: normalEventType.id,
lengthInMinutes,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a booking with default length", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: variableLengthEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
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(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
new Date(Date.UTC(2030, 0, 8, 10, variableLengthEventType.length, 0)).toISOString()
);
expect(data.duration).toEqual(variableLengthEventType.length);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should create a booking with specified length that is not default length", async () => {
const lengthInMinutes = 30;
const body: CreateBookingInput_2024_08_13 = {
lengthInMinutes,
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: variableLengthEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
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(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, lengthInMinutes, 0)).toISOString());
expect(data.duration).toEqual(lengthInMinutes);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
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 responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -20,6 +20,7 @@ import {
roundRobinReassignment,
roundRobinManualReassignment,
handleMarkNoShow,
confirmBookingHandler,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
@@ -306,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);
@@ -371,11 +389,14 @@ export class BookingsService_2024_08_13 {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
const profile = this.usersService.getUserMainProfile(requestUser);
await roundRobinReassignment({
bookingId: booking.id,
orgId: profile?.organizationId || null,
emailsEnabled,
});
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
@@ -386,6 +407,12 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputReassignedBooking(reassigned);
}
async getEmailsEnabled(eventTypeId: number) {
const oAuthParams = await this.inputService.getOAuthClientParams(eventTypeId);
const emailsEnabled = oAuthParams ? oAuthParams.arePlatformEmailsEnabled : true;
return emailsEnabled;
}
async reassignBookingToUser(
bookingUid: string,
newUserId: number,
@@ -402,6 +429,8 @@ export class BookingsService_2024_08_13 {
throw new NotFoundException(`User with id=${newUserId} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
const profile = this.usersService.getUserMainProfile(user);
const reassigned = await roundRobinManualReassignment({
@@ -410,8 +439,56 @@ export class BookingsService_2024_08_13 {
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
emailsEnabled,
});
return this.outputService.getOutputReassignedBooking(reassigned);
}
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
await confirmBookingHandler({
ctx: {
user: requestUser,
},
input: {
bookingId: booking.id,
confirmed: true,
recurringEventId: booking.recurringEventId,
emailsEnabled,
},
});
return this.getBooking(bookingUid);
}
async declineBooking(bookingUid: string, requestUser: UserWithProfile, reason?: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const emailsEnabled = booking.eventTypeId ? await this.getEmailsEnabled(booking.eventTypeId) : true;
await confirmBookingHandler({
ctx: {
user: requestUser,
},
input: {
bookingId: booking.id,
confirmed: false,
recurringEventId: booking.recurringEventId,
reason,
emailsEnabled,
},
});
return this.getBooking(bookingUid);
}
}
@@ -18,7 +18,7 @@ import { NextApiRequest } from "next/types";
import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { EventTypeMetaDataSchema } from "@calcom/platform-libraries";
import {
CancelBookingInput,
CancelBookingInput_2024_08_13,
@@ -32,18 +32,10 @@ import {
RescheduleBookingInput_2024_08_13,
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { EventType, PlatformOAuthClient } from "@calcom/prisma/client";
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
const DEFAULT_PLATFORM_PARAMS = {
platformClientId: "",
platformCancelUrl: "",
platformRescheduleUrl: "",
platformBookingUrl: "",
arePlatformEmailsEnabled: false,
platformBookingLocation: undefined,
};
type OAuthRequestParams = {
platformClientId: string;
platformRescheduleUrl: string;
@@ -90,30 +82,53 @@ export class InputBookingsService_2024_08_13 {
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13
): Promise<BookingRequest> {
const bodyTransformed = await this.transformInputCreateBooking(body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const requestId = request.get("X-Request-Id");
const oAuthClientParams = await this.getOAuthClientParams(body.eventTypeId);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const location = request.body.location || request.body.meetingUrl;
this.logger.log(`createBookingRequest_2024_04_15`, {
requestId,
requestId: request.get("X-Request-Id"),
ownerId: userId,
location,
oAuthClientId,
...oAuthParams,
oAuthClientParams,
});
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
if (oAuthClientParams) {
Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthClientParams.arePlatformEmailsEnabled };
} else {
Object.assign(newRequest, { userId, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: false };
}
return newRequest as unknown as BookingRequest;
}
async getOAuthClientParams(eventTypeId: number) {
const eventType = await this.eventTypesRepository.getEventTypeById(eventTypeId);
let oAuthClient: PlatformOAuthClient | null = null;
if (eventType?.userId) {
oAuthClient = await this.oAuthClientRepository.getByUserId(eventType.userId);
} else if (eventType?.teamId) {
oAuthClient = await this.oAuthClientRepository.getByTeamId(eventType.teamId);
}
if (oAuthClient) {
return {
platformClientId: oAuthClient.id,
platformCancelUrl: oAuthClient.bookingCancelRedirectUri,
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
platformBookingUrl: oAuthClient.bookingRedirectUri,
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
};
}
return undefined;
}
async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13) {
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
inputBooking.eventTypeId
@@ -123,10 +138,13 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
inputBooking.attendee.timeZone
);
const endTime = startTime.plus({ minutes: eventType.length });
const endTime = startTime.plus({ minutes: lengthInMinutes });
return {
start: startTime.toISO(),
@@ -134,9 +152,7 @@ export class InputBookingsService_2024_08_13 {
eventTypeId: inputBooking.eventTypeId,
timeZone: inputBooking.attendee.timeZone,
language: inputBooking.attendee.language || "en",
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
// metadata: inputBooking.metadata || {},
metadata: {},
metadata: inputBooking.metadata || {},
hasHashedBookingLink: false,
guests: inputBooking.guests,
// note(Lauris): responses with name and email are required by the handleNewBooking
@@ -150,27 +166,48 @@ export class InputBookingsService_2024_08_13 {
};
}
validateBookingLengthInMinutes(inputBooking: CreateBookingInput_2024_08_13, eventType: EventType) {
const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType.metadata);
if (inputBooking.lengthInMinutes && !eventTypeMetadata?.multipleDuration) {
throw new BadRequestException(
"Can't specify 'lengthInMinutes' because event type does not have multiple possible lengths. Please, remove the 'lengthInMinutes' field from the request."
);
}
if (
inputBooking.lengthInMinutes &&
!eventTypeMetadata?.multipleDuration?.includes(inputBooking.lengthInMinutes)
) {
throw new BadRequestException(
`Provided 'lengthInMinutes' is not one of the possible lengths for the event type. The possible lengths are: ${eventTypeMetadata?.multipleDuration?.join(
", "
)}`
);
}
}
async createRecurringBookingRequest(
request: Request,
body: CreateRecurringBookingInput_2024_08_13
): Promise<BookingRequest> {
// note(Lauris): update to this.transformInputCreate when rescheduling is implemented
const bodyTransformed = await this.transformInputCreateRecurringBooking(body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const oAuthClientParams = await this.getOAuthClientParams(body.eventTypeId);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const location = request.body.location || request.body.meetingUrl;
Object.assign(newRequest, {
userId,
...oAuthParams,
platformBookingLocation: location,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
});
if (oAuthClientParams) {
Object.assign(newRequest, {
userId,
...oAuthClientParams,
platformBookingLocation: location,
noEmail: !oAuthClientParams.arePlatformEmailsEnabled,
});
} else {
Object.assign(newRequest, { userId, platformBookingLocation: location });
}
newRequest.body = bodyTransformed.map((event) => ({
...event,
@@ -219,9 +256,7 @@ export class InputBookingsService_2024_08_13 {
recurringEventId,
timeZone: inputBooking.attendee.timeZone,
language: inputBooking.attendee.language || "en",
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
// metadata: inputBooking.metadata || {},
metadata: {},
metadata: inputBooking.metadata || {},
hasHashedBookingLink: false,
guests: inputBooking.guests,
// note(Lauris): responses with name and email are required by the handleNewBooking
@@ -261,18 +296,20 @@ export class InputBookingsService_2024_08_13 {
const bodyTransformed = this.isRescheduleSeatedBody(body)
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
: await this.transformInputRescheduleBooking(bookingUid, body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const oAuthClientParams = await this.getOAuthClientParams(bodyTransformed.eventTypeId);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const location = await this.getRescheduleBookingLocation(bookingUid);
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
if (oAuthClientParams) {
Object.assign(newRequest, { userId, ...oAuthClientParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthClientParams.arePlatformEmailsEnabled };
} else {
Object.assign(newRequest, { userId, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: false };
}
return newRequest as unknown as BookingRequest;
}
@@ -321,9 +358,7 @@ export class InputBookingsService_2024_08_13 {
eventTypeId: eventType.id,
timeZone: attendee.timeZone,
language: attendee.locale,
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
// metadata: booking.metadata || {},
metadata: {},
metadata: seat.metadata || {},
hasHashedBookingLink: false,
guests: [],
responses: { ...bookingResponses },
@@ -362,9 +397,7 @@ export class InputBookingsService_2024_08_13 {
eventTypeId: eventType.id,
timeZone: attendee.timeZone,
language: attendee.locale,
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
// metadata: booking.metadata || {},
metadata: {},
metadata: booking.metadata || {},
hasHashedBookingLink: false,
guests: bookingResponses.guests,
responses: { ...bookingResponses, rescheduledReason: inputBooking.reschedulingReason },
@@ -399,24 +432,6 @@ export class InputBookingsService_2024_08_13 {
}
}
private async createBookingRequestOAuthClientParams(clientId: string) {
const params = DEFAULT_PLATFORM_PARAMS;
try {
const client = await this.oAuthClientRepository.getOAuthClient(clientId);
if (client) {
params.platformClientId = clientId;
params.platformCancelUrl = client.bookingCancelRedirectUri ?? "";
params.platformRescheduleUrl = client.bookingRescheduleRedirectUri ?? "";
params.platformBookingUrl = client.bookingRedirectUri ?? "";
params.arePlatformEmailsEnabled = client.areEmailsEnabled ?? false;
}
return params;
} catch (err) {
this.logger.error(err);
return params;
}
}
transformGetBookingsFilters(queryParams: GetBookingsInput_2024_08_13) {
return {
attendeeEmail: queryParams.attendeeEmail,
@@ -449,17 +464,26 @@ export class InputBookingsService_2024_08_13 {
const bodyTransformed = this.isCancelSeatedBody(body)
? await this.transformInputCancelSeatedBooking(bookingUid, body)
: await this.transformInputCancelBooking(bookingUid, body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const booking = await this.bookingsRepository.getByUid(bodyTransformed.uid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
}
const oAuthClientParams = booking.eventTypeId
? await this.getOAuthClientParams(booking.eventTypeId)
: undefined;
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
const oAuthParams = oAuthClientId
? await this.createBookingRequestOAuthClientParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(newRequest, { userId, ...oAuthParams });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
if (oAuthClientParams) {
Object.assign(newRequest, { userId, ...oAuthClientParams });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthClientParams.arePlatformEmailsEnabled };
} else {
Object.assign(newRequest, { userId });
newRequest.body = { ...bodyTransformed, noEmail: false };
}
return newRequest as unknown as BookingRequest;
}
@@ -469,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,
};
}
@@ -493,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,
};
}
@@ -4,6 +4,7 @@ import { plainToClass } from "class-transformer";
import { DateTime } from "luxon";
import { z } from "zod";
import { bookingMetadataSchema } from "@calcom/platform-libraries";
import {
BookingOutput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
@@ -49,10 +50,16 @@ type DatabaseBooking = Booking & {
noShow: boolean | null;
bookingSeat?: BookingSeat | null;
}[];
} & { user: { id: number; name: string | null; email: string } | null };
user: { id: number; name: string | null; email: string } | null;
createdAt: Date;
};
type BookingWithUser = Booking & { user: { id: number; name: string | null; email: string } | null };
type DatabaseMetadata = z.infer<typeof bookingMetadataSchema>;
const seatedBookingMetadataSchema = z.object({}).catchall(z.string());
@Injectable()
export class OutputBookingsService_2024_08_13 {
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
@@ -61,8 +68,9 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
id: databaseBooking.id,
@@ -88,18 +96,28 @@ export class OutputBookingsService_2024_08_13 {
absent: !!attendee.noShow,
})),
guests: bookingResponses.guests,
location: databaseBooking.location,
location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
meetingUrl: location,
absentHost: !!databaseBooking.noShowHost,
createdAt: databaseBooking.createdAt,
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
// note(Lauris): I don't know why plainToClass erases bookings responses so attaching manually
// note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually
bookingTransformed.bookingFieldsResponses = bookingResponses;
bookingTransformed.metadata = this.getUserDefinedMetadata(metadata);
return bookingTransformed;
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
const { videoCallUrl, ...userDefinedMetadata } = databaseMetadata;
return userDefinedMetadata;
}
async getOutputRecurringBookings(bookingsIds: number[]) {
const transformed = [];
@@ -119,8 +137,9 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
id: databaseBooking.id,
@@ -146,15 +165,22 @@ export class OutputBookingsService_2024_08_13 {
absent: !!attendee.noShow,
})),
guests: bookingResponses.guests,
location: databaseBooking.location,
location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
meetingUrl: location,
recurringBookingUid: databaseBooking.recurringEventId,
absentHost: !!databaseBooking.noShowHost,
bookingFieldsResponses: databaseBooking.responses,
createdAt: databaseBooking.createdAt,
};
return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, {
strategy: "excludeAll",
});
// note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually
bookingTransformed.bookingFieldsResponses = bookingResponses;
bookingTransformed.metadata = this.getUserDefinedMetadata(metadata);
return bookingTransformed;
}
getOutputCreateSeatedBooking(
@@ -169,6 +195,8 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
id: databaseBooking.id,
@@ -185,10 +213,11 @@ export class OutputBookingsService_2024_08_13 {
// note(Lauris): eventTypeId is deprecated
eventTypeId: databaseBooking.eventTypeId,
attendees: [],
location: databaseBooking.location,
location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
meetingUrl: location,
absentHost: !!databaseBooking.noShowHost,
createdAt: databaseBooking.createdAt,
};
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -208,6 +237,7 @@ export class OutputBookingsService_2024_08_13 {
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
@@ -260,6 +290,8 @@ export class OutputBookingsService_2024_08_13 {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = bookingMetadataSchema.parse(databaseBooking.metadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const booking = {
id: databaseBooking.id,
@@ -277,11 +309,12 @@ export class OutputBookingsService_2024_08_13 {
// note(Lauris): eventTypeId is deprecated
eventTypeId: databaseBooking.eventTypeId,
attendees: [],
location: databaseBooking.location,
location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
meetingUrl: location,
recurringBookingUid: databaseBooking.recurringEventId,
absentHost: !!databaseBooking.noShowHost,
createdAt: databaseBooking.createdAt,
};
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
@@ -303,9 +336,9 @@ export class OutputBookingsService_2024_08_13 {
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
return attendeeParsed;
});
@@ -212,6 +212,7 @@ describe("Event types Endpoints", () => {
slug: "coding-class",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
lengthInMinutesOptions: [30, 60, 90],
locations: [
{
type: "integration",
@@ -300,6 +301,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.description).toEqual(body.description);
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
expect(createdEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
expect(createdEventType.locations).toEqual(body.locations);
expect(createdEventType.ownerId).toEqual(user.id);
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
@@ -360,6 +362,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
@@ -665,6 +668,7 @@ describe("Event types Endpoints", () => {
const body: UpdateEventTypeInput_2024_06_14 = {
title: newTitle,
scheduleId: secondSchedule.id,
lengthInMinutesOptions: [15, 30],
bookingFields: [
nameBookingField,
{
@@ -733,6 +737,7 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.id).toEqual(eventType.id);
expect(updatedEventType.title).toEqual(newTitle);
expect(updatedEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
expect(updatedEventType.description).toEqual(eventType.description);
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(updatedEventType.locations).toEqual(eventType.locations);
@@ -772,6 +777,7 @@ describe("Event types Endpoints", () => {
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
eventType.lengthInMinutesOptions = updatedEventType.lengthInMinutesOptions;
eventType.bookingLimitsCount = updatedEventType.bookingLimitsCount;
eventType.onlyShowFirstAvailableSlot = updatedEventType.onlyShowFirstAvailableSlot;
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
@@ -819,6 +825,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.title).toEqual(eventType.title);
expect(fetchedEventType.description).toEqual(eventType.description);
expect(fetchedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType.locations).toEqual(eventType.locations);
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType.ownerId).toEqual(user.id);
@@ -857,6 +864,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
@@ -104,6 +104,7 @@ export class InputEventTypesService_2024_06_14 {
const {
lengthInMinutes,
lengthInMinutesOptions,
locations,
bookingFields,
bookingLimitsCount,
@@ -135,6 +136,7 @@ export class InputEventTypesService_2024_06_14 {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
@@ -152,6 +154,7 @@ export class InputEventTypesService_2024_06_14 {
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
const {
lengthInMinutes,
lengthInMinutesOptions,
locations,
bookingFields,
bookingLimitsCount,
@@ -191,6 +194,7 @@ export class InputEventTypesService_2024_06_14 {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
@@ -153,6 +153,7 @@ export class OutputEventTypesService_2024_06_14 {
id,
ownerId,
lengthInMinutes: length,
lengthInMinutesOptions: metadata.multipleDuration,
title,
slug,
description: description || "",
+2
View File
@@ -28,6 +28,8 @@ export type Environment = {
RATE_LIMIT_DEFAULT_LIMIT_ACCESS_TOKEN: number;
RATE_LIMIT_DEFAULT_LIMIT: number;
RATE_LIMIT_DEFAULT_BLOCK_DURATION_MS: number;
AXIOM_DATASET: string;
AXIOM_TOKEN: string;
};
export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environment[K]): Environment[K] => {
+18 -4
View File
@@ -1,5 +1,7 @@
import { WinstonTransport as AxiomTransport } from "@axiomhq/winston";
import type { LoggerOptions } from "winston";
import { format, transports } from "winston";
import { format, transports as Transports } from "winston";
import type Transport from "winston-transport";
const formattedTimestamp = format.timestamp({
format: "YYYY-MM-DD HH:mm:ss.SSS",
@@ -36,13 +38,25 @@ export const logLevels = {
export const loggerConfig = (): LoggerOptions => {
const isProduction = process.env.NODE_ENV === "production";
const transports: Transport[] = [];
transports.push(new Transports.Console());
if (!!process.env.AXIOM_TOKEN && !!process.env.AXIOM_DATASET) {
transports.push(
new AxiomTransport({
token: process.env.AXIOM_TOKEN,
dataset: process.env.AXIOM_DATASET,
})
);
}
return {
levels: logLevels,
level: process.env.LOG_LEVEL ?? "info",
format: isProduction ? WINSTON_PROD_FORMAT : WINSTON_DEV_FORMAT,
transports: [new transports.Console()],
exceptionHandlers: [new transports.Console()],
rejectionHandlers: [new transports.Console()],
transports,
exceptionHandlers: transports,
rejectionHandlers: transports,
defaultMeta: {
service: "cal-platform-api",
},
+12 -4
View File
@@ -1,4 +1,5 @@
import type { AppConfig } from "@/config/type";
import { getEnv } from "@/env";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
@@ -107,13 +108,20 @@ async function generateSwagger(app: NestExpressApplication<Server>) {
const document = SwaggerModule.createDocument(app, config);
document.paths = groupAndSortPathsByFirstTag(document.paths);
const outputFile = "./swagger/documentation.json";
const swaggerOutputFile = "./swagger/documentation.json";
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
const stringifiedContents = JSON.stringify(document, null, 2);
if (fs.existsSync(outputFile)) {
fs.unlinkSync(outputFile);
if (fs.existsSync(swaggerOutputFile)) {
fs.unlinkSync(swaggerOutputFile);
}
fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" });
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
fs.unlinkSync(docsOutputFile);
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
}
if (!process.env.DOCS_URL) {
SwaggerModule.setup("docs", app, document, {
@@ -25,9 +25,9 @@ import {
import {
ApiTags as DocsTags,
ApiExcludeController as DocsExcludeController,
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiOperation as DocsOperation,
ApiOkResponse as DocsOkResponse,
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiBadRequestResponse as DocsBadRequestResponse,
ApiHeader as DocsHeader,
} from "@nestjs/swagger";
@@ -50,20 +50,7 @@ export class OAuthFlowController {
@Post("/authorize")
@HttpCode(HttpStatus.OK)
@UseGuards(NextAuthGuard)
@DocsOperation({
summary: "Authorize an OAuth client",
description:
"Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
})
@DocsOkResponse({
description:
"The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`",
})
@DocsBadRequestResponse({
description:
"Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
@DocsExcludeEndpoint()
async authorize(
@Param("clientId") clientId: string,
@Body() body: OAuthAuthorizeInput,
@@ -97,20 +84,7 @@ export class OAuthFlowController {
@Post("/exchange")
@HttpCode(HttpStatus.OK)
@DocsOperation({
summary: "Exchange authorization code for access tokens",
description:
"Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
})
@DocsOkResponse({
type: KeysResponseDto,
description: "Successfully exchanged authorization code for access and refresh tokens.",
})
@DocsBadRequestResponse({
description:
"Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
@DocsExcludeEndpoint()
async exchange(
@Headers("Authorization") authorization: string,
@Param("clientId") clientId: string,
@@ -99,4 +99,28 @@ export class OAuthClientRepository {
where: { id: clientId },
});
}
async getByUserId(userId: number) {
return this.dbRead.prisma.platformOAuthClient.findFirst({
where: {
users: {
some: {
id: userId,
},
},
},
});
}
async getByTeamId(teamId: number) {
return this.dbRead.prisma.platformOAuthClient.findFirst({
where: {
teams: {
some: {
id: teamId,
},
},
},
});
}
}
@@ -82,7 +82,10 @@ export class WebhooksController {
}
@Get("/")
@ApiOperation({ summary: "Get all webooks (paginated)" })
@ApiOperation({
summary: "Get all webooks",
description: "Gets a paginated list of webhooks for the authenticated user.",
})
async getWebhooks(
@GetUser() user: UserWithProfile,
@Query() query: SkipTakePagination
+444 -116
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",
@@ -3257,6 +3258,230 @@
]
}
},
"/v2/bookings/{bookingUid}/reassign": {
"post": {
"operationId": "BookingsController_2024_08_13_reassignBooking",
"summary": "Automatically reassign booking to a new host",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/{bookingUid}/reassign/{userId}": {
"post": {
"operationId": "BookingsController_2024_08_13_reassignBookingToUser",
"summary": "Reassign a booking to a specific user",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "userId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReassignToUserBookingInput_2024_08_13"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/{bookingUid}/confirm": {
"post": {
"operationId": "BookingsController_2024_08_13_confirmBooking",
"summary": "Confirm booking that requires a confirmation",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/bookings/{bookingUid}/decline": {
"post": {
"operationId": "BookingsController_2024_08_13_declineBooking",
"summary": "Decline booking that requires a confirmation",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to `2024-08-13`",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeclineBookingInput_2024_08_13"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetBookingOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -4214,97 +4439,6 @@
]
}
},
"/v2/oauth/{clientId}/authorize": {
"post": {
"operationId": "OAuthFlowController_authorize",
"summary": "Authorize an OAuth client",
"description": "Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/OAuthAuthorizeInput"
}
}
}
},
"responses": {
"200": {
"description": "The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`"
},
"400": {
"description": "Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client."
}
},
"tags": [
"OAuth"
]
}
},
"/v2/oauth/{clientId}/exchange": {
"post": {
"operationId": "OAuthFlowController_exchange",
"summary": "Exchange authorization code for access tokens",
"description": "Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ExchangeAuthorizationCodeInput"
}
}
}
},
"responses": {
"200": {
"description": "Successfully exchanged authorization code for access and refresh tokens.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/KeysResponseDto"
}
}
}
},
"400": {
"description": "Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match."
}
},
"tags": [
"OAuth"
]
}
},
"/v2/oauth/{clientId}/refresh": {
"post": {
"operationId": "OAuthFlowController_refreshAccessToken",
@@ -5119,7 +5253,8 @@
},
"get": {
"operationId": "WebhooksController_getWebhooks",
"summary": "Get all webooks (paginated)",
"summary": "Get all webooks",
"description": "Gets a paginated list of webhooks for the authenticated user.",
"parameters": [
{
"name": "take",
@@ -5849,28 +5984,6 @@
}
}
},
"OAuthAuthorizeInput": {
"type": "object",
"properties": {
"redirectUri": {
"type": "string"
}
},
"required": [
"redirectUri"
]
},
"ExchangeAuthorizationCodeInput": {
"type": "object",
"properties": {
"clientSecret": {
"type": "string"
}
},
"required": [
"clientSecret"
]
},
"RefreshTokenInput": {
"type": "object",
"properties": {
@@ -6751,6 +6864,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -6974,6 +7099,7 @@
},
"required": [
"lengthInMinutes",
"lengthInMinutesOptions",
"title",
"slug"
]
@@ -8116,6 +8242,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -8339,6 +8477,7 @@
"required": [
"id",
"lengthInMinutes",
"lengthInMinutesOptions",
"title",
"slug",
"description",
@@ -8454,6 +8593,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -8674,7 +8825,10 @@
"hideCalendarEventDetails": {
"type": "boolean"
}
}
},
"required": [
"lengthInMinutesOptions"
]
},
"UpdateEventTypeOutput_2024_06_14": {
"type": "object",
@@ -9933,6 +10087,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -10169,6 +10335,7 @@
},
"required": [
"lengthInMinutes",
"lengthInMinutesOptions",
"title",
"slug",
"schedulingType",
@@ -10250,6 +10417,19 @@
"minimum": 1,
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "number",
"minimum": 1
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -10675,6 +10855,18 @@
"type": "number",
"example": 60
},
"lengthInMinutesOptions": {
"example": [
15,
30,
60
],
"description": "If you want that user can choose between different lengths of the event you can specify them here. Must include the provided `lengthInMinutes`.",
"type": "array",
"items": {
"type": "string"
}
},
"title": {
"type": "string",
"example": "Learn the secrets of masterchief!"
@@ -10905,7 +11097,10 @@
"type": "boolean",
"description": "If true, all current and future team members will be assigned to this event type"
}
}
},
"required": [
"lengthInMinutesOptions"
]
},
"UpdateTeamEventTypeOutput": {
"type": "object",
@@ -12615,6 +12810,11 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
@@ -12650,6 +12850,13 @@
"description": "Location for this booking. Displayed in email and calendar event.",
"example": "https://example.com/meeting"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
"example": {
"key": "value"
}
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
@@ -12660,6 +12867,7 @@
},
"required": [
"start",
"lengthInMinutes",
"eventTypeId",
"attendee"
]
@@ -12672,6 +12880,11 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
@@ -12707,6 +12920,13 @@
"description": "Location for this booking. Displayed in email and calendar event.",
"example": "https://example.com/meeting"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
"example": {
"key": "value"
}
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
@@ -12722,6 +12942,7 @@
},
"required": [
"start",
"lengthInMinutes",
"eventTypeId",
"attendee",
"instant"
@@ -12735,6 +12956,11 @@
"description": "The start time of the booking in ISO 8601 format in UTC timezone.",
"example": "2024-08-13T09:00:00Z"
},
"lengthInMinutes": {
"type": "number",
"example": 30,
"description": "If it is an event type that has multiple possible lengths that attendee can pick from, you can pass the desired booking length here.\n If not provided then event type default length will be used for the booking."
},
"eventTypeId": {
"type": "number",
"description": "The ID of the event type that is booked.",
@@ -12770,6 +12996,13 @@
"description": "Location for this booking. Displayed in email and calendar event.",
"example": "https://example.com/meeting"
},
"metadata": {
"type": "object",
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
"example": {
"key": "value"
}
},
"bookingFieldsResponses": {
"type": "object",
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
@@ -12785,6 +13018,7 @@
},
"required": [
"start",
"lengthInMinutes",
"eventTypeId",
"attendee"
]
@@ -12888,6 +13122,16 @@
"type": "boolean",
"example": true
},
"createdAt": {
"type": "string",
"example": "2024-08-13T15:30:00Z"
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
},
"attendees": {
"type": "array",
"items": {
@@ -12925,6 +13169,7 @@
"eventTypeId",
"eventType",
"absentHost",
"createdAt",
"attendees"
]
},
@@ -13010,6 +13255,16 @@
"type": "boolean",
"example": true
},
"createdAt": {
"type": "string",
"example": "2024-08-13T15:30:00Z"
},
"metadata": {
"type": "object",
"example": {
"key": "value"
}
},
"attendees": {
"type": "array",
"items": {
@@ -13051,6 +13306,7 @@
"eventTypeId",
"eventType",
"absentHost",
"createdAt",
"attendees",
"recurringBookingUid"
]
@@ -13309,6 +13565,78 @@
"data"
]
},
"ReassignedToDto": {
"type": "object",
"properties": {
"id": {
"type": "number",
"example": 123
},
"name": {
"type": "string",
"example": "John Doe"
},
"email": {
"type": "string",
"example": "john.doe@example.com"
}
},
"required": [
"id",
"name",
"email"
]
},
"ReassignBookingOutput_2024_08_13": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"oneOf": [
{
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
],
"description": "Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object",
"allOf": [
{
"$ref": "#/components/schemas/ReassignBookingOutput_2024_08_13"
}
]
}
},
"required": [
"status",
"data"
]
},
"ReassignToUserBookingInput_2024_08_13": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"example": "Host has to take another call",
"description": "Reason for reassigning the booking"
}
}
},
"DeclineBookingInput_2024_08_13": {
"type": "object",
"properties": {
"reason": {
"type": "string",
"example": "Host has to take another call",
"description": "Reason for declining a booking that requires a confirmation"
}
}
},
"ReserveSlotInput": {
"type": "object",
"properties": {
@@ -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 });
}
@@ -1,11 +1,8 @@
import { _generateMetadata } from "app/_utils";
import { getFixedT } from "app/_utils";
import { ConferencingAppsViewWebWrapper } from "@calcom/atoms/monorepo";
import { getServerSessionForAppDir } from "@calcom/feature-auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { Button } from "@calcom/ui";
import ConferencingView from "~/settings/my-account/conferencing-view";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -17,22 +14,12 @@ const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const AddConferencingButton = () => {
return (
<Button color="secondary" StartIcon="plus" href="/apps/categories/conferencing">
{t("add")}
</Button>
);
};
return (
<SettingsHeader
<ConferencingAppsViewWebWrapper
title={t("conferencing")}
description={t("conferencing_description")}
CTA={<AddConferencingButton />}
borderInShellHeader={true}>
<ConferencingView />
</SettingsHeader>
add={t("add")}
/>
);
};
@@ -7,6 +7,7 @@ import {
SelectedCalendarsSettingsWebWrapper,
DestinationCalendarSettingsWebWrapper,
} from "@calcom/atoms/monorepo";
import AppListCard from "@calcom/features/apps/components/AppListCard";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
@@ -21,7 +22,6 @@ import {
import { QueryCell } from "@lib/QueryCell";
import useRouterQuery from "@lib/hooks/useRouterQuery";
import AppListCard from "@components/AppListCard";
import SubHeadingTitleWithConnections from "@components/integrations/SubHeadingTitleWithConnections";
type Props = {
@@ -132,6 +132,7 @@ function BookingListItem(booking: BookingItemProps) {
});
const isUpcoming = new Date(booking.endTime) >= new Date();
const isOngoing = isUpcoming && new Date() >= new Date(booking.startTime);
const isBookingInPast = new Date(booking.endTime) < new Date();
const isCancelled = booking.status === BookingStatus.CANCELLED;
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
@@ -269,7 +270,7 @@ function BookingListItem(booking: BookingItemProps) {
});
}
if (isBookingInPast) {
if (isBookingInPast || isOngoing) {
editBookingActions.push({
id: "no_show",
label: t("mark_as_no_show"),
+2 -2
View File
@@ -775,9 +775,9 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
>([]);
const findTeamMembersMatchingAttributeLogicMutation =
trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({
trpc.viewer.routingForms.findTeamMembersMatchingAttributeLogicOfRoute.useMutation({
onSuccess(data) {
setTeamMembersMatchingAttributeLogic(data.result);
setTeamMembersMatchingAttributeLogic(data.result ? data.result.users : data.result);
},
});
@@ -164,28 +164,6 @@ vi.mock("@calcom/trpc/react", () => ({
isPending: false,
})),
},
findTeamMembersMatchingAttributeLogic: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
onSuccess({
result: [
{
id: 1,
name: "Matching User 1",
email: "matching-user-1@example.com",
},
{
id: 2,
name: "Matching User 2",
email: "matching-user-2@example.com",
},
],
});
}),
};
}),
},
},
eventTypes: {
get: {
@@ -204,11 +182,37 @@ vi.mock("@calcom/trpc/react", () => ({
})),
},
},
routingForms: {
findTeamMembersMatchingAttributeLogicOfRoute: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
onSuccess({
result: {
users: [
{
id: 1,
name: "Matching User 1",
email: "matching-user-1@example.com",
},
{
id: 2,
name: "Matching User 2",
email: "matching-user-2@example.com",
},
],
},
});
}),
};
}),
},
},
},
},
}));
const mockMutateFn = vi.fn(({ __testOnSuccess }) => {
const mockReactQueryMutateFn = vi.fn(({ __testOnSuccess }) => {
__testOnSuccess({
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
});
@@ -218,7 +222,7 @@ vi.mock("@tanstack/react-query", () => ({
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn((payload) => {
mockMutateFn({
mockReactQueryMutateFn({
...payload,
__testOnSuccess: onSuccess,
});
@@ -441,7 +445,7 @@ describe("RerouteDialog", () => {
);
clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton();
expect(mockMutateFn).toHaveBeenCalledWith(
expect(mockReactQueryMutateFn).toHaveBeenCalledWith(
expect.objectContaining({
rescheduleUid: mockBooking.uid,
// Shouldn't include the user who booked the 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({ email: teamMemberEmail, recordType: null, crmAppSlug: null });
}
return Promise.resolve({ email: null, recordType: null, crmAppSlug: null });
});
}
function mockBookingFormHandler({
bookerEmail,
teamMemberEmail,
}: {
bookerEmail: string;
teamMemberEmail: string;
}) {
vi.mocked(bookingFormHandlers.salesforce).mockImplementation(
(_bookerEmail, _attributeRoutingConfig, _eventTypeId) => {
if (_bookerEmail === bookerEmail) {
return Promise.resolve({ email: teamMemberEmail, recordType: null });
}
return Promise.resolve({ email: null, recordType: 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",
},
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,
});
const ownerEmail = result.email;
expect(ownerEmail).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.email).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.email).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.email).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.email).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.email).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.email).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.email).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.email).toBe(null);
});
});
});
+246
View File
@@ -0,0 +1,246 @@
import type { ParsedUrlQuery } from "querystring";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps";
import type { AttributeRoutingConfig, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["getTeamMemberEmailFromCrm"] });
interface EventData {
id: number;
isInstantEvent: boolean;
schedulingType: SchedulingType | null;
metadata: Prisma.JsonValue | null;
length: number;
}
const returnNullValue = { email: null, recordType: null, crmAppSlug: null };
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;
return routingFormResponseId;
}
async function getAttributeRoutingConfig(
data:
| {
routingFormResponseId: number;
eventTypeId: number;
}
| {
route: Pick<LocalRoute, "attributeRoutingConfig">;
}
) {
if ("route" in data) {
return data.route.attributeRoutingConfig ?? null;
}
const { routingFormResponseId, eventTypeId } = data;
const routingFormQuery = await prisma.app_RoutingForms_Form.findFirst({
where: {
responses: {
some: {
id: routingFormResponseId,
},
},
},
select: {
routes: true,
},
});
if (!routingFormQuery || !routingFormQuery?.routes) return null;
const parsedRoutes = routesSchema.safeParse(routingFormQuery.routes);
if (!parsedRoutes.success || !parsedRoutes.data) return null;
// Find the route with the attributeRoutingConfig
// FIXME: There could be multiple routes with same action.eventTypeId, we should actually ensure we have the chosenRouteId in here and use that route.
const route = parsedRoutes.data.find((route) => {
if ("action" in route) {
return route.action.eventTypeId === eventTypeId;
}
});
if (!route || !("attributeRoutingConfig" in route)) return null;
// Get attributeRoutingConfig for the form
const attributeRoutingConfig = route.attributeRoutingConfig;
if (!attributeRoutingConfig) return null;
return attributeRoutingConfig;
}
function getEnabledRoutingFormAppSlugFromQuery(query: ParsedUrlQuery) {
// Determine if a routing form enabled app is in the query. Then pass it to the proper handler
// Routing form apps will have the format cal.appSlug
let enabledRoutingFormApp;
for (const key of Object.keys(query)) {
const keySplit = key.split(".");
const appSlug = keySplit[1];
if (enabledAppSlugs.includes(appSlug)) {
enabledRoutingFormApp = appSlug;
break;
}
}
return enabledRoutingFormApp;
}
/**
* Uses the owner of the contact directly from CRM
*/
async function getOwnerEmailFromCrm(
eventData: EventData,
email: string
): Promise<{ email: string | null; recordType: string | null; crmAppSlug: string | null }> {
const crmContactOwner = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
if (!crmContactOwner?.email) return returnNullValue;
// Determine if the contactOwner is a part of the event type
const contactOwnerQuery = await findUserByEmailWhoIsAHostOfEventType({
email: crmContactOwner.email,
eventTypeId: eventData.id,
});
if (!contactOwnerQuery) return returnNullValue;
return crmContactOwner;
}
/**
* Handles custom lookup field logic
*/
async function getTeamMemberEmailUsingRoutingFormHandler({
bookerEmail,
eventTypeId,
attributeRoutingConfig,
crmAppSlug,
}: {
bookerEmail: string;
eventTypeId: number;
attributeRoutingConfig: AttributeRoutingConfig | null;
crmAppSlug: string;
}) {
const nullReturnValue = { email: null, skipContactOwner: false, recordType: "" };
if (!attributeRoutingConfig) return nullReturnValue;
// If the skipContactOwner is enabled then don't return an team member email
if (attributeRoutingConfig.skipContactOwner) return { ...nullReturnValue, skipContactOwner: true };
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
.default;
const appHandler = appBookingFormHandler[crmAppSlug];
if (!appHandler) return nullReturnValue;
const { email: userEmail, recordType } = await appHandler(bookerEmail, attributeRoutingConfig, eventTypeId);
if (!userEmail) return nullReturnValue;
// Determine if the user is a part of the event type
const userQuery = await findUserByEmailWhoIsAHostOfEventType({ email: userEmail, eventTypeId });
if (!userQuery) return nullReturnValue;
return { ...nullReturnValue, email: userEmail, recordType };
}
async function getTeamMemberEmailForResponseOrContact({
bookerEmail,
eventData,
routingFormResponseId,
chosenRoute,
crmAppSlug,
}: {
bookerEmail: string;
eventData: EventData;
routingFormResponseId?: number | null;
/**
* If provided, we won't go look for the route from DB.
*/
chosenRoute?: LocalRoute;
crmAppSlug?: string;
}) {
const eventTypeId = eventData.id;
if (eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return returnNullValue;
const attributeRoutingConfigGetterData = routingFormResponseId
? { routingFormResponseId, eventTypeId }
: chosenRoute
? { route: chosenRoute }
: null;
// If we have found crmAppSlug, it means that the CRM App in the routing-form will handle the logic
if (attributeRoutingConfigGetterData && crmAppSlug) {
log.debug(
"Using CRM App handler in routing-forms",
safeStringify({ attributeRoutingConfigGetterData, crmAppSlug })
);
const attributeRoutingConfig = await getAttributeRoutingConfig(attributeRoutingConfigGetterData);
const { email, skipContactOwner, recordType } = await getTeamMemberEmailUsingRoutingFormHandler({
bookerEmail,
eventTypeId,
attributeRoutingConfig,
crmAppSlug,
});
if (skipContactOwner) return returnNullValue;
if (email) return { email, recordType, crmAppSlug };
} else {
log.debug("Getting the contact owner email from CRM");
return await getOwnerEmailFromCrm(eventData, bookerEmail);
}
return returnNullValue;
}
export async function getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
chosenRoute,
}: {
query: ParsedUrlQuery;
eventData: EventData;
chosenRoute?: LocalRoute;
}) {
// Without email no lookup is possible
if (!query.email || typeof query.email !== "string") {
return returnNullValue;
}
log.debug("getTeamMemberEmailForResponseOrContactUsingUrlQuery", safeStringify({ query }));
const crmAppSlug = getEnabledRoutingFormAppSlugFromQuery(query);
const routingFormResponseId = getRoutingFormResponseIdFromQuery(query);
return await getTeamMemberEmailForResponseOrContact({
bookerEmail: query.email,
eventData,
routingFormResponseId,
chosenRoute,
crmAppSlug,
});
}
@@ -1,12 +1,6 @@
import type { Prisma } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import type { ParsedUrlQuery } from "querystring";
import { z } from "zod";
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import { enabledAppSlugs } from "@calcom/app-store/routing-forms/lib/enabledApps";
import { zodRoutes as routesSchema } from "@calcom/app-store/routing-forms/zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
@@ -14,7 +8,6 @@ import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/org
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
@@ -93,6 +86,17 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const ssr = await ssrInit(context);
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
const { getTeamMemberEmailForResponseOrContactUsingUrlQuery } = await import(
"@calcom/web/lib/getTeamMemberEmailFromCrm"
);
const {
email: teamMemberEmail,
recordType: crmOwnerRecordType,
crmAppSlug,
} = await getTeamMemberEmailForResponseOrContactUsingUrlQuery({
query,
eventData,
});
return {
props: {
@@ -117,148 +121,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false,
themeBasis: null,
orgBannerUrl: team.parent?.bannerUrl ?? "",
teamMemberEmail: await handleGettingTeamMemberEmail(query, eventTypeId, eventData),
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
},
};
};
interface EventData {
id: number;
isInstantEvent: boolean;
schedulingType: SchedulingType | null;
metadata: Prisma.JsonValue | null;
length: number;
}
async function handleGettingTeamMemberEmail(
query: ParsedUrlQuery,
eventTypeId: number,
eventData: EventData
) {
if (
!query.email ||
typeof query.email !== "string" ||
eventData.schedulingType !== SchedulingType.ROUND_ROBIN
)
return null;
// Check if a routing form was completed and an routing form option is enabled
if (
ROUTING_FORM_RESPONSE_ID_QUERY_STRING in query &&
Object.values(query).some((value) => value === "true")
) {
const { email, skipContactOwner } = await handleRoutingFormOption(query, eventTypeId);
if (skipContactOwner) return null;
if (email) return email;
} else {
return await getTeamMemberEmail(eventData, query.email);
}
return null;
}
async function handleRoutingFormOption(query: ParsedUrlQuery, eventTypeId: number) {
const nullReturnValue = { email: null, skipContactOwner: false };
if (typeof query.email !== "string") return nullReturnValue;
const routingFormQuery = await prisma.app_RoutingForms_Form.findFirst({
where: {
responses: {
some: {
id: Number(query[ROUTING_FORM_RESPONSE_ID_QUERY_STRING]),
},
},
},
select: {
routes: true,
},
});
if (!routingFormQuery || !routingFormQuery?.routes) return nullReturnValue;
const parsedRoutes = routesSchema.safeParse(routingFormQuery.routes);
if (!parsedRoutes.success || !parsedRoutes.data) return nullReturnValue;
// Find the route with the attributeRoutingConfig
const route = parsedRoutes.data.find((route) => {
if ("action" in route) {
return route.action.eventTypeId === eventTypeId;
}
});
if (!route || !("attributeRoutingConfig" in route)) return nullReturnValue;
// Get attributeRoutingConfig for the form
const attributeRoutingConfig = route.attributeRoutingConfig;
if (!attributeRoutingConfig) return nullReturnValue;
// If the skipContactOwner is enabled then don't return an team member email
if (attributeRoutingConfig?.skipContactOwner) return { ...nullReturnValue, skipContactOwner: true };
// Determine if a routing form enabled app is in the query. Then pass it to the proper handler
// Routing form apps will have the format cal.appSlug
let enabledRoutingFormApp;
for (const key of Object.keys(query)) {
const keySplit = key.split(".");
const appSlug = keySplit[1];
if (enabledAppSlugs.includes(appSlug)) {
enabledRoutingFormApp = appSlug;
break;
}
}
if (!enabledRoutingFormApp) return nullReturnValue;
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
.default;
const appHandler = appBookingFormHandler[enabledRoutingFormApp];
if (!appHandler) return nullReturnValue;
const { email: userEmail } = await appHandler(query.email, attributeRoutingConfig, eventTypeId);
if (!userEmail) return nullReturnValue;
// Determine if the user is a part of the event type
const userQuery = await await prisma.user.findFirst({
where: {
email: userEmail,
hosts: {
some: {
eventTypeId: eventTypeId,
},
},
},
});
if (!userQuery) return nullReturnValue;
return { ...nullReturnValue, email: userEmail };
}
async function getTeamMemberEmail(eventData: EventData, email: string): Promise<string | null> {
// Pre-requisites
if (!eventData || !email || eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return 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,
},
},
},
});
if (!contactOwnerQuery) return null;
return crmContactOwnerEmail;
}
@@ -9,6 +9,7 @@ import {
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
@@ -55,6 +56,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
).profile
: null;
const calVideoLogo = profile?.organization
? await OrganizationRepository.findCalVideoLogoByOrgId({ id: profile.organization.id })
: null;
//daily.co calls have a 14 days exit buffer when a user enters a call when it's not available it will trigger the modals
const now = new Date();
const exitDate = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000);
@@ -134,6 +139,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
: bookingObj.user,
},
hasTeamPlan: !!hasTeamPlan,
calVideoLogo,
trpcState: ssr.dehydrate(),
},
};
@@ -3,19 +3,28 @@
import { useReducer } from "react";
import getAppCategoryTitle from "@calcom/app-store/_utils/getAppCategoryTitle";
import type { UpdateDefaultConferencingAppParams } from "@calcom/features/apps/components/AppList";
import { AppList } from "@calcom/features/apps/components/AppList";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import type { RemoveAppParams } from "@calcom/features/apps/components/DisconnectIntegrationModal";
import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { AppCategories } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import type { Icon } from "@calcom/ui";
import { AppSkeletonLoader as SkeletonLoader, Button, EmptyScreen, ShellSubHeading } from "@calcom/ui";
import {
AppSkeletonLoader as SkeletonLoader,
Button,
EmptyScreen,
ShellSubHeading,
showToast,
} from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
import type { querySchemaType, getServerSideProps } from "@lib/apps/installed/[category]/getServerSideProps";
import { AppList } from "@components/apps/AppList";
import { CalendarListContainer } from "@components/apps/CalendarListContainer";
import InstalledAppsLayout from "@components/apps/layouts/InstalledAppsLayout";
@@ -31,6 +40,7 @@ const IntegrationsContainer = ({
handleDisconnect,
}: IntegrationsContainerProps): JSX.Element => {
const { t } = useLocale();
const utils = trpc.useUtils();
const query = trpc.viewer.integrations.useQuery({
variant,
exclude,
@@ -38,6 +48,42 @@ const IntegrationsContainer = ({
includeTeamInstalledApps: true,
});
const { data: defaultConferencingApp } = trpc.viewer.getUsersDefaultConferencingApp.useQuery();
const updateDefaultAppMutation = trpc.viewer.updateUserDefaultConferencingApp.useMutation();
const updateLocationsMutation = trpc.viewer.eventTypes.bulkUpdateToDefaultLocation.useMutation();
const handleUpdateDefaultConferencingApp = ({ appSlug, callback }: UpdateDefaultConferencingAppParams) => {
updateDefaultAppMutation.mutate(
{ appSlug },
{
onSuccess: () => {
showToast("Default app updated successfully", "success");
utils.viewer.getUsersDefaultConferencingApp.invalidate();
callback();
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
}
);
};
const handleBulkUpdateDefaultLocation = ({ eventTypeIds, callback }: BulkUpdatParams) => {
updateLocationsMutation.mutate(
{
eventTypeIds,
},
{
onSuccess: () => {
utils.viewer.getUsersDefaultConferencingApp.invalidate();
callback();
},
}
);
};
// TODO: Refactor and reuse getAppCategories?
const emptyIcon: Record<AppCategories, React.ComponentProps<typeof Icon>["name"]> = {
calendar: "calendar",
@@ -95,7 +141,15 @@ const IntegrationsContainer = ({
}
/>
<AppList handleDisconnect={handleDisconnect} data={data} variant={variant} />
<AppList
handleDisconnect={handleDisconnect}
data={data}
variant={variant}
defaultConferencingApp={defaultConferencingApp}
handleUpdateDefaultConferencingApp={handleUpdateDefaultConferencingApp}
handleBulkUpdateDefaultLocation={handleBulkUpdateDefaultLocation}
isBulkUpdateDefaultLocationPending={updateDefaultAppMutation.isPending}
/>
</div>
);
}}
@@ -114,6 +168,7 @@ export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InstalledApps(props: PageProps) {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const utils = trpc.useUtils();
const category = searchParams?.get("category") as querySchemaType["category"];
const categoryList: AppCategories[] = Object.values(AppCategories).filter((category) => {
// Exclude calendar and other from categoryList, we handle those slightly differently below
@@ -136,6 +191,26 @@ export default function InstalledApps(props: PageProps) {
updateData({ isOpen: true, credentialId, teamId });
};
const deleteCredentialMutation = trpc.viewer.deleteCredential.useMutation();
const handleRemoveApp = ({ credentialId, teamId, callback }: RemoveAppParams) => {
deleteCredentialMutation.mutate(
{ id: credentialId, teamId },
{
onSuccess: () => {
showToast(t("app_removed_successfully"), "success");
callback();
utils.viewer.integrations.invalidate();
utils.viewer.connectedCalendars.invalidate();
},
onError: () => {
showToast(t("error_removing_app"), "error");
callback();
},
}
);
};
return (
<>
<InstalledAppsLayout heading={t("installed_apps")} subtitle={t("manage_your_connected_apps")}>
@@ -156,6 +231,7 @@ export default function InstalledApps(props: PageProps) {
isOpen={data.isOpen}
credentialId={data.credentialId}
teamId={data.teamId}
handleRemoveApp={handleRemoveApp}
/>
</>
);
@@ -4,6 +4,7 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { AvailabilitySettings } from "@calcom/atoms/monorepo";
import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { withErrorFromUnknown } from "@calcom/lib/getClientErrorFromUnknown";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -47,13 +48,22 @@ export const AvailabilitySettingsWebWrapper = ({
const [isBulkUpdateModalOpen, setIsBulkUpdateModalOpen] = useState(false);
const bulkUpdateDefaultAvailabilityMutation =
trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation({
onSuccess: () => {
utils.viewer.availability.list.invalidate();
setIsBulkUpdateModalOpen(false);
showToast(t("success"), "success");
trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation();
const bulkUpdateFunction = ({ eventTypeIds, callback }: BulkUpdatParams) => {
bulkUpdateDefaultAvailabilityMutation.mutate(
{
eventTypeIds,
},
});
{
onSuccess: () => {
utils.viewer.availability.list.invalidate();
callback();
showToast(t("success"), "success");
},
}
);
};
const isDefaultSchedule = me.data?.defaultScheduleId === scheduleId;
@@ -130,7 +140,7 @@ export const AvailabilitySettingsWebWrapper = ({
bulkUpdateModalProps={{
isOpen: isBulkUpdateModalOpen,
setIsOpen: setIsBulkUpdateModalOpen,
save: bulkUpdateDefaultAvailabilityMutation.mutate,
save: bulkUpdateFunction,
isSaving: bulkUpdateDefaultAvailabilityMutation.isPending,
}}
/>
@@ -7,6 +7,7 @@ import { useCallback, useState } from "react";
import SkeletonLoader from "@calcom/features/availability/components/SkeletonLoader";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import type { BulkUpdatParams } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import Shell from "@calcom/features/shell/Shell";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
@@ -80,13 +81,22 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
});
const bulkUpdateDefaultAvailabilityMutation =
trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation({
onSuccess: () => {
utils.viewer.availability.list.invalidate();
setBulkUpdateModal(false);
showToast(t("success"), "success");
trpc.viewer.availability.schedule.bulkUpdateToDefaultAvailability.useMutation();
const bulkUpdateFunction = ({ eventTypeIds, callback }: BulkUpdatParams) => {
bulkUpdateDefaultAvailabilityMutation.mutate(
{
eventTypeIds,
},
});
{
onSuccess: () => {
utils.viewer.availability.list.invalidate();
showToast(t("success"), "success");
callback();
},
}
);
};
const duplicateMutation = trpc.viewer.availability.schedule.duplicate.useMutation({
onSuccess: async ({ schedule }) => {
@@ -149,7 +159,7 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
isPending={bulkUpdateDefaultAvailabilityMutation.isPending}
open={bulkUpdateModal}
setOpen={setBulkUpdateModal}
bulkUpdateFunction={bulkUpdateDefaultAvailabilityMutation.mutate}
bulkUpdateFunction={bulkUpdateFunction}
description={t("default_schedules_bulk_description")}
/>
)}
@@ -81,7 +81,7 @@ interface InfiniteEventTypeListProps {
group: InfiniteEventTypeGroup;
readOnly: boolean;
bookerUrl: string | null;
pages: { nextCursor: number | undefined; eventTypes: InfiniteEventType[] }[] | undefined;
pages: { nextCursor: number | null | undefined; eventTypes: InfiniteEventType[] }[] | undefined;
lockedByOrg?: boolean;
isPending?: boolean;
debouncedSearchTerm?: string;
@@ -349,9 +349,14 @@ export const InfiniteEventTypeList = ({
group: { teamId: group?.teamId, parentId: group?.parentId },
},
(data) => {
if (!data) return { pages: [], pageParams: [] };
return {
pageParams: data?.pageParams ?? [],
pages: newOrder,
...data,
pages: newOrder.map((page) => ({
...page,
nextCursor: page.nextCursor ?? undefined,
})),
};
}
);
@@ -0,0 +1,48 @@
"use client";
import {
FailedBookingsByField,
RoutingFormResponsesTable,
RoutingKPICards,
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { RoutingInsightsFilters } from "@calcom/features/insights/filters/routing/FilterBar";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import InsightsLayout from "./layout";
export default function InsightsPage() {
const { t } = useLocale();
return (
<InsightsLayout>
<FiltersProvider>
<div className="mb-4 space-y-4">
<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 />
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
</InsightsLayout>
);
}
+34 -87
View File
@@ -14,107 +14,54 @@ import {
} from "@calcom/features/insights/components";
import { FiltersProvider } from "@calcom/features/insights/context/FiltersProvider";
import { Filters } from "@calcom/features/insights/filters";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { getServerSideProps } from "@lib/insights/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
import InsightsLayout from "./layout";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InsightsPage() {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
title={t("insights")}
description={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? (
<></>
) : (
<FiltersProvider>
<Filters />
<InsightsLayout>
<FiltersProvider>
<Filters />
<div className="mb-4 space-y-4">
<BookingKPICards />
<div className="mb-4 space-y-4">
<BookingKPICards />
<BookingStatusLineChart />
<BookingStatusLineChart />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<PopularEventsTable />
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
)}
</UpgradeTip>
</Shell>
</div>
<AverageEventDurationChart />
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
</div>
<small className="text-default block text-center">
{t("looking_for_more_insights")}{" "}
<a
className="text-blue-500 hover:underline"
href="mailto:updates@cal.com?subject=Feature%20Request%3A%20More%20Analytics&body=Hey%20Cal.com%20Team%2C%20I%20love%20the%20analytics%20page%20but%20I%20am%20looking%20for%20...">
{" "}
{t("contact_support")}
</a>
</small>
</div>
</FiltersProvider>
</InsightsLayout>
);
}
+69
View File
@@ -0,0 +1,69 @@
"use client";
import Shell from "@calcom/features/shell/Shell";
import { UpgradeTip } from "@calcom/features/tips";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Button, ButtonGroup } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import type { getServerSideProps } from "@lib/insights/getServerSideProps";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function InsightsLayout({ children }: { children: React.ReactNode }) {
const { t } = useLocale();
const { data: user } = trpc.viewer.me.useQuery();
const features = [
{
icon: <Icon name="users" className="h-5 w-5" />,
title: t("view_bookings_across"),
description: t("view_bookings_across_description"),
},
{
icon: <Icon name="refresh-ccw" className="h-5 w-5" />,
title: t("identify_booking_trends"),
description: t("identify_booking_trends_description"),
},
{
icon: <Icon name="user-plus" className="h-5 w-5" />,
title: t("spot_popular_event_types"),
description: t("spot_popular_event_types_description"),
},
];
return (
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
title={t("insights")}
description={t("insights_subtitle")}>
<UpgradeTip
plan="team"
title={t("make_informed_decisions")}
description={t("make_informed_decisions_description")}
features={features}
background="/tips/insights"
buttons={
<div className="space-y-2 rtl:space-x-reverse sm:space-x-2">
<ButtonGroup>
<Button color="primary" href={`${WEBAPP_URL}/settings/teams/new`}>
{t("create_team")}
</Button>
<Button color="minimal" href="https://go.cal.com/insights" target="_blank">
{t("learn_more")}
</Button>
</ButtonGroup>
</div>
}>
{!user ? null : children}
</UpgradeTip>
</Shell>
</div>
);
}
@@ -1,100 +0,0 @@
"use client";
import { useReducer } from "react";
import DisconnectIntegrationModal from "@calcom/features/apps/components/DisconnectIntegrationModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, EmptyScreen, SkeletonContainer, SkeletonText } from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
import { AppList } from "@components/apps/AppList";
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="divide-subtle border-subtle space-y-6 rounded-b-lg border border-t-0 px-6 py-4">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
</div>
</SkeletonContainer>
);
};
type ModalState = {
isOpen: boolean;
credentialId: null | number;
};
const ConferencingView = () => {
const { t } = useLocale();
const [modal, updateModal] = useReducer(
(data: ModalState, partialData: Partial<ModalState>) => ({ ...data, ...partialData }),
{
isOpen: false,
credentialId: null,
}
);
const query = trpc.viewer.integrations.useQuery({
variant: "conferencing",
onlyInstalled: true,
});
const handleModelClose = () => {
updateModal({ isOpen: false, credentialId: null });
};
const handleDisconnect = (credentialId: number) => {
updateModal({ isOpen: true, credentialId });
};
return (
<>
<div className="bg-default w-full sm:mx-0 xl:mt-0">
<QueryCell
query={query}
customLoader={<SkeletonLoader />}
success={({ data }) => {
if (!data.items.length) {
return (
<EmptyScreen
Icon="calendar"
headline={t("no_category_apps", {
category: t("conferencing").toLowerCase(),
})}
description={t("no_category_apps_description_conferencing")}
buttonRaw={
<Button
color="secondary"
data-testid="connect-conferencing-apps"
href="/apps/categories/conferencing">
{t("connect_conference_apps")}
</Button>
}
/>
);
}
return (
<AppList
listClassName="rounded-lg rounded-t-none border-t-0 max-w-full"
handleDisconnect={handleDisconnect}
data={data}
variant="conferencing"
/>
);
}}
/>
</div>
<DisconnectIntegrationModal
handleModelClose={handleModelClose}
isOpen={modal.isOpen}
credentialId={modal.credentialId}
/>
</>
);
};
export default ConferencingView;
+3 -3
View File
@@ -624,7 +624,7 @@ export default function Signup({
</div>
</div>
</div>
<div className="border-subtle lg:bg-subtle mx-auto mt-24 w-full max-w-2xl flex-col justify-between rounded-l-2xl pl-4 lg:mt-0 lg:flex lg:max-w-full lg:border lg:py-12 lg:pl-12 dark:bg-none">
<div className="border-subtle lg:bg-subtle mx-auto mt-24 w-full max-w-2xl flex-col justify-between rounded-l-2xl pl-4 dark:bg-none lg:mt-0 lg:flex lg:max-w-full lg:border lg:py-12 lg:pl-12">
{IS_CALCOM && (
<>
<div className="-mt-4 mb-6 mr-12 grid w-full grid-cols-3 gap-5 pr-4 sm:gap-3 lg:grid-cols-4">
@@ -675,7 +675,7 @@ export default function Signup({
</div>
</>
)}
<div className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed bg-black/[3%] lg:block lg:py-[6px] lg:pl-[6px] dark:bg-white/5">
<div className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed bg-black/[3%] dark:bg-white/5 lg:block lg:py-[6px] lg:pl-[6px]">
<img className="block dark:hidden" src="/mock-event-type-list.svg" alt="Cal.com Booking Page" />
<img
className="hidden dark:block"
@@ -686,7 +686,7 @@ export default function Signup({
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
{FEATURES.map((feature) => (
<>
<div className="mb-8 flex max-w-52 flex-col leading-none sm:mb-0">
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
<div className="text-emphasis items-center">
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
<span className="text-sm font-medium">{t(feature.title)}</span>
+4
View File
@@ -32,6 +32,8 @@ function Type({
isInstantMeeting,
orgBannerUrl,
teamMemberEmail,
crmOwnerRecordType,
crmAppSlug,
}: PageProps) {
const searchParams = useSearchParams();
@@ -65,6 +67,8 @@ function Type({
)}
orgBannerUrl={orgBannerUrl}
teamMemberEmail={teamMemberEmail}
crmOwnerRecordType={crmOwnerRecordType}
crmAppSlug={crmAppSlug}
/>
</main>
);
@@ -24,7 +24,7 @@ export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function JoinCall(props: PageProps) {
const { t } = useLocale();
const { meetingUrl, meetingPassword, booking, hasTeamPlan } = props;
const { meetingUrl, meetingPassword, booking, hasTeamPlan, calVideoLogo } = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
useEffect(() => {
@@ -103,10 +103,10 @@ export default function JoinCall(props: PageProps) {
<CalAiTranscribe />
</div>
<div style={{ zIndex: 2, position: "relative" }}>
{booking?.user?.organization?.calVideoLogo ? (
{calVideoLogo ? (
<img
className="min-w-16 min-h-16 fixed z-10 hidden aspect-square h-16 w-16 rounded-full sm:inline-block"
src={booking.user.organization.calVideoLogo}
src={calVideoLogo}
alt="My Org Logo"
style={{
top: 32,
+9 -1
View File
@@ -186,7 +186,7 @@ const nextConfig = {
locales: ["en"],
localeDetection: false,
},
productionBrowserSourceMaps: false,
productionBrowserSourceMaps: process.env.SENTRY_DISABLE_CLIENT_SOURCE_MAPS === "0",
/* We already do type check on GH actions */
typescript: {
ignoreBuildErrors: !!process.env.CI,
@@ -223,6 +223,10 @@ const nextConfig = {
},
webpack: (config, { webpack, buildId, isServer }) => {
if (isServer) {
if (process.env.SENTRY_DISABLE_SERVER_SOURCE_MAPS === "1") {
config.devtool = false;
}
// Module not found fix @see https://github.com/boxyhq/jackson/issues/1535#issuecomment-1704381612
config.plugins.push(
new webpack.IgnorePlugin({
@@ -667,6 +671,10 @@ if (!!process.env.NEXT_PUBLIC_SENTRY_DSN) {
hideSourceMaps: true,
// disable source map generation for the server code
disableServerWebpackPlugin: !!process.env.SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN,
silent: false,
sourcemaps: {
disable: process.env.SENTRY_DISABLE_SERVER_SOURCE_MAPS === "1",
},
})
);
}
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.6.22",
"version": "4.7.11",
"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",
@@ -74,7 +75,7 @@
"@upstash/redis": "^1.21.0",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.5.0",
"@vercel/og": "^0.6.3",
"accept-language-parser": "^1.5.0",
"async": "^3.2.4",
"bcp-47-match": "^2.0.3",
+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
+3 -7
View File
@@ -83,7 +83,7 @@ export default async function handler(req: NextApiRequest) {
/>
),
ogConfig
) as { body: Buffer };
);
return new Response(img.body, {
status: 200,
@@ -97,9 +97,7 @@ export default async function handler(req: NextApiRequest) {
slug: searchParams.get("slug"),
imageType,
});
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig) as {
body: Buffer;
};
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
@@ -111,9 +109,7 @@ export default async function handler(req: NextApiRequest) {
imageType,
});
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig) as {
body: Buffer;
};
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
}
-3
View File
@@ -80,9 +80,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
// Sync Services: Close.com
// closeComUpdateTeam(prevTeam, team);
// redirect to team screen
res.redirect(302, `/settings/teams/${team.id}/onboard-members?event=team_created`);
}
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { routingFormsRouter } from "@calcom/trpc/server/routers/viewer/routing-forms/_router";
export default createNextApiHandler(routingFormsRouter);
+12
View File
@@ -0,0 +1,12 @@
"use client";
import { getServerSideProps } from "@lib/insights/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import InsightsPage from "~/insights/insights-routing-view";
const Page = () => <InsightsPage />;
Page.PageWrapper = PageWrapper;
export default Page;
export { getServerSideProps };
@@ -64,7 +64,7 @@ test.describe("Embed Code Generator Tests", () => {
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
calLink: `${pro.username}/30-min`,
calLink: `${pro.username}/multiple-duration`,
});
});
@@ -102,7 +102,7 @@ test.describe("Embed Code Generator Tests", () => {
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "floating-popup",
calLink: `${pro.username}/30-min`,
calLink: `${pro.username}/multiple-duration`,
});
});
@@ -140,7 +140,7 @@ test.describe("Embed Code Generator Tests", () => {
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "element-click",
calLink: `${pro.username}/30-min`,
calLink: `${pro.username}/multiple-duration`,
});
});
});
@@ -238,7 +238,7 @@ test.describe("Embed Code Generator Tests", () => {
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
calLink: `${user.username}/30-min`,
calLink: `${user.username}/multiple-duration`,
bookerUrl: getOrgFullOrigin(org?.slug ?? ""),
});
});
@@ -279,7 +279,7 @@ test.describe("Embed Code Generator Tests", () => {
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "floating-popup",
calLink: `${user.username}/30-min`,
calLink: `${user.username}/multiple-duration`,
bookerUrl: getOrgFullOrigin(org?.slug ?? ""),
});
});
@@ -319,7 +319,7 @@ test.describe("Embed Code Generator Tests", () => {
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "element-click",
calLink: `${user.username}/30-min`,
calLink: `${user.username}/multiple-duration`,
bookerUrl: getOrgFullOrigin(org?.slug ?? ""),
});
});
+10
View File
@@ -61,6 +61,16 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
});
test("new event type appears first in the list", async ({ page }) => {
const nonce = randomString(3);
const eventTitle = `hello ${nonce}`;
await createNewEventType(page, { eventTitle });
await page.goto("/event-types");
const firstEvent = page.locator("[data-testid=event-types] > li a").first();
const firstEventTitle = await firstEvent.getAttribute("title");
await expect(firstEventTitle).toBe(eventTitle);
});
test("enabling recurring event comes with default options", async ({ page }) => {
const nonce = randomString(3);
const eventTitle = `my recurring event ${nonce}`;
+13 -9
View File
@@ -209,20 +209,24 @@ test.describe("Insights", async () => {
await page.getByRole("button", { name: "User" }).click();
// <div class="flex select-none truncate font-medium" data-state="closed">People</div>
await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
// await page.locator('div[class="flex select-none truncate font-medium"]').getByText("People").click();
await page.waitForSelector('[data-testid="people-filter-button"]');
await page.click('[data-testid="people-filter-button"]');
await page
.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
)
.nth(0)
// .locator(
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
// )
.locator('[data-testid="people-filter-group-option-0"]')
// .nth(0)
.click();
await page
.locator(
'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
)
.nth(1)
// .locator(
// 'div[class="hover:bg-muted flex items-center py-2 pl-3 pr-2.5 transition hover:cursor-pointer"]'
// )
.locator('[data-testid="people-filter-group-option-1"]')
// .nth(1)
.click();
// press escape button to close the filter
await page.keyboard.press("Escape");
+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 };
}
);
});
});
});
+112 -1
View File
@@ -197,10 +197,121 @@ test.describe("Out of office", () => {
await expect(page.getByTestId("away-emoji")).toBeTruthy();
});
test("User can create Entry for past", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
await user.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.getByTestId("add_entry_ooo").click();
await page.locator('[id="date"]').click();
await selectToAndFromDates(page, "13", "22", true);
// send request
await saveAndWaitForResponse(page);
const ooo = await prisma.outOfOfficeEntry.findMany({
where: {
userId: user.id,
},
orderBy: {
createdAt: "desc",
},
select: {
start: true,
end: true,
},
take: 1,
});
const latestEntry = ooo[0];
const currentDate = dayjs();
const fromDate = dayjs(latestEntry.start);
const toDate = dayjs(latestEntry.end);
expect(toDate.isBefore(currentDate)).toBe(true);
expect(fromDate.isBefore(currentDate)).toBe(true);
});
test("User can create overriding entries", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
await user.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.getByTestId("add_entry_ooo").click();
await page.locator('[id="date"]').click();
await selectToAndFromDates(page, "13", "22");
// send request
await saveAndWaitForResponse(page);
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
// add another entry
await page.getByTestId("add_entry_ooo").click();
await page.locator('[id="date"]').click();
await selectToAndFromDates(page, "11", "24");
// send request
await saveAndWaitForResponse(page);
await expect(page.locator(`data-testid=table-redirect-n-a`)).toHaveCount(2);
});
test("User cannot create duplicate entries", async ({ page, users }) => {
const user = await users.create({ name: "userOne" });
await user.apiLogin();
await page.goto("/settings/my-account/out-of-office");
await page.getByTestId("add_entry_ooo").click();
await page.locator('[id="date"]').click();
await selectToAndFromDates(page, "13", "22");
// send request
await saveAndWaitForResponse(page);
await expect(page.locator(`data-testid=table-redirect-n-a`)).toBeVisible();
// add another entry
await page.getByTestId("add_entry_ooo").click();
await page.locator('[id="date"]').click();
await selectToAndFromDates(page, "13", "22");
// send request
await saveAndWaitForResponse(page, 409);
});
});
async function saveAndWaitForResponse(page: Page) {
async function saveAndWaitForResponse(page: Page, expectedStatusCode = 200) {
await submitAndWaitForResponse(page, "/api/trpc/viewer/outOfOfficeCreateOrUpdate?batch=1", {
action: () => page.getByTestId("create-or-edit-entry-ooo-redirect").click(),
expectedStatusCode,
});
}
async function selectToAndFromDates(page: Page, fromDate: string, toDate: string, isRangeInPast = false) {
// deselects by default selected range start day
await page.locator(`button[name="day"].rdp-day_range_start`).click();
const month = isRangeInPast ? "previous" : "next";
await page.locator(`button[name="${month}-month"]`).click();
await page.locator(`button[name="day"]:has-text("${fromDate}")`).nth(0).click();
await page.locator(`button[name="day"]:has-text("${toDate}")`).nth(0).click();
}
+57 -3
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "الإجراء المطلوب إعادة الجدولة: يُرجى حجز وقت جديد لمدة {{eventType}} مع {{name}}",
"hi_user_name": "مرحبا {{name}}",
"ics_event_title": "{{eventType}} مع {{name}}",
"please_book_a_time_sometime_later": "عفواً، لم نتمكن من الاتصال بك هذه المرة. الرجاء جدولة مكالمة مستقبلية بدلاً من ذلك",
"please_book_a_time_sometime_later": "يبدو أن جميع خبرائنا مشغولون حاليًا. يرجى تحديد موعد مكالمة في الوقت الذي يناسبك وسنكون جاهزين لمساعدتك.",
"schedule_instead": "حدد موعدًا بدلاً من ذلك",
"new_event_subject": "حدث جديد: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "الانضمام بواسطة {{entryPoint}}",
"notes": "ملاحظات",
@@ -350,9 +351,13 @@
"other": "آخر",
"email_sign_in_subject": "رابط تسجيل الدخول بك لـ{{appName}}",
"round_robin_emailed_you_and_attendees": "أنت تجتمع مع {{user}}. أرسلنا بريداً إلكترونياً مع دعوة اجتماع بكل التفاصيل إلى الجميع.",
"round_robin_emailed_host_and_attendee": "{{host}} سيجتمع مع {{attendee}}. لقد أرسلنا بريداً إلكترونياً يحتوي على دعوة تقويم مع كافة التفاصيل للجميع.",
"emailed_you_and_attendees": "لقد أرسلنا إليك وإلى الحضور الآخرين دعوة للتقويم عبر البريد الإلكتروني تتضمن كل التفاصيل.",
"emailed_host_and_attendee": "لقد أرسلنا بريداً إلكترونياً يحتوي على دعوة تقويم مع كافة التفاصيل للجميع.",
"emailed_you_and_attendees_recurring": "لقد أرسلنا إليك وإلى الحضور الآخرين دعوة تقويم عبر البريد الإلكتروني لأول هذه الأحداث المتكررة.",
"round_robin_emailed_you_and_attendees_recurring": "أنت تجتمع مع {{user}}. أرسلنا بريداً إلكترونياً مع دعوة اجتماع بكل التفاصيل إلى الجميع لأول اجتماع من هذه الاجتماعات الدورية.",
"emailed_host_and_attendee_recurring": "لقد أرسلنا بريداً إلكترونياً يحتوي على دعوة تقويم مع كافة التفاصيل للجميع للحدث الأول من هذه الأحداث المتكررة.",
"round_robin_emailed_you_and_attendees_recurring": "أنت على موعد مع {{user}}. لقد أرسلنا بريداً إلكترونياً يحتوي على دعوة تقويم مع كافة التفاصيل للجميع للحدث الأول من هذه الأحداث المتكررة.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} سيجتمع مع {{attendee}}. لقد أرسلنا بريداً إلكترونياً يحتوي على دعوة تقويم مع كافة التفاصيل للجميع للحدث الأول من هذه الأحداث المتكررة.",
"emailed_you_and_any_other_attendees": "تم إرسال هذه المعلومات إليك وإلى الحضور الآخرين عبر البريد الإلكتروني.",
"needs_to_be_confirmed_or_rejected": "لا يزال الحجز الخاص بك يحتاج إلى التأكيد أو الرفض.",
"needs_to_be_confirmed_or_rejected_recurring": "لا يزال الاجتماع المتكرر الخاص بك بحاجة إلى التأكيد أو الرفض.",
@@ -405,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": "لا يمكنك إلغاء هذا الحجز",
@@ -1573,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": "يرسل رسالة بريد إلكتروني إلى المالك عند إرسال النموذج",
@@ -2210,7 +2218,9 @@
"add_times_to_your_email": "حدّد بعض الفترات المتاحة وأدرجها في رسالتك الإلكترونية",
"select_time": "اختيار الوقت",
"select_date": "اختيار التاريخ",
"connecting_you_to_someone": "نقوم بربطك بشخص ما.",
"only_show_if_parameter_set": "إظهار فقط في حالة تعيين المعامل",
"add_parameter": "إضافة معامل",
"connecting_you_to_someone": "نقوم بربطك بشخص من {{orgName}}.",
"please_do_not_close_this_tab": "من فضلك لا تغلق أو تحدّث هذه الصفحة",
"see_all_available_times": "رؤية كل الأوقات المتاحة",
"org_team_names_example_1": "مثال، فريق التسويق",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "تم تحديث حدود الحجز بنجاح",
"you_are_unauthorized_to_make_this_change_to_the_booking": "أنت غير مخول لإجراء هذا التغيير على الحجز",
"matching_members": "الأعضاء المطابقون",
"x_matching_members": "{{x}} عضو مطابق",
"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": "لا يوجد أعضاء مطابقين. سيتم الرجوع إلى استخدام أعضاء الفريق المعينين لنوع الحدث. فكر في إضافة بديل أو تصحيح منطق استخدام الأعضاء البديلين",
"hide_calendar_event_details": "إخفاء تفاصيل الأحداث في التقويمات المشتركة",
@@ -2717,6 +2730,8 @@
"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": "تطابق منطق السمات",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "هل أنت متأكد أنك تريد إعادة تعيين هذا الحجز لمضيف غير متاح؟",
"reassign_available_team_member_description": "هل أنت متأكد أنك تريد إعادة تعيين هذا الحجز؟",
"yes_reassign": "نعم، أعد التعيين",
"filter_by_attributes": "تصفية حسب السمات",
"reassign_reason": "سبب إعادة التعيين",
"reassigned_by": "تمت إعادة التعيين بواسطة",
"row": "صف",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "معلومات وقت انتهاء الحدث في المنطقة الزمنية للحاضر",
"salesforce_create_event_on_contact": "إنشاء حدث على جهة الاتصال، إذا كانت موجودة. وإلا، الرجوع إلى العميل المحتمل",
"salesforce_owner_name_to_change": "اسم المالك المراد تغييره",
"no_filter_set": "لم يتم تعيين أي تصفية",
"booking_dry_run_successful": "تم إجراء المحاكاة التجريبية للحجز بنجاح",
"dont_have_access_this_page": "ليس لديك صلاحية الوصول إلى هذه الصفحة",
"you_need_admin_or_owner_privileges_to_access": "لرؤية هذه الصفحة أو التفاعل معها، يجب أن تكون لديك صلاحيات المدير أو المالك",
"field_type": "نوع الحقل",
@@ -2755,6 +2773,42 @@
"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": "اسم حقل البحث",
"filter_operator_is": "يساوي",
"filter_operator_is_not": "لا يساوي",
"filter_operator_contains": "يحتوي على",
"filter_operator_does_not_contain": "لا يحتوي على",
"filter_operator_starts_with": "يبدأ بـ",
"filter_operator_ends_with": "ينتهي بـ",
"filter_operator_is_empty": "فارغ",
"filter_operator_not_empty": "غير فارغ",
"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": "استبعاد رسائل البريد الإلكتروني التي تحتوي على ...",
"exclude_emails_match_found_error_message": "يرجى إدخال عنوان بريد إلكتروني صالح للعمل",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ أضف السلاسل الجديدة أعلاه هنا ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+58 -4
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Təxirə salma tələb olunur: {{eventType}} {{name}} ilə yeni vaxt təyin edin",
"hi_user_name": "Salam {{name}}",
"ics_event_title": "{{eventType}} {{name}} ilə",
"please_book_a_time_sometime_later": "Bağışlayın, bu dəfə əlaqə qura bilmədik. Zəhmət olmasa, gələcək bir zəng üçün vaxt təyin edin",
"please_book_a_time_sometime_later": "Görünür ki, hazırda bütün ekspertlərimiz məşğuldur. Zəhmət olmasa, sizə ən uyğun olan vaxtı seçin və biz sizə kömək etməyə hazır olacağıq.",
"schedule_instead": "Əvəzində planlaşdırın",
"new_event_subject": "Yeni tədbir: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "{{entryPoint}} ilə qoşulun",
"notes": "Qeydlər",
@@ -350,9 +351,13 @@
"other": "Digər",
"email_sign_in_subject": "{{appName}} üçün giriş linkiniz",
"round_robin_emailed_you_and_attendees": "{{user}} ilə görüşürsünüz. Təfərrüatları olan təqvim dəvəti ilə hər kəsə e-poçt göndərdik.",
"round_robin_emailed_host_and_attendee": "{{host}} {{attendee}} ilə görüşür. Təfərrüatları olan təqvim dəvəti ilə hər kəsə e-poçt göndərdik.",
"emailed_you_and_attendees": "Təfərrüatları olan təqvim dəvəti ilə hər kəsə e-poçt göndərdik.",
"emailed_host_and_attendee": "Təfərrüatları olan təqvim dəvəti ilə hər kəsə e-poçt göndərdik.",
"emailed_you_and_attendees_recurring": "Bu təkrarlanan hadisələrin birincisi üçün təfərrüatları olan təqvim dəvəti ilə hər kəsə e-poçt göndərdik.",
"round_robin_emailed_you_and_attendees_recurring": "{{user}} ilə görüşürsünüz. Bu təkrarlanan hadisələrin birincisi üçün təfərrüatları olan təqvim dəvəti ilə hər kəsə e-poçt göndərdik.",
"emailed_host_and_attendee_recurring": "Bu təkrarlanan tədbirlərin birincisi üçün təfərrüatları olan təqvim dəvəti ilə hər kəsə e-poçt göndərdik.",
"round_robin_emailed_you_and_attendees_recurring": "Siz {{user}} ilə görüşürsünüz. Bu təkrarlanan tədbirlərin birincisi üçün təfərrüatları olan təqvim dəvəti ilə hər kəsə e-poçt göndərdik.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} {{attendee}} ilə görüşür. Bu təkrarlanan tədbirlərin birincisi üçün təfərrüatları olan təqvim dəvəti ilə hər kəsə e-poçt göndərdik.",
"emailed_you_and_any_other_attendees": "Bu məlumatla hər kəsə e-poçt göndərdik.",
"needs_to_be_confirmed_or_rejected": "Rezervasiyanız hələ də təsdiqlənməli və ya rədd edilməlidir.",
"needs_to_be_confirmed_or_rejected_recurring": "Təkrarlanan görüşünüz hələ də təsdiqlənməli və ya rədd edilməlidir.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Rezervasiyalara geri dön",
"free_to_pick_another_event_type": "Başqa bir tədbir növünü seçməkdə sərbəstsiniz.",
"cancelled": "Ləğv edildi",
"no_booking": "Rezervasiya yoxdur",
"awaiting_host": "Ev sahibini gözləyir",
"cancellation_successful": "Ləğv uğurla tamamlandı",
"really_cancel_booking": "Rezervasiyanı həqiqətən ləğv etmək istəyirsiniz?",
"cannot_cancel_booking": "Bu rezervasiyanı ləğv edə bilməzsiniz",
@@ -1242,7 +1249,7 @@
"number_provided": "Telefon nömrəsi təmin ediləcək",
"before_event_trigger": "tədbir başlamazdan əvvəl",
"event_cancelled_trigger": "tədbir ləğv edildikdə",
"after_hosts_cal_video_no_show": "Ev sahibləri Cal video görüşünə qoşulmadıqdan sonra",
"after_hosts_cal_video_no_show": "Təşkilatçılar Cal video görüşünə qoşulmadıqdan sonra",
"after_guests_cal_video_no_show": "Qonaqlar Cal video görüşünə qoşulmadıqdan sonra",
"new_event_trigger": "yeni tədbir sifariş edildikdə",
"email_host_action": "ev sahibinə e-poçt göndərin",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Marşrutlaşdırma",
"routing_forms_description": "İştirakçıları düzgün istiqamətlərə yönləndirmək üçün formalar yaradın",
"routing_forms_send_email_owner": "Sahibə e-poçt göndər",
"routing_forms_send_email_owner_description": "Forma təqdim edildikdə sahibə e-poçt göndərir",
@@ -2210,7 +2218,9 @@
"add_times_to_your_email": "Mövcud vaxtları seçin və Email-ə əlavə edin",
"select_time": "Vaxtı Seçin",
"select_date": "Tarixi Seçin",
"connecting_you_to_someone": "Sizi kiməsə qoşuruq.",
"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",
"org_team_names_example_1": "məs. Marketinq Komandası",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "Rezervasiya məhdudiyyətləri uğurla yeniləndi",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Bu dəyişikliyi etmək üçün səlahiyyətiniz yoxdur",
"matching_members": "Uyğun üzvlər",
"x_matching_members": "{{x}} uyğun üzv",
"matching_members_queue_using_attribute_weights": "Üzvlərin uyğunlaşdırılması növbəsi (atribut çəkilərindən istifadə edərək)",
"matching_members_queue_using_event_assignee_weights": "Üzvlərin uyğunlaşdırılması növbəsi (tədbir təyin edən çəkilərindən istifadə edərək)",
"no_matching_members": "Uyğun üzvlər yoxdur. Bu halda tədbir növünə təyin edilmiş komanda üzvləri istifadə olunacaq.",
"no_matching_members_will_fallback_to_all_assigned_members": "Uyğun üzvlər tapılmadı. Tədbir növünə təyin edilmiş komanda üzvlərindən istifadə ediləcək. Ehtiyat variant əlavə etməyi və ya using_fallback_members məntiqini düzəltməyi nəzərdən keçirin",
"hide_calendar_event_details": "Təqvim hadisə detallarını gizlət",
@@ -2717,6 +2730,8 @@
"add_new_field": "Yeni sahə əlavə et",
"you_dont_have_access_to_reroute_this_booking": "Bu rezervasiyanı yönləndirmək üçün icazəniz yoxdur",
"form_response_not_found": "Form cavabı tapılmadı",
"contact_owner": "Əlaqə sahibi",
"contact_owner_not_found": "Tapılmadı",
"using_fallback_members": "Ehtiyat üzvlərdən istifadə edilir",
"chosen_route": "Seçilmiş marşrut",
"attribute_logic_matched": "Atribut məntiqi uyğun gəldi",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "Bu rezervasiyanı mövcud olmayan ev sahibinə yenidən təyin etmək istədiyinizə əminsiniz?",
"reassign_available_team_member_description": "Bu rezervasiyanı yenidən təyin etmək istədiyinizə əminsiniz?",
"yes_reassign": "Bəli, yenidən təyin et",
"filter_by_attributes": "Atributlara görə filtrlə",
"reassign_reason": "Yenidən təyin etmə səbəbi",
"reassigned_by": "Yenidən təyin edən",
"row": "sətir",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "İştirakçı Saat Qurşağında Tədbirin Bitmə Vaxtı",
"salesforce_create_event_on_contact": "Əgər mövcuddursa, kontakt üzərində hadisə yaradın. Əks halda, lidə qayıdın",
"salesforce_owner_name_to_change": "Dəyişdiriləcək sahibin adı",
"no_filter_set": "Filtr təyin edilməyib",
"booking_dry_run_successful": "Rezervasiya test yoxlaması uğurlu oldu",
"dont_have_access_this_page": "Bu səhifəyə girişiniz yoxdur",
"you_need_admin_or_owner_privileges_to_access": "Bu səhifəni görmək və ya onunla qarşılıqlı əlaqədə olmaq üçün admin və ya sahib imtiyazlarına malik olmalısınız",
"field_type": "Sahə növü",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Rezervasiyanın başlama tarixi",
"booking_created_date": "Rezervasiyanın yaradılma tarixi",
"booking_reassigned_to_host": "Rezervasiya {{host}}-a yenidən təyin edildi",
"no_contact_owner": "Əlaqə sahibi yoxdur",
"routing_forms_created": "Marşrutlaşdırma formaları yaradıldı",
"routing_forms_total_responses": "Ümumi cavablar",
"routing_forms_total_responses_without_booking": "Rezervasiyasız ümumi cavablar",
"routing_forms_total_responses_with_booking": "Rezervasiyalı ümumi cavablar",
"routing_form_insights": "Rezervasiya yoxdur",
"routing_form_insights_form_name_header": "Formanın adı",
"routing_form_insights_booking_status_header": "Rezervasiya",
"routing_form_insights_response_no_booking": "Rezervasiya yoxdur",
"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ı",
"filter_operator_is": "Bərabərdir",
"filter_operator_is_not": "Bərabər deyil",
"filter_operator_contains": "Daxildir",
"filter_operator_does_not_contain": "Daxil deyil",
"filter_operator_starts_with": "İlə başlayır",
"filter_operator_ends_with": "İlə bitir",
"filter_operator_is_empty": "Boşdur",
"filter_operator_not_empty": "Boş deyil",
"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",
"rr_distribution_method_availability_description": "Görüş təşkilatçısı mövcud olduqda, görüşlərin bron edilməsinə imkan verir. Bu, potensial görüşlərin sayını maksimuma çatdırmaq və görüşlərin təşkilatçılar arasında bərabər paylanmasının daha az əhəmiyyətli olduğu hallarda istifadə olunur.",
"rr_distribution_method_balanced_title": "Yük balanslaşdırma",
"rr_distribution_method_balanced_description": "Hər bir təşkilatçı ilə neçə bron edildiyini izləyəcəyik və bunu digərləri ilə müqayisə edəcəyik, çox irəlidə olan bəzi təşkilatçıları deaktiv edərək bronların bərabər paylanmasını təmin edəcəyik.",
"exclude_emails_that_contain": "Tərkibində olan e-poçtları istisna et...",
"exclude_emails_match_found_error_message": "Zəhmət olmasa düzgün iş e-poçt ünvanı daxil edin",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Yeni sətirləri bura əlavə edin ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+59 -5
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Akce vyžadovaná k přeplánování: zarezervujte si nový čas pro událost {{eventType}} s osobou {{name}}",
"hi_user_name": "Hezký den, {{name}}",
"ics_event_title": "{{eventType}} s {{name}}",
"please_book_a_time_sometime_later": "Jejda, tentokrát jsme vás nemohli spojit. Prosím, naplánujte si hovor na později.",
"please_book_a_time_sometime_later": "Zdá se, že všichni naši experti jsou momentálně zaneprázdněni. Naplánujte si prosím hovor na čas, který vám nejvíce vyhovuje, a my vám rádi pomůžeme.",
"schedule_instead": "Naplánovat schůzku",
"new_event_subject": "Nová událost: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Připojit se k {{entryPoint}}",
"notes": "Poznámky",
@@ -350,9 +351,13 @@
"other": "Ostatní",
"email_sign_in_subject": "Váš přihlašovací odkaz pro {{appName}}",
"round_robin_emailed_you_and_attendees": "Setkáte se s {{user}}. Poslali jsme e-mail s pozvánkou do kalendáře a podrobnostmi všem účastníkům.",
"round_robin_emailed_host_and_attendee": "{{host}} se setká s {{attendee}}. Všem jsme poslali e-mail s pozvánkou do kalendáře a podrobnostmi.",
"emailed_you_and_attendees": "Poslali jsme vám a ostatním účastníkům e-mail s pozvánkou a podrobnostmi.",
"emailed_host_and_attendee": "Všem jsme poslali e-mail s pozvánkou do kalendáře a podrobnostmi.",
"emailed_you_and_attendees_recurring": "Na první z těchto opakovaných událostí jsme vám a ostatním účastníkům poslali e-mail s pozvánkou a podrobnostmi.",
"round_robin_emailed_you_and_attendees_recurring": "Setkáte se s {{user}}. Poslali jsme e-mail s pozvánkou do kalendáře a podrobnostmi všem účastníkům pro první z těchto opakujících se událostí.",
"emailed_host_and_attendee_recurring": "Všem jsme poslali e-mail s pozvánkou do kalendáře a podrobnostmi pro první z těchto opakujících se událostí.",
"round_robin_emailed_you_and_attendees_recurring": "Setkáte se s {{user}}. Všem jsme poslali e-mail s pozvánkou do kalendáře a podrobnostmi pro první z těchto opakujících se událostí.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} se setká s {{attendee}}. Všem jsme poslali e-mail s pozvánkou do kalendáře a podrobnostmi pro první z těchto opakujících se událostí.",
"emailed_you_and_any_other_attendees": "Poslali jsme vám a ostatním účastníkům e-mail s těmito informacemi.",
"needs_to_be_confirmed_or_rejected": "Vaše rezervace bude teprve schválena nebo zamítnuta.",
"needs_to_be_confirmed_or_rejected_recurring": "Vaše opakovaná schůzka musí být ještě potvrzena nebo zamítnuta.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Zpět na rezervace",
"free_to_pick_another_event_type": "Kdykoliv si můžete vybrat jinou událost.",
"cancelled": "Zrušeno",
"no_booking": "Žádná rezervace",
"awaiting_host": "Čeká se na hostitele",
"cancellation_successful": "Zrušení proběhlo úspěšně",
"really_cancel_booking": "Opravdu chcete zrušit svou rezervaci?",
"cannot_cancel_booking": "Tuto rezervaci nelze zrušit",
@@ -1242,8 +1249,8 @@
"number_provided": "Bude uvedeno telefonní číslo",
"before_event_trigger": "před zahájením události",
"event_cancelled_trigger": "při zrušení události",
"after_hosts_cal_video_no_show": "Po tom, co se hostitelé nepřipojí k cal video",
"after_guests_cal_video_no_show": "Po tom, co se hosté nepřipojí k cal video",
"after_hosts_cal_video_no_show": "Poté, co se hostitelé nepřipojili k video hovoru",
"after_guests_cal_video_no_show": "Poté, co se hosté nepřipojili k video hovoru",
"new_event_trigger": "při rezervaci nové události",
"email_host_action": "odeslání e-mailu hostiteli",
"email_attendee_action": "odeslat e-mail účastníkům",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Směrování",
"routing_forms_description": "Vytvořit formuláře pro přesměrování účastníků do správných destinací",
"routing_forms_send_email_owner": "Odeslat e-mail vlastníkovi",
"routing_forms_send_email_owner_description": "Po odeslání formuláře bude vlastníkovi odeslán e-mail",
@@ -2210,7 +2218,9 @@
"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",
"connecting_you_to_someone": "Spojujeme vás s někým.",
"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",
"org_team_names_example_1": "Např. marketingový tým",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "Limity rezervací byly úspěšně aktualizovány",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nemáte oprávnění provést tuto změnu v rezervaci",
"matching_members": "Odpovídající členové",
"x_matching_members": "{{x}} odpovídajících členů",
"matching_members_queue_using_attribute_weights": "Fronta přiřazování členů (podle vah atributů)",
"matching_members_queue_using_event_assignee_weights": "Fronta přiřazování členů (podle vah účastníků události)",
"no_matching_members": "Žádní odpovídající členové. Bude použito záložní řešení s členy týmu přiřazenými k typu události.",
"no_matching_members_will_fallback_to_all_assigned_members": "Žádní odpovídající členové. Použijí se všichni členové týmu přiřazení k typu události. Zvažte přidání záložního plánu nebo opravu logiky using_fallback_members",
"hide_calendar_event_details": "Skrýt podrobnosti událostí v kalendáři na sdílených kalendářích",
@@ -2717,6 +2730,8 @@
"add_new_field": "Přidat nové pole",
"you_dont_have_access_to_reroute_this_booking": "Nemáte přístup k přesměrování této rezervace",
"form_response_not_found": "Odpověď formuláře nebyla nalezena",
"contact_owner": "Vlastník kontaktu",
"contact_owner_not_found": "Nenalezeno",
"using_fallback_members": "Používají se záložní členové",
"chosen_route": "Zvolená cesta",
"attribute_logic_matched": "Logika atributů odpovídá",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "Opravdu chcete přeřadit tuto rezervaci nedostupnému hostiteli?",
"reassign_available_team_member_description": "Opravdu chcete tuto rezervaci přeřadit?",
"yes_reassign": "Ano, přeřadit",
"filter_by_attributes": "Filtrovat podle atributů",
"reassign_reason": "Důvod přeřazení",
"reassigned_by": "Přeřazeno kým",
"row": "řádek",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "Čas konce události v časovém pásmu účastníka",
"salesforce_create_event_on_contact": "Vytvořit událost na kontaktu, pokud existuje. Jinak použít lead",
"salesforce_owner_name_to_change": "Jméno vlastníka ke změně",
"no_filter_set": "Není nastaven žádný filtr",
"booking_dry_run_successful": "Testovací rezervace proběhla úspěšně",
"dont_have_access_this_page": "K této stránce nemáte přístup",
"you_need_admin_or_owner_privileges_to_access": "Pro zobrazení nebo interakci s touto stránkou musíte mít administrátorská nebo vlastnická práva",
"field_type": "Typ pole",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Datum začátku rezervace",
"booking_created_date": "Datum vytvoření rezervace",
"booking_reassigned_to_host": "Rezervace přeřazena hostiteli {{host}}",
"no_contact_owner": "Bez vlastníka kontaktu",
"routing_forms_created": "Vytvořené směrovací formuláře",
"routing_forms_total_responses": "Celkový počet odpovědí",
"routing_forms_total_responses_without_booking": "Celkový počet odpovědí bez rezervace",
"routing_forms_total_responses_with_booking": "Celkový počet odpovědí s rezervací",
"routing_form_insights": "Žádné rezervace",
"routing_form_insights_form_name_header": "Název formuláře",
"routing_form_insights_booking_status_header": "Rezervace",
"routing_form_insights_response_no_booking": "Bez rezervace",
"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",
"filter_operator_is": "Je",
"filter_operator_is_not": "Není",
"filter_operator_contains": "Obsahuje",
"filter_operator_does_not_contain": "Neobsahuje",
"filter_operator_starts_with": "Začíná na",
"filter_operator_ends_with": "Končí na",
"filter_operator_is_empty": "Je prázdné",
"filter_operator_not_empty": "Není prázdné",
"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",
"rr_distribution_method_availability_description": "Umožňuje rezervujícím sjednat schůzky kdykoliv, když je hostitel k dispozici. Použijte tuto možnost pro maximalizaci počtu potenciálních schůzek a v případech, kdy rovnoměrné rozdělení schůzek mezi hostitele není tak důležité.",
"rr_distribution_method_balanced_title": "Vyvažování zátěže",
"rr_distribution_method_balanced_description": "Budeme sledovat, kolik rezervací bylo provedeno u jednotlivých hostitelů a porovnávat je s ostatními. Některé hostitele, kteří jsou příliš napřed, dočasně deaktivujeme, aby byly rezervace rovnoměrně distribuovány.",
"exclude_emails_that_contain": "Vyloučit e-maily obsahující...",
"exclude_emails_match_found_error_message": "Zadejte prosím platnou pracovní e-mailovou adresu",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Přidejte své nové řetězce nahoru ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+58 -4
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Handling Påkrævet Omlægning: Reservér en ny tid for {{eventType}} med {{name}}",
"hi_user_name": "Hej {{name}}",
"ics_event_title": "{{eventType}} med {{name}}",
"please_book_a_time_sometime_later": "Ups, vi kunne ikke forbinde dig denne gang. Venligst planlæg et opkald på et senere tidspunkt.",
"please_book_a_time_sometime_later": "Det ser ud til, at alle vores eksperter er optaget lige nu. Venligst planlæg et opkald på et tidspunkt, der passer dig bedst, så vi er klar til at hjælpe dig.",
"schedule_instead": "Planlæg i stedet",
"new_event_subject": "Ny begivenhed: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Deltag via {{entryPoint}}",
"notes": "Noter",
@@ -350,9 +351,13 @@
"other": "Flere",
"email_sign_in_subject": "Dit login link til {{appName}}",
"round_robin_emailed_you_and_attendees": "Du mødes med {{user}}. Vi har sendt en e-mail med en kalenderinvitation med detaljerne til alle.",
"round_robin_emailed_host_and_attendee": "{{host}} mødes med {{attendee}}. Vi har sendt en e-mail med en kalenderinvitation med detaljerne til alle.",
"emailed_you_and_attendees": "Vi har sendt dig og de andre deltagere en kalenderinvitation med alle detaljerne.",
"emailed_host_and_attendee": "Vi har sendt en e-mail med en kalenderinvitation med detaljerne til alle.",
"emailed_you_and_attendees_recurring": "Vi har sendt dig og de andre deltagere en kalenderinvitation til den første af disse tilbagevendende begivenheder.",
"emailed_host_and_attendee_recurring": "Vi har sendt en e-mail med en kalenderinvitation med detaljerne til alle for den første af disse tilbagevendende begivenheder.",
"round_robin_emailed_you_and_attendees_recurring": "Du mødes med {{user}}. Vi har sendt en e-mail med en kalenderinvitation med detaljerne til alle for den første af disse tilbagevendende begivenheder.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} mødes med {{attendee}}. Vi har sendt en e-mail med en kalenderinvitation med detaljerne til alle for den første af disse tilbagevendende begivenheder.",
"emailed_you_and_any_other_attendees": "Du og eventuelle andre deltagere er blevet mailet med disse oplysninger.",
"needs_to_be_confirmed_or_rejected": "Din booking mangler stadig at blive bekræftet eller afvist.",
"needs_to_be_confirmed_or_rejected_recurring": "Dit tilbagevendende møde mangler stadig at blive bekræftet eller afvist.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Tilbage til bookinger",
"free_to_pick_another_event_type": "Du er til enhver tid velkommen til at vælge en anden begivenhed.",
"cancelled": "Annulleret",
"no_booking": "Ingen booking",
"awaiting_host": "Venter på vært",
"cancellation_successful": "Annullering lykkedes",
"really_cancel_booking": "Vil du virkelig annullere din booking?",
"cannot_cancel_booking": "Du kan ikke annullere denne booking",
@@ -1242,8 +1249,8 @@
"number_provided": "Telefonnummer vil blive angivet",
"before_event_trigger": "før begivenheden starter",
"event_cancelled_trigger": "når begivenheden er aflyst",
"after_hosts_cal_video_no_show": "Efter værter ikke deltager i cal video",
"after_guests_cal_video_no_show": "Efter gæster ikke deltager i cal video",
"after_hosts_cal_video_no_show": "Efter værterne ikke deltog i cal-videomødet",
"after_guests_cal_video_no_show": "Efter gæsterne ikke deltog i cal-videomødet",
"new_event_trigger": "når ny begivenhed er booket",
"email_host_action": "send e-mail til vært",
"email_attendee_action": "send e-mail til deltagere",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Routing",
"routing_forms_description": "Opret formularer for at dirigere deltagere til de korrekte destinationer",
"routing_forms_send_email_owner": "Send e-mail til Ejer",
"routing_forms_send_email_owner_description": "Sender en e-mail til ejeren når formularen er indsendt",
@@ -2210,7 +2218,9 @@
"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",
"connecting_you_to_someone": "Vi forbinder dig med en person.",
"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",
"org_team_names_example_1": "f.eks. Marketingteam",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "Bookinggrænser opdateret med succes",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Du har ikke tilladelse til at foretage denne ændring i bookingen",
"matching_members": "Matchende medlemmer",
"x_matching_members": "{{x}} matchende medlemmer",
"matching_members_queue_using_attribute_weights": "Matchende medlemskø (bruger attributvægte)",
"matching_members_queue_using_event_assignee_weights": "Matchende medlemskø (bruger begivenhedsansvarlig-vægte)",
"no_matching_members": "Ingen matchende medlemmer. Det vil falde tilbage til at bruge de teammedlemmer, der er tildelt til begivenhedstypen.",
"no_matching_members_will_fallback_to_all_assigned_members": "Ingen matchende medlemmer. Der vil falde tilbage til at bruge de teammedlemmer, der er tildelt begivenhedstypen. Overvej at tilføje en fallback eller korrigere logikken for using_fallback_members",
"hide_calendar_event_details": "Skjul kalenderbegivenhedsdetaljer på delte kalendere",
@@ -2717,6 +2730,8 @@
"add_new_field": "Tilføj nyt felt",
"you_dont_have_access_to_reroute_this_booking": "Du har ikke adgang til at omdirigere denne booking",
"form_response_not_found": "Formularbesvarelse ikke fundet",
"contact_owner": "Kontaktejer",
"contact_owner_not_found": "Ikke fundet",
"using_fallback_members": "Bruger fallback-medlemmer",
"chosen_route": "Valgt rute",
"attribute_logic_matched": "Attributlogik matchet",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "Er du sikker på, at du vil omfordele denne booking til en utilgængelig vært?",
"reassign_available_team_member_description": "Er du sikker på, at du vil omfordele denne booking?",
"yes_reassign": "Ja, omfordel",
"filter_by_attributes": "Filtrer efter attributter",
"reassign_reason": "Årsag til omfordeling",
"reassigned_by": "Omfordelt af",
"row": "række",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "Begivenhedens sluttidspunkt i deltagers tidszone",
"salesforce_create_event_on_contact": "Opret begivenhed på kontakt, hvis den eksisterer. Ellers fald tilbage til lead",
"salesforce_owner_name_to_change": "Ejernavn der skal ændres",
"no_filter_set": "Intet filter indstillet",
"booking_dry_run_successful": "Booking-testkørsel gennemført",
"dont_have_access_this_page": "Du har ikke adgang til denne side",
"you_need_admin_or_owner_privileges_to_access": "For at kunne se eller interagere med denne side, skal du have administrator- eller ejerrettigheder",
"field_type": "Felttype",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Bookings startdato",
"booking_created_date": "Dato for oprettelse af booking",
"booking_reassigned_to_host": "Booking omfordelt til {{host}}",
"no_contact_owner": "Ingen kontaktejer",
"routing_forms_created": "Oprettede routingformularer",
"routing_forms_total_responses": "Samlede besvarelser",
"routing_forms_total_responses_without_booking": "Samlede besvarelser uden booking",
"routing_forms_total_responses_with_booking": "Samlede besvarelser med booking",
"routing_form_insights": "Ingen bookinger",
"routing_form_insights_form_name_header": "Formularnavn",
"routing_form_insights_booking_status_header": "Booking",
"routing_form_insights_response_no_booking": "Ingen booking",
"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",
"filter_operator_is": "Er",
"filter_operator_is_not": "Er ikke",
"filter_operator_contains": "Indeholder",
"filter_operator_does_not_contain": "Indeholder ikke",
"filter_operator_starts_with": "Starter med",
"filter_operator_ends_with": "Slutter med",
"filter_operator_is_empty": "Er tom",
"filter_operator_not_empty": "Er ikke tom",
"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",
"rr_distribution_method_availability_description": "Giver bookere mulighed for at booke møder, når en vært er tilgængelig. Brug dette for at maksimere antallet af potentielle møder, der kan bookes, når den jævne fordeling af møder på tværs af værter er mindre vigtig.",
"rr_distribution_method_balanced_title": "Load balancing",
"rr_distribution_method_balanced_description": "Vi vil overvåge, hvor mange bookinger der er foretaget med hver vært og sammenligne dette med andre, og deaktivere nogle værter, der er for langt foran, så bookinger fordeles jævnt.",
"exclude_emails_that_contain": "Ekskluder e-mails, der indeholder ...",
"exclude_emails_match_found_error_message": "Indtast venligst en gyldig arbejds-e-mailadresse",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Tilføj dine nye strenge ovenfor her ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+59 -5
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Aktion erforderlich - Neuplanung: Bitte buchen Sie eine neue Zeit für „{{eventType}}“ mit {{name}}",
"hi_user_name": "Hallo, {{name}}",
"ics_event_title": "{{eventType}} mit {{name}}",
"please_book_a_time_sometime_later": "Ups, wir konnten Sie dieses Mal nicht verbinden. Bitte planen Sie stattdessen einen zukünftigen Anruf",
"please_book_a_time_sometime_later": "Es scheint, dass alle unsere Experten momentan beschäftigt sind. Bitte vereinbaren Sie einen Termin zu einem Zeitpunkt, der Ihnen am besten passt, und wir werden bereit sein, Ihnen zu helfen.",
"schedule_instead": "Stattdessen planen",
"new_event_subject": "Neuer Termin: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "{{entryPoint}} beitreten",
"notes": "Notizen",
@@ -350,9 +351,13 @@
"other": "Sonstige",
"email_sign_in_subject": "Ihr Anmelde-Link für {{appName}}",
"round_robin_emailed_you_and_attendees": "Sie treffen sich mit {{user}}. Wir haben eine E-Mail mit einer Kalendereinladung mit den Details an alle gesendet.",
"round_robin_emailed_host_and_attendee": "{{host}} trifft sich mit {{attendee}}. Wir haben allen eine E-Mail mit einer Kalendereinladung und den Details gesendet.",
"emailed_you_and_attendees": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details an alle gesendet.",
"emailed_host_and_attendee": "Wir haben allen eine E-Mail mit einer Kalendereinladung und den Details gesendet.",
"emailed_you_and_attendees_recurring": "Wir haben eine E-Mail mit einer Kalendereinladung mit den Details für das erste dieser wiederkehrenden Termine an alle gesendet.",
"round_robin_emailed_you_and_attendees_recurring": "Sie treffen sich mit {{user}}. Wir haben eine E-Mail mit einer Kalendereinladung mit den Details für das erste dieser wiederkehrenden Termine an alle gesendet.",
"emailed_host_and_attendee_recurring": "Wir haben allen eine E-Mail mit einer Kalendereinladung und den Details für das erste dieser wiederkehrenden Events gesendet.",
"round_robin_emailed_you_and_attendees_recurring": "Sie treffen sich mit {{user}}. Wir haben allen eine E-Mail mit einer Kalendereinladung und den Details für das erste dieser wiederkehrenden Events gesendet.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} trifft sich mit {{attendee}}. Wir haben allen eine E-Mail mit einer Kalendereinladung und den Details für das erste dieser wiederkehrenden Events gesendet.",
"emailed_you_and_any_other_attendees": "Wir haben eine E-Mail mit diesen Informationen an alle gesendet.",
"needs_to_be_confirmed_or_rejected": "Ihr Termin muss noch bestätigt oder abgelehnt werden.",
"needs_to_be_confirmed_or_rejected_recurring": "Ihr wiederkehrender Termin muss noch bestätigt oder abgelehnt werden.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Zurück zu den Buchungen",
"free_to_pick_another_event_type": "Sie können jederzeit einen anderen Termin wählen.",
"cancelled": "Abgesagt",
"no_booking": "Keine Buchung",
"awaiting_host": "Warten auf Gastgeber",
"cancellation_successful": "Stornierung erfolgreich",
"really_cancel_booking": "Möchten Sie wirklich stornieren?",
"cannot_cancel_booking": "Sie können diese Buchung nicht stornieren",
@@ -1242,8 +1249,8 @@
"number_provided": "Telefonnummer wird angegeben",
"before_event_trigger": "vor Beginn des Termins",
"event_cancelled_trigger": "wenn der Termin abgesagt wird",
"after_hosts_cal_video_no_show": "Nachdem Gastgeber nicht zum Cal-Video erscheinen",
"after_guests_cal_video_no_show": "Nachdem Gäste nicht zum Cal-Video erscheinen",
"after_hosts_cal_video_no_show": "Nachdem die Gastgeber nicht am Cal-Video teilgenommen haben",
"after_guests_cal_video_no_show": "Nachdem die Gäste nicht am Cal-Video teilgenommen haben",
"new_event_trigger": "wenn ein neuer Termin gebucht wird",
"email_host_action": "E-Mail an den Veranstalter senden",
"email_attendee_action": "E-Mail an Teilnehmende senden",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Weiterleitung",
"routing_forms_description": "Erstellen Sie Formulare , um Teilnehmer zu den richtigen Stellen weiterzuleiten",
"routing_forms_send_email_owner": "E-Mail an Eigentümer senden",
"routing_forms_send_email_owner_description": "Sendet eine E-Mail an den Eigentümer, wenn das Formular abgeschickt wird",
@@ -2210,7 +2218,9 @@
"add_times_to_your_email": "Zeiten zur E-Mail hinzufügen",
"select_time": "Zeit auswählen",
"select_date": "Datum auswählen",
"connecting_you_to_someone": "Wir verbinden Sie mit jemandem.",
"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",
"org_team_names_example_1": "z.B. Marketing-Team",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "Buchungslimits erfolgreich aktualisiert",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Sie sind nicht berechtigt, diese Änderung an der Buchung vorzunehmen",
"matching_members": "Passende Mitglieder",
"x_matching_members": "{{x}} passende Mitglieder",
"matching_members_queue_using_attribute_weights": "Warteschlange für passende Mitglieder (mit Attributgewichtung)",
"matching_members_queue_using_event_assignee_weights": "Warteschlange für passende Mitglieder (mit Gewichtung der Terminverantwortlichen)",
"no_matching_members": "Keine passenden Mitglieder gefunden. Es wird auf die dem Ereignistyp zugewiesenen Teammitglieder zurückgegriffen.",
"no_matching_members_will_fallback_to_all_assigned_members": "Keine passenden Mitglieder gefunden. Es wird auf alle dem Ereignistyp zugewiesenen Teammitglieder zurückgegriffen. Erwägen Sie, eine Fallback-Option hinzuzufügen oder die Logik für using_fallback_members zu korrigieren",
"hide_calendar_event_details": "Kalenderereignisdetails in geteilten Kalendern verbergen",
@@ -2717,6 +2730,8 @@
"add_new_field": "Neues Feld hinzufügen",
"you_dont_have_access_to_reroute_this_booking": "Sie haben keine Berechtigung, diese Buchung umzuleiten",
"form_response_not_found": "Formularantwort nicht gefunden",
"contact_owner": "Kontaktinhaber",
"contact_owner_not_found": "Nicht gefunden",
"using_fallback_members": "Verwendung von Fallback-Mitgliedern",
"chosen_route": "Gewählte Route",
"attribute_logic_matched": "Attributlogik zutreffend",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "Sind Sie sicher, dass Sie diese Buchung einem nicht verfügbaren Gastgeber neu zuweisen möchten?",
"reassign_available_team_member_description": "Sind Sie sicher, dass Sie diese Buchung neu zuweisen möchten?",
"yes_reassign": "Ja, neu zuweisen",
"filter_by_attributes": "Nach Attributen filtern",
"reassign_reason": "Grund für die Neuzuweisung",
"reassigned_by": "Neu zugewiesen von",
"row": "Zeile",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "Ereignis-Endzeit in der Zeitzone des Teilnehmers",
"salesforce_create_event_on_contact": "Event für Kontakt erstellen, falls vorhanden. Andernfalls auf Lead zurückgreifen",
"salesforce_owner_name_to_change": "Zu ändernder Besitzername",
"no_filter_set": "Kein Filter gesetzt",
"booking_dry_run_successful": "Buchungs-Testlauf erfolgreich",
"dont_have_access_this_page": "Sie haben keinen Zugriff auf diese Seite",
"you_need_admin_or_owner_privileges_to_access": "Um diese Seite anzuzeigen oder mit ihr zu interagieren, benötigen Sie Administrator- oder Eigentümerrechte",
"field_type": "Feldtyp",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Buchungs-Startdatum",
"booking_created_date": "Buchungs-Erstellungsdatum",
"booking_reassigned_to_host": "Buchung an {{host}} neu zugewiesen",
"no_contact_owner": "Kein Kontaktinhaber",
"routing_forms_created": "Erstellte Weiterleitungsformulare",
"routing_forms_total_responses": "Gesamtantworten",
"routing_forms_total_responses_without_booking": "Gesamtantworten ohne Buchung",
"routing_forms_total_responses_with_booking": "Gesamtantworten mit Buchung",
"routing_form_insights": "Keine Buchungen",
"routing_form_insights_form_name_header": "Formularname",
"routing_form_insights_booking_status_header": "Buchung",
"routing_form_insights_response_no_booking": "Keine Buchung",
"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",
"filter_operator_is": "Ist",
"filter_operator_is_not": "Ist nicht",
"filter_operator_contains": "Enthält",
"filter_operator_does_not_contain": "Enthält nicht",
"filter_operator_starts_with": "Beginnt mit",
"filter_operator_ends_with": "Endet mit",
"filter_operator_is_empty": "Ist leer",
"filter_operator_not_empty": "Ist nicht leer",
"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",
"rr_distribution_method_availability_description": "Ermöglicht Buchenden, Meetings zu buchen, wann immer ein Host verfügbar ist. Nutzen Sie dies, um die Anzahl potenzieller Meetings zu maximieren, wenn die gleichmäßige Verteilung der Meetings auf die Hosts weniger wichtig ist.",
"rr_distribution_method_balanced_title": "Lastverteilung",
"rr_distribution_method_balanced_description": "Wir überwachen, wie viele Buchungen bei jedem Host eingegangen sind und vergleichen dies mit anderen. Hosts, die zu weit voraus sind, werden temporär deaktiviert, damit die Buchungen gleichmäßig verteilt werden.",
"exclude_emails_that_contain": "E-Mails ausschließen, die ... enthalten",
"exclude_emails_match_found_error_message": "Bitte geben Sie eine gültige Geschäfts-E-Mail-Adresse ein",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Fügen Sie Ihre neuen Code-Zeilen über dieser hinzu ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
File diff suppressed because it is too large Load Diff
+50 -5
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Action Required Reschedule: Please book a new time for {{eventType}} with {{name}}",
"hi_user_name": "Hi {{name}}",
"ics_event_title": "{{eventType}} with {{name}}",
"please_book_a_time_sometime_later": "Oops, we couldn't connect you this time. Please schedule a future call instead",
"please_book_a_time_sometime_later": "It looks like all of our experts are busy right now. Please schedule a call at a time that works best for you and we'll be ready to assist you.",
"schedule_instead": "Schedule instead",
"new_event_subject": "New event: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Join by {{entryPoint}}",
"notes": "Notes",
@@ -409,6 +410,8 @@
"back_to_bookings": "Back to bookings",
"free_to_pick_another_event_type": "Feel free to pick another event anytime.",
"cancelled": "Canceled",
"no_booking": "No booking",
"awaiting_host": "Awaiting host",
"cancellation_successful": "Cancellation successful",
"really_cancel_booking": "Really cancel your booking?",
"cannot_cancel_booking": "You cannot cancel this booking",
@@ -1246,8 +1249,8 @@
"number_provided": "Phone number will be provided",
"before_event_trigger": "before event starts",
"event_cancelled_trigger": "when event is canceled",
"after_hosts_cal_video_no_show": "After hosts don't join cal video",
"after_guests_cal_video_no_show": "After guests don't join cal video",
"after_hosts_cal_video_no_show": "After hosts didn't join cal video",
"after_guests_cal_video_no_show": "After guests didn't join cal video",
"new_event_trigger": "when new event is booked",
"email_host_action": "send email to host",
"email_attendee_action": "send email to attendees",
@@ -1577,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Routing",
"routing_forms_description": "Create forms to direct attendees to the correct destinations",
"routing_forms_send_email_owner": "Send Email to Owner",
"routing_forms_send_email_owner_description": "Sends an email to the owner when the form is submitted",
@@ -2046,7 +2050,7 @@
"looking_for_more_analytics": "Looking for more analytics?",
"looking_for_more_insights": "Looking for more Insights?",
"filters": "Filters",
"add_filter": "Add filter",
"add_filter": "Add filter",
"add_rule": "Add rule",
"add_rule_group": "Add rule group",
"remove_filters": "Clear all filters",
@@ -2214,7 +2218,9 @@
"add_times_to_your_email": "Select a few available times and embed them in your Email",
"select_time": "Select Time",
"select_date": "Select Date",
"connecting_you_to_someone": "We are connecting you to someone.",
"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",
"org_team_names_example_1": "e.g. Marketing Team",
@@ -2676,6 +2682,9 @@
"booking_limits_updated_successfully": "Booking limits updated successfully",
"you_are_unauthorized_to_make_this_change_to_the_booking": "You are unauthorized to make this change to the booking",
"matching_members": "Matching members",
"x_matching_members": "{{x}} matching members",
"matching_members_queue_using_attribute_weights": "Matching members queue (using attribute weights)",
"matching_members_queue_using_event_assignee_weights": "Matching members queue (using event assignee weights)",
"no_matching_members": "No matching members. It will fallback to using the team members assigned to the event type.",
"no_matching_members_will_fallback_to_all_assigned_members": "No matching members. It will fallback to using the team members assigned to the event type. Consider adding a fallback or correcting the logic of using_fallback_members",
"hide_calendar_event_details": "Hide calendar event details on shared calendars",
@@ -2725,6 +2734,8 @@
"add_new_field": "Add new field",
"you_dont_have_access_to_reroute_this_booking": "You don't have access to reroute this booking",
"form_response_not_found": "Form response not found",
"contact_owner": "Contact owner",
"contact_owner_not_found": "Not found",
"using_fallback_members": "Using fallback members",
"chosen_route": "Chosen Route",
"attribute_logic_matched": "Attribute logic matched",
@@ -2741,6 +2752,7 @@
"reassign_unavailable_team_member_description": "Are you sure you want to reassign this booking to an unavailable host?",
"reassign_available_team_member_description": "Are you sure you want to reassign this booking?",
"yes_reassign": "Yes, reassign",
"filter_by_attributes": "Filter by attributes",
"reassign_reason": "Reason for reassigning",
"reassigned_by": "Reassigned by",
"row": "row",
@@ -2753,6 +2765,8 @@
"event_end_time_in_attendee_timezone_info": "Event End Time In Attendee Timezone",
"salesforce_create_event_on_contact": "Create event on contact, if it exists. Else fallback to lead",
"salesforce_owner_name_to_change": "Owner name to change",
"no_filter_set": "No filter set",
"booking_dry_run_successful": "Booking dry run successful",
"dont_have_access_this_page": "You don't have access to this page",
"you_need_admin_or_owner_privileges_to_access": "In order to view or interact with this page, you must have admin or owner privileges",
"field_type": "Field type",
@@ -2763,11 +2777,42 @@
"booking_start_date": "Booking start date",
"booking_created_date": "Booking created date",
"booking_reassigned_to_host": "Booking reassigned to {{host}}",
"no_contact_owner": "No contact owner",
"routing_forms_created": "Routing Forms Created",
"routing_forms_total_responses": "Total Responses",
"routing_forms_total_responses_without_booking": "Total Responses Without Booking",
"routing_forms_total_responses_with_booking": "Total Responses With Booking",
"routing_form_insights": "No Bookings",
"routing_form_insights_form_name_header": "Form Name",
"routing_form_insights_booking_status_header": "Booking",
"routing_form_insights_response_no_booking": "No Booking",
"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",
"filter_operator_is": "Is",
"filter_operator_is_not": "Is not",
"filter_operator_contains": "Contains",
"filter_operator_does_not_contain": "Does not contain",
"filter_operator_starts_with": "Starts with",
"filter_operator_ends_with": "Ends with",
"filter_operator_is_empty": "Is empty",
"filter_operator_not_empty": "Not empty",
"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",
"rr_distribution_method_availability_description": "Allows bookers to book meetings whenever a host is available. Use this to maximize the number of potential meetings booked and when the even distribution of meetings across hosts is less important.",
"rr_distribution_method_balanced_title": "Load balancing",
"rr_distribution_method_balanced_description": "We will monitor how many bookings have been made with each host and compare this with others, disabling some hosts that are too far ahead so bookings are evenly distributed.",
"exclude_emails_that_contain" : "Exclude emails that contain ...",
"exclude_emails_match_found_error_message" : "Please enter a valid work email address",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Acción requerida para reprogramar: Por favor, reserva un nuevo horario para {{eventType}} con {{name}}",
"hi_user_name": "Hola {{name}}",
"ics_event_title": "{{eventType}} con {{name}}",
"please_book_a_time_sometime_later": "Ups, no pudimos conectarte esta vez. Por favor, programa una llamada futura en su lugar",
"please_book_a_time_sometime_later": "Parece que todos nuestros expertos están ocupados en este momento. Por favor, agenda una llamada en el horario que más te convenga y estaremos listos para ayudarte.",
"schedule_instead": "Agendar ahora",
"new_event_subject": "Nuevo evento: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Únete por {{entryPoint}}",
"notes": "Notas",
@@ -350,9 +351,13 @@
"other": "Otro",
"email_sign_in_subject": "Tu enlace de inicio de sesión para {{appName}}",
"round_robin_emailed_you_and_attendees": "Te reunirás con {{user}}. Enviamos un correo electrónico con una invitación de calendario con los detalles a todos.",
"round_robin_emailed_host_and_attendee": "{{host}} se reunirá con {{attendee}}. Enviamos un correo electrónico con una invitación de calendario con los detalles a todos.",
"emailed_you_and_attendees": "Enviamos un correo electrónico con una invitación de calendario con los detalles a todos.",
"emailed_host_and_attendee": "Enviamos un correo electrónico con una invitación de calendario con los detalles a todos.",
"emailed_you_and_attendees_recurring": "Enviamos un correo electrónico con una invitación de calendario con los detalles a todos para el primero de estos eventos recurrentes.",
"emailed_host_and_attendee_recurring": "Enviamos un correo electrónico con una invitación de calendario con los detalles a todos para el primero de estos eventos recurrentes.",
"round_robin_emailed_you_and_attendees_recurring": "Te reunirás con {{user}}. Enviamos un correo electrónico con una invitación de calendario con los detalles a todos para el primero de estos eventos recurrentes.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} se reunirá con {{attendee}}. Enviamos un correo electrónico con una invitación de calendario con los detalles a todos para el primero de estos eventos recurrentes.",
"emailed_you_and_any_other_attendees": "Enviamos un correo electrónico a todos con esta información.",
"needs_to_be_confirmed_or_rejected": "Tu reserva aún necesita ser confirmada o rechazada.",
"needs_to_be_confirmed_or_rejected_recurring": "Tu reunión recurrente aún necesita ser confirmada o rechazada.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Volver a las reservas",
"free_to_pick_another_event_type": "Siéntete libre de elegir otro evento en cualquier momento.",
"cancelled": "Cancelado",
"no_booking": "Sin reserva",
"awaiting_host": "Esperando al anfitrión",
"cancellation_successful": "Cancelación exitosa",
"really_cancel_booking": "¿Realmente quieres cancelar tu reserva?",
"cannot_cancel_booking": "No puedes cancelar esta reserva",
@@ -1242,8 +1249,8 @@
"number_provided": "Se proporcionará el número de teléfono",
"before_event_trigger": "antes de que comience el evento",
"event_cancelled_trigger": "cuando el evento está cancelado",
"after_hosts_cal_video_no_show": "Después de que los anfitriones no se unan al video de Cal",
"after_guests_cal_video_no_show": "Después de que los invitados no se unan al video de Cal",
"after_hosts_cal_video_no_show": "Después de que los anfitriones no se unieron a la videollamada",
"after_guests_cal_video_no_show": "Después de que los invitados no se unieron a la videollamada",
"new_event_trigger": "cuando se reserva un nuevo evento",
"email_host_action": "enviar correo electrónico al anfitrión",
"email_attendee_action": "enviar correo electrónico a los asistentes",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Enrutamiento",
"routing_forms_description": "Cree formularios para dirigir a los asistentes a los destinos correctos",
"routing_forms_send_email_owner": "Enviar correo electrónico al propietario",
"routing_forms_send_email_owner_description": "Envía un correo electrónico al propietario cuando se envía el formulario",
@@ -2210,7 +2218,9 @@
"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",
"connecting_you_to_someone": "Te estamos conectando con alguien.",
"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",
"org_team_names_example_1": "p. ej. Equipo de Marketing",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "Límites de reservas actualizados con éxito",
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
"matching_members": "Miembros coincidentes",
"x_matching_members": "{{x}} miembros coincidentes",
"matching_members_queue_using_attribute_weights": "Cola de miembros coincidentes (usando pesos de atributos)",
"matching_members_queue_using_event_assignee_weights": "Cola de miembros coincidentes (usando pesos de asignados al evento)",
"no_matching_members": "No hay miembros coincidentes. Se utilizarán los miembros del equipo asignados al tipo de evento como alternativa.",
"no_matching_members_will_fallback_to_all_assigned_members": "No hay miembros que coincidan. Se utilizarán los miembros del equipo asignados al tipo de evento como respaldo. Considera agregar un respaldo o corregir la lógica de using_fallback_members",
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
@@ -2717,6 +2730,8 @@
"add_new_field": "Agregar nuevo campo",
"you_dont_have_access_to_reroute_this_booking": "No tienes acceso para redirigir esta reserva",
"form_response_not_found": "Respuesta del formulario no encontrada",
"contact_owner": "Propietario del contacto",
"contact_owner_not_found": "No encontrado",
"using_fallback_members": "Usando miembros de respaldo",
"chosen_route": "Ruta elegida",
"attribute_logic_matched": "Lógica de atributos coincidente",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "¿Estás seguro de que quieres reasignar esta reserva a un anfitrión no disponible?",
"reassign_available_team_member_description": "¿Estás seguro de que quieres reasignar esta reserva?",
"yes_reassign": "Sí, reasignar",
"filter_by_attributes": "Filtrar por atributos",
"reassign_reason": "Motivo de la reasignación",
"reassigned_by": "Reasignado por",
"row": "fila",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "Hora de finalización del evento en la zona horaria del asistente",
"salesforce_create_event_on_contact": "Crear evento en el contacto, si existe. De lo contrario, usar lead como alternativa",
"salesforce_owner_name_to_change": "Nombre del propietario a cambiar",
"no_filter_set": "Sin filtros establecidos",
"booking_dry_run_successful": "Prueba de reserva exitosa",
"dont_have_access_this_page": "No tienes acceso a esta página",
"you_need_admin_or_owner_privileges_to_access": "Para ver o interactuar con esta página, debes tener privilegios de administrador o propietario",
"field_type": "Tipo de campo",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Fecha de inicio de la reserva",
"booking_created_date": "Fecha de creación de la reserva",
"booking_reassigned_to_host": "Reserva reasignada a {{host}}",
"no_contact_owner": "Sin propietario del contacto",
"routing_forms_created": "Formularios de enrutamiento creados",
"routing_forms_total_responses": "Respuestas totales",
"routing_forms_total_responses_without_booking": "Respuestas totales sin reserva",
"routing_forms_total_responses_with_booking": "Respuestas totales con reserva",
"routing_form_insights": "Sin reservas",
"routing_form_insights_form_name_header": "Nombre del formulario",
"routing_form_insights_booking_status_header": "Reserva",
"routing_form_insights_response_no_booking": "Sin reserva",
"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",
"filter_operator_is": "Es",
"filter_operator_is_not": "No es",
"filter_operator_contains": "Contiene",
"filter_operator_does_not_contain": "No contiene",
"filter_operator_starts_with": "Empieza con",
"filter_operator_ends_with": "Termina con",
"filter_operator_is_empty": "Está vacío",
"filter_operator_not_empty": "No está vacío",
"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",
"rr_distribution_method_availability_description": "Permite a los usuarios agendar reuniones cuando un anfitrión esté disponible. Usa esta opción para maximizar el número de reuniones potenciales y cuando la distribución equitativa entre anfitriones es menos importante.",
"rr_distribution_method_balanced_title": "Equilibrio de carga",
"rr_distribution_method_balanced_description": "Monitoreamos cuántas reservas se han realizado con cada anfitrión y las comparamos con otros, deshabilitando algunos anfitriones que están muy adelantados para que las reservas se distribuyan de manera uniforme.",
"exclude_emails_that_contain": "Excluir correos electrónicos que contengan ...",
"exclude_emails_match_found_error_message": "Por favor, ingresa una dirección de correo electrónico laboral válida",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agrega tus nuevas cadenas arriba de esta línea ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+58 -4
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Reprogramar acción requerida: reserva una nueva hora para {{eventType}} con {{name}}",
"hi_user_name": "Hola {{name}}",
"ics_event_title": "{{eventType}} con {{name}}",
"please_book_a_time_sometime_later": "Vaya, no pudimos conectarte esta vez. Por favor, programa una llamada futura.",
"please_book_a_time_sometime_later": "Parece que todos nuestros expertos están ocupados en este momento. Por favor, programa una llamada en el horario que mejor te convenga y estaremos listos para ayudarte.",
"schedule_instead": "Programar ahora",
"new_event_subject": "Nuevo Evento: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Únase por {{entryPoint}}",
"notes": "Notas",
@@ -350,9 +351,13 @@
"other": "Otro",
"email_sign_in_subject": "Su enlace de inicio de sesión para {{appName}}",
"round_robin_emailed_you_and_attendees": "Te reunirás con {{user}}. Hemos enviado un correo electrónico con una invitación de calendario con los detalles a todos.",
"round_robin_emailed_host_and_attendee": "{{host}} se reunirá con {{attendee}}. Hemos enviado un correo electrónico con una invitación de calendario con los detalles a todos.",
"emailed_you_and_attendees": "Le enviamos a usted y a los demás asistentes una invitación al calendario con todos los detalles.",
"emailed_host_and_attendee": "Hemos enviado un correo electrónico con una invitación de calendario con los detalles a todos.",
"emailed_you_and_attendees_recurring": "Le enviamos a usted y a los demás asistentes una invitación al calendario para el primero de estos eventos recurrentes.",
"emailed_host_and_attendee_recurring": "Hemos enviado un correo electrónico con una invitación de calendario con los detalles a todos para el primero de estos eventos recurrentes.",
"round_robin_emailed_you_and_attendees_recurring": "Te reunirás con {{user}}. Hemos enviado un correo electrónico con una invitación de calendario con los detalles a todos para el primero de estos eventos recurrentes.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} se reunirá con {{attendee}}. Hemos enviado un correo electrónico con una invitación de calendario con los detalles a todos para el primero de estos eventos recurrentes.",
"emailed_you_and_any_other_attendees": "Usted y cualquier otro asistente han recibido esta información.",
"needs_to_be_confirmed_or_rejected": "Su reserva todavía necesita ser confirmada o rechazada.",
"needs_to_be_confirmed_or_rejected_recurring": "Su reunión recurrente todavía necesita confirmarse o rechazarse.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Volver a Reservas",
"free_to_pick_another_event_type": "No dude en elegir otro evento en cualquier momento.",
"cancelled": "Cancelado",
"no_booking": "Sin reserva",
"awaiting_host": "Esperando al anfitrión",
"cancellation_successful": "Cancelación Realizada",
"really_cancel_booking": "¿Realmente deseas cancelar tu reserva?",
"cannot_cancel_booking": "No puedes cancelar esta reserva",
@@ -1242,8 +1249,8 @@
"number_provided": "Se proporcionará el número de teléfono",
"before_event_trigger": "antes del inicio del evento",
"event_cancelled_trigger": "cuando se cancela el evento",
"after_hosts_cal_video_no_show": "Después de que los anfitriones no se unan al video de Cal",
"after_guests_cal_video_no_show": "Después de que los invitados no se unan al video de Cal",
"after_hosts_cal_video_no_show": "Después de que los anfitriones no se unieron al video de Cal",
"after_guests_cal_video_no_show": "Después de que los invitados no se unieron al video de Cal",
"new_event_trigger": "cuando se reserva un nuevo evento",
"email_host_action": "enviar correo electrónico al anfitrión",
"email_attendee_action": "enviar correo electrónico a los asistentes",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Enrutamiento",
"routing_forms_description": "Crea formularios para dirigir a los asistentes a sus destinos correctos",
"routing_forms_send_email_owner": "Enviar correo electrónico al propietario",
"routing_forms_send_email_owner_description": "Mande un correo electrónico al propietario cuando se envía el formulario",
@@ -2210,7 +2218,9 @@
"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",
"connecting_you_to_someone": "Te estamos conectando con alguien.",
"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",
"org_team_names_example_1": "ej. Equipo de marketing",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "Límites de reservas actualizados con éxito",
"you_are_unauthorized_to_make_this_change_to_the_booking": "No tienes autorización para realizar este cambio en la reserva",
"matching_members": "Miembros coincidentes",
"x_matching_members": "{{x}} miembros coincidentes",
"matching_members_queue_using_attribute_weights": "Cola de miembros coincidentes (usando pesos de atributos)",
"matching_members_queue_using_event_assignee_weights": "Cola de miembros coincidentes (usando pesos de asignados al evento)",
"no_matching_members": "No hay miembros coincidentes. Se utilizarán los miembros del equipo asignados al tipo de evento como alternativa.",
"no_matching_members_will_fallback_to_all_assigned_members": "No hay miembros coincidentes. Se utilizarán los miembros del equipo asignados al tipo de evento como alternativa. Considera añadir una opción de respaldo o corregir la lógica de using_fallback_members",
"hide_calendar_event_details": "Ocultar detalles de eventos del calendario en calendarios compartidos",
@@ -2717,6 +2730,8 @@
"add_new_field": "Añadir nuevo campo",
"you_dont_have_access_to_reroute_this_booking": "No tienes acceso para redirigir esta reserva",
"form_response_not_found": "Respuesta del formulario no encontrada",
"contact_owner": "Propietario del contacto",
"contact_owner_not_found": "No encontrado",
"using_fallback_members": "Usando miembros de respaldo",
"chosen_route": "Ruta elegida",
"attribute_logic_matched": "Lógica de atributos coincidente",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "¿Estás seguro de que quieres reasignar esta reserva a un anfitrión no disponible?",
"reassign_available_team_member_description": "¿Estás seguro de que quieres reasignar esta reserva?",
"yes_reassign": "Sí, reasignar",
"filter_by_attributes": "Filtrar por atributos",
"reassign_reason": "Motivo de la reasignación",
"reassigned_by": "Reasignado por",
"row": "fila",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "Hora de finalización del evento en la zona horaria del asistente",
"salesforce_create_event_on_contact": "Crear evento en el contacto, si existe. De lo contrario, usar el lead",
"salesforce_owner_name_to_change": "Nombre del propietario a cambiar",
"no_filter_set": "Ningún filtro establecido",
"booking_dry_run_successful": "Prueba de reserva exitosa",
"dont_have_access_this_page": "No tienes acceso a esta página",
"you_need_admin_or_owner_privileges_to_access": "Para ver o interactuar con esta página, debes tener privilegios de administrador o propietario",
"field_type": "Tipo de campo",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Fecha de inicio de la reserva",
"booking_created_date": "Fecha de creación de la reserva",
"booking_reassigned_to_host": "Reserva reasignada a {{host}}",
"no_contact_owner": "Sin propietario del contacto",
"routing_forms_created": "Formularios de enrutamiento creados",
"routing_forms_total_responses": "Total de respuestas",
"routing_forms_total_responses_without_booking": "Total de respuestas sin reserva",
"routing_forms_total_responses_with_booking": "Total de respuestas con reserva",
"routing_form_insights": "Sin reservas",
"routing_form_insights_form_name_header": "Nombre del formulario",
"routing_form_insights_booking_status_header": "Reserva",
"routing_form_insights_response_no_booking": "Sin reserva",
"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",
"filter_operator_is": "Es",
"filter_operator_is_not": "No es",
"filter_operator_contains": "Contiene",
"filter_operator_does_not_contain": "No contiene",
"filter_operator_starts_with": "Empieza con",
"filter_operator_ends_with": "Termina con",
"filter_operator_is_empty": "Está vacío",
"filter_operator_not_empty": "No está vacío",
"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",
"rr_distribution_method_availability_description": "Permite a los usuarios reservar reuniones cuando un anfitrión esté disponible. Utiliza esta opción para maximizar el número de reuniones potenciales y cuando la distribución equitativa entre anfitriones sea menos importante.",
"rr_distribution_method_balanced_title": "Equilibrio de carga",
"rr_distribution_method_balanced_description": "Monitorizaremos cuántas reservas se han realizado con cada anfitrión y las compararemos con otros, deshabilitando algunos anfitriones que estén muy por delante para que las reservas se distribuyan uniformemente.",
"exclude_emails_that_contain": "Excluir correos que contengan...",
"exclude_emails_match_found_error_message": "Por favor, introduce una dirección de correo electrónico laboral válida",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Agregue sus nuevas cadenas arriba ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+59 -5
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Nõutud ümberajastamine: Palun broneerige oma {{eventType}} üritusele nimega {{name}} uus aeg",
"hi_user_name": "Tere, {{name}}",
"ics_event_title": "{{eventType}} koos {{name}}",
"please_book_a_time_sometime_later": "Vabandust, meil ei olnud võimalik teiega praegu ühendust luua. Ajastage selle asemel tulevane kõne",
"please_book_a_time_sometime_later": "Paistab, et kõik meie eksperdid on hetkel hõivatud. Palun broneeri kõne sulle sobivaks ajaks ja oleme valmis sind aitama.",
"schedule_instead": "Broneeri aeg",
"new_event_subject": "Uus üritus: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Liituge {{entryPoint}} viisil",
"notes": "Märkmed",
@@ -350,9 +351,13 @@
"other": "Muu",
"email_sign_in_subject": "Teie sisselogimislink rakendusele {{appName}}",
"round_robin_emailed_you_and_attendees": "Kohtute kasutajaga {{user}}. Saatsime kõigile meili kalendri kutsega koos üksikasjadega.",
"round_robin_emailed_host_and_attendee": "{{host}} kohtub {{attendee}}-ga. Saatsime kõigile e-kirja koos kalendrikutsega, mis sisaldab üksikasju.",
"emailed_you_and_attendees": "Saatsime kõigile kalendrikutse üksikasjadega meili.",
"emailed_host_and_attendee": "Saatsime kõigile e-kirja koos kalendrikutsega, mis sisaldab üksikasju.",
"emailed_you_and_attendees_recurring": "Saatsime kõigile esimeste korduvate sündmuste üksikasjadega meili kalendrikutsega.",
"round_robin_emailed_you_and_attendees_recurring": "Kohtute kasutajaga {{user}}. Saatsime kõigile kalendrikutse koos üksikasjadega meili esimese korduva sündmuse kohta.",
"emailed_host_and_attendee_recurring": "Saatsime kõigile e-kirja koos kalendrikutsega, mis sisaldab esimese korduva ürituse üksikasju.",
"round_robin_emailed_you_and_attendees_recurring": "Sa kohtud {{user}}-ga. Saatsime kõigile e-kirja koos kalendrikutsega, mis sisaldab esimese korduva ürituse üksikasju.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} kohtub {{attendee}}-ga. Saatsime kõigile e-kirja koos kalendrikutsega, mis sisaldab esimese korduva ürituse üksikasju.",
"emailed_you_and_any_other_attendees": "Saatsime selle teabega kõigile meili.",
"needs_to_be_confirmed_or_rejected": "Teie broneering tuleb veel kinnitada või tagasi lükata.",
"needs_to_be_confirmed_or_rejected_recurring": "Teie korduv koosolek tuleb veel kinnitada või tagasi lükata.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Tagasi broneeringute juurde",
"free_to_pick_another_event_type": "Valige igal ajal mõni muu sündmus.",
"cancelled": "Tühistatud",
"no_booking": "Broneering puudub",
"awaiting_host": "Ootab võõrustajat",
"cancellation_successful": "Tühistamine õnnestus",
"really_cancel_booking": "Kas tõesti tühistada oma broneering?",
"cannot_cancel_booking": "Sa ei saa seda broneeringut tühistada",
@@ -1242,8 +1249,8 @@
"number_provided": "Telefoninumber antakse",
"before_event_trigger": "enne ürituse algust",
"event_cancelled_trigger": "Kui sündmus tühistatakse",
"after_hosts_cal_video_no_show": "Pärast seda, kui võõrustajad ei liitu Cal videokõnega",
"after_guests_cal_video_no_show": "Pärast seda, kui külalised ei liitu Cal videokõnega",
"after_hosts_cal_video_no_show": "Pärast seda, kui võõrustajad ei liitunud cal videoga",
"after_guests_cal_video_no_show": "Pärast seda, kui külalised ei liitunud cal videoga",
"new_event_trigger": "kui uus sündmus on broneeritud",
"email_host_action": "saada e-kiri hostile",
"email_attendee_action": "saada osalejatele e-kiri",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Marsruutimine",
"routing_forms_description": "Loo vormid osalejate suunamiseks õigetesse sihtkohtadesse",
"routing_forms_send_email_owner": "Saada e-kiri omanikule",
"routing_forms_send_email_owner_description": "Saadab vormi esitamisel omanikule meili",
@@ -2210,7 +2218,9 @@
"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",
"connecting_you_to_someone": "Me ühendame teid kellegagi.",
"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",
"org_team_names_example_1": "nt turundusmeeskond",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "Broneerimispiirangud on edukalt uuendatud",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Teil puuduvad õigused selle broneeringu muutmiseks",
"matching_members": "Sobivad liikmed",
"x_matching_members": "{{x}} sobivat liiget",
"matching_members_queue_using_attribute_weights": "Liikmete järjekord vastavalt atribuudi kaaludele",
"matching_members_queue_using_event_assignee_weights": "Liikmete järjekord vastavalt sündmuse määratud isiku kaaludele",
"no_matching_members": "Sobivaid liikmeid ei leitud. Kasutatakse sündmuse tüübile määratud meeskonnaliikmeid.",
"no_matching_members_will_fallback_to_all_assigned_members": "Sobivaid liikmeid ei leitud. Kasutatakse sündmuse tüübile määratud meeskonnaliikmeid. Kaaluge varuvariant lisada või parandada using_fallback_members loogikat",
"hide_calendar_event_details": "Peida kalendrisündmuste üksikasjad jagatud kalendrites",
@@ -2717,6 +2730,8 @@
"add_new_field": "Lisa uus väli",
"you_dont_have_access_to_reroute_this_booking": "Sul pole õigust seda broneeringut ümber suunata",
"form_response_not_found": "Vormi vastust ei leitud",
"contact_owner": "Kontakti omanik",
"contact_owner_not_found": "Ei leitud",
"using_fallback_members": "Kasutatakse varuliikmeid",
"chosen_route": "Valitud tee",
"attribute_logic_matched": "Atribuudi loogika sobis",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "Kas oled kindel, et soovid selle broneeringu määrata mittesaadaolevale võõrustajale?",
"reassign_available_team_member_description": "Kas olete kindel, et soovite selle broneeringu ümber määrata?",
"yes_reassign": "Jah, määra ümber",
"filter_by_attributes": "Filtreeri omaduste järgi",
"reassign_reason": "Ümbermääramise põhjus",
"reassigned_by": "Ümber määranud",
"row": "rida",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "Sündmuse lõpuaeg osaleja ajavööndis",
"salesforce_create_event_on_contact": "Loo sündmus kontaktil, kui see eksisteerib. Vastasel juhul kasuta liidi",
"salesforce_owner_name_to_change": "Muudetava omaniku nimi",
"no_filter_set": "Filter määramata",
"booking_dry_run_successful": "Broneeringu proovikäivitus õnnestus",
"dont_have_access_this_page": "Teil ei ole sellele lehele juurdepääsu",
"you_need_admin_or_owner_privileges_to_access": "Selle lehe vaatamiseks või sellega suhtlemiseks peate omama administraatori või omaniku õigusi",
"field_type": "Välja tüüp",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Broneeringu alguskuupäev",
"booking_created_date": "Broneeringu loomise kuupäev",
"booking_reassigned_to_host": "Broneering määrati ümber {{host}}-le",
"no_contact_owner": "Kontakti omanik puudub",
"routing_forms_created": "Marsruutimisvormid loodud",
"routing_forms_total_responses": "Kokku vastuseid",
"routing_forms_total_responses_without_booking": "Vastused ilma broneeringuta",
"routing_forms_total_responses_with_booking": "Vastused broneeringuga",
"routing_form_insights": "Broneeringud puuduvad",
"routing_form_insights_form_name_header": "Vormi nimi",
"routing_form_insights_booking_status_header": "Broneering",
"routing_form_insights_response_no_booking": "Broneering puudub",
"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",
"filter_operator_is": "On",
"filter_operator_is_not": "Ei ole",
"filter_operator_contains": "Sisaldab",
"filter_operator_does_not_contain": "Ei sisalda",
"filter_operator_starts_with": "Algab",
"filter_operator_ends_with": "Lõpeb",
"filter_operator_is_empty": "On tühi",
"filter_operator_not_empty": "Ei ole tühi",
"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",
"rr_distribution_method_availability_description": "Võimaldab broneerijatel broneerida kohtumisi alati, kui võõrustaja on saadaval. Kasutage seda, et maksimeerida potentsiaalsete broneeritud kohtumiste arvu, kui kohtumiste ühtlane jaotamine võõrustajate vahel on vähem oluline.",
"rr_distribution_method_balanced_title": "Koormuse tasakaalustamine",
"rr_distribution_method_balanced_description": "Jälgime, kui palju broneeringuid on tehtud iga võõrustajaga ja võrdleme seda teistega, keelates mõned võõrustajad, kes on liiga kaugel ees, et broneeringud oleksid ühtlaselt jaotatud.",
"exclude_emails_that_contain": "Välista e-kirjad, mis sisaldavad ...",
"exclude_emails_match_found_error_message": "Palun sisestage kehtiv tööalane e-posti aadress",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
File diff suppressed because it is too large Load Diff
+59 -5
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Replanification requise : veuillez réserver un nouveau créneau pour {{eventType}} avec {{name}}",
"hi_user_name": "Bonjour {{name}}",
"ics_event_title": "{{eventType}} avec {{name}}",
"please_book_a_time_sometime_later": "Oups, nous n'avons pas pu vous connecter cette fois-ci. Veuillez plutôt planifier un appel ultérieur",
"please_book_a_time_sometime_later": "Il semble que tous nos experts soient occupés pour le moment. Veuillez planifier un appel à un moment qui vous convient le mieux et nous serons prêts à vous aider.",
"schedule_instead": "Planifier à la place",
"new_event_subject": "Nouvel événement : {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Rejoindre par {{entryPoint}}",
"notes": "Notes",
@@ -350,9 +351,13 @@
"other": "Autre",
"email_sign_in_subject": "Votre lien de connexion pour {{appName}}",
"round_robin_emailed_you_and_attendees": "Vous avez rendez-vous avec {{user}}. Nous avons envoyé un e-mail avec une invitation de calendrier comprenant les détails à tout le monde.",
"round_robin_emailed_host_and_attendee": "{{host}} a un rendez-vous avec {{attendee}}. Nous avons envoyé un e-mail avec une invitation calendrier contenant tous les détails aux participants.",
"emailed_you_and_attendees": "Nous avons envoyé un e-mail avec une invitation de calendrier comprenant les détails à tout le monde.",
"emailed_host_and_attendee": "Nous avons envoyé un e-mail avec une invitation calendrier contenant tous les détails aux participants.",
"emailed_you_and_attendees_recurring": "Nous avons envoyé un e-mail avec une invitation de calendrier comprenant les détails à tout le monde pour le premier de ces événements récurrents.",
"round_robin_emailed_you_and_attendees_recurring": "Vous avez rendez-vous avec {{user}}. Nous avons envoyé un e-mail avec une invitation de calendrier comprenant les détails à tout le monde pour le premier de ces événements récurrents.",
"emailed_host_and_attendee_recurring": "Nous avons envoyé un e-mail avec une invitation calendrier contenant tous les détails aux participants pour le premier de ces événements récurrents.",
"round_robin_emailed_you_and_attendees_recurring": "Vous avez un rendez-vous avec {{user}}. Nous avons envoyé un e-mail avec une invitation calendrier contenant tous les détails aux participants pour le premier de ces événements récurrents.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} a un rendez-vous avec {{attendee}}. Nous avons envoyé un e-mail avec une invitation calendrier contenant tous les détails aux participants pour le premier de ces événements récurrents.",
"emailed_you_and_any_other_attendees": "Nous avons envoyé cette information à tout le monde par e-mail.",
"needs_to_be_confirmed_or_rejected": "Votre réservation doit encore être confirmée ou refusée.",
"needs_to_be_confirmed_or_rejected_recurring": "Votre rendez-vous récurrent doit encore être confirmé ou refusé.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Retour aux réservations",
"free_to_pick_another_event_type": "N'hésitez pas à choisir un autre événement à tout moment.",
"cancelled": "Annulées",
"no_booking": "Aucune réservation",
"awaiting_host": "En attente de l'hôte",
"cancellation_successful": "Annulation réussie",
"really_cancel_booking": "Voulez-vous vraiment annuler votre réservation ?",
"cannot_cancel_booking": "Vous ne pouvez pas annuler cette réservation",
@@ -1242,8 +1249,8 @@
"number_provided": "Un numéro de téléphone sera fourni",
"before_event_trigger": "avant le début de l'événement",
"event_cancelled_trigger": "lorsque l'événement est annulé",
"after_hosts_cal_video_no_show": "Après que les hôtes ne rejoignent pas la vidéo Cal",
"after_guests_cal_video_no_show": "Après que les invités ne rejoignent pas la vidéo Cal",
"after_hosts_cal_video_no_show": "Après que les hôtes ne se sont pas connectés à l'appel vidéo",
"after_guests_cal_video_no_show": "Après que les invités ne se sont pas connectés à l'appel vidéo",
"new_event_trigger": "lorsqu'un nouvel événement est réservé",
"email_host_action": "envoyer un e-mail à l'hôte",
"email_attendee_action": "envoyer un e-mail aux participants",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Routage",
"routing_forms_description": "Créez des formulaires pour acheminer les participants vers les bonnes destinations.",
"routing_forms_send_email_owner": "Envoyer un e-mail au propriétaire",
"routing_forms_send_email_owner_description": "Envoie un e-mail au propriétaire lorsque le formulaire est soumis",
@@ -2210,7 +2218,9 @@
"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",
"connecting_you_to_someone": "Nous vous mettons en relation avec quelqu'un.",
"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",
"org_team_names_example_1": "p. ex. Équipe marketing",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "Limites de réservation mises à jour avec succès",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Vous n'êtes pas autorisé à apporter cette modification à la réservation",
"matching_members": "Membres correspondants",
"x_matching_members": "{{x}} membres correspondants",
"matching_members_queue_using_attribute_weights": "File d'attente des membres correspondants (selon les pondérations d'attributs)",
"matching_members_queue_using_event_assignee_weights": "File d'attente des membres correspondants (selon les pondérations des assignés à l'événement)",
"no_matching_members": "Aucun membre correspondant. Par défaut, les membres de l'équipe assignés au type d'événement seront utilisés.",
"no_matching_members_will_fallback_to_all_assigned_members": "Aucun membre correspondant. Le système utilisera par défaut les membres de l'équipe assignés au type d'événement. Envisagez d'ajouter une solution de repli ou de corriger la logique d'utilisation des membres de repli",
"hide_calendar_event_details": "Masquer les détails des événements du calendrier sur les calendriers partagés",
@@ -2717,6 +2730,8 @@
"add_new_field": "Ajouter un nouveau champ",
"you_dont_have_access_to_reroute_this_booking": "Vous n'avez pas accès pour rediriger cette réservation",
"form_response_not_found": "Réponse du formulaire non trouvée",
"contact_owner": "Propriétaire du contact",
"contact_owner_not_found": "Non trouvé",
"using_fallback_members": "Utilisation des membres de repli",
"chosen_route": "Itinéraire choisi",
"attribute_logic_matched": "Logique d'attribut correspondante",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "Êtes-vous sûr de vouloir réassigner cette réservation à un hôte indisponible ?",
"reassign_available_team_member_description": "Êtes-vous sûr de vouloir réassigner cette réservation ?",
"yes_reassign": "Oui, réassigner",
"filter_by_attributes": "Filtrer par attributs",
"reassign_reason": "Raison de la réassignation",
"reassigned_by": "Réassigné par",
"row": "ligne",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "Heure de fin de l'événement dans le fuseau horaire du participant",
"salesforce_create_event_on_contact": "Créer un événement sur le contact, s'il existe. Sinon, utiliser le lead comme alternative",
"salesforce_owner_name_to_change": "Nom du propriétaire à modifier",
"no_filter_set": "Aucun filtre défini",
"booking_dry_run_successful": "Test de réservation réussi",
"dont_have_access_this_page": "Vous n'avez pas accès à cette page",
"you_need_admin_or_owner_privileges_to_access": "Pour voir ou interagir avec cette page, vous devez avoir des privilèges d'administrateur ou de propriétaire",
"field_type": "Type de champ",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Date de début de la réservation",
"booking_created_date": "Date de création de la réservation",
"booking_reassigned_to_host": "Réservation réassignée à {{host}}",
"no_contact_owner": "Aucun propriétaire de contact",
"routing_forms_created": "Formulaires de routage créés",
"routing_forms_total_responses": "Réponses totales",
"routing_forms_total_responses_without_booking": "Réponses totales sans réservation",
"routing_forms_total_responses_with_booking": "Réponses totales avec réservation",
"routing_form_insights": "Aucune réservation",
"routing_form_insights_form_name_header": "Nom du formulaire",
"routing_form_insights_booking_status_header": "Réservation",
"routing_form_insights_response_no_booking": "Aucune réservation",
"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",
"filter_operator_is": "Est",
"filter_operator_is_not": "N'est pas",
"filter_operator_contains": "Contient",
"filter_operator_does_not_contain": "Ne contient pas",
"filter_operator_starts_with": "Commence par",
"filter_operator_ends_with": "Se termine par",
"filter_operator_is_empty": "Est vide",
"filter_operator_not_empty": "N'est pas vide",
"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é",
"rr_distribution_method_availability_description": "Permet aux participants de réserver des réunions dès qu'un hôte est disponible. Utilisez cette option pour maximiser le nombre potentiel de réunions réservées lorsque la distribution équitable des réunions entre les hôtes est moins importante.",
"rr_distribution_method_balanced_title": "Équilibrage de charge",
"rr_distribution_method_balanced_description": "Nous surveillerons le nombre de réservations effectuées avec chaque hôte et le comparerons avec les autres, en désactivant certains hôtes trop en avance pour que les réservations soient réparties équitablement.",
"exclude_emails_that_contain": "Exclure les e-mails contenant ...",
"exclude_emails_match_found_error_message": "Veuillez entrer une adresse e-mail professionnelle valide",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Ajoutez vos nouvelles chaînes ci-dessus ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+59 -5
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "הפעולה חייבה קביעת מועד חדש: יש לקבוע מועד חדש עבור {{eventType}} עם {{name}}",
"hi_user_name": "שלום {{name}}",
"ics_event_title": "{{eventType}} עם {{name}}",
"please_book_a_time_sometime_later": "אף אחד לא זמין כרגע. נא לקבוע זימון למועד אחר",
"please_book_a_time_sometime_later": "נראה שכל המומחים שלנו עסוקים כרגע. אנא קבע/י שיחה בזמן שנוח לך ונשמח לסייע לך.",
"schedule_instead": "לקבוע במקום",
"new_event_subject": "אירוע חדש: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "ניתן להצטרף עד {{entryPoint}}",
"notes": "הערות",
@@ -350,9 +351,13 @@
"other": "אחר",
"email_sign_in_subject": "קישור הכניסה שלך אל {{appName}}",
"round_robin_emailed_you_and_attendees": "נקבעה לך פגישה עם {{user}}. שלחנו לך ולמשתתפים האחרים הודעת דוא״ל עם הזמנה בלוח השנה הכוללת את כל הפרטים.",
"round_robin_emailed_host_and_attendee": "{{host}} נפגש/ת עם {{attendee}}. שלחנו לכולם אימייל עם הזמנה ליומן הכוללת את כל הפרטים.",
"emailed_you_and_attendees": "שלחנו לך ולמשתתפים האחרים הודעת דוא\"ל עם הזמנה בלוח השנה הכוללת את כל הפרטים.",
"emailed_host_and_attendee": "שלחנו לכולם אימייל עם הזמנה ליומן הכוללת את כל הפרטים.",
"emailed_you_and_attendees_recurring": "שלחנו לך ולמשתתפים האחרים הודעת דוא\"ל עם הזמנה בלוח השנה לאירוע הראשון מבין האירועים החוזרים האלה.",
"round_robin_emailed_you_and_attendees_recurring": "נקבעה לך פגישה עם {{user}}. שלחנו לך ולמשתתפים האחרים הודעת דוא״ל עם הזמנה בלוח השנה לאירוע הראשון מבין האירועים החוזרים האלה.",
"emailed_host_and_attendee_recurring": "שלחנו לכולם אימייל עם הזמנה ליומן הכוללת את הפרטים עבור האירוע הראשון בסדרת האירועים החוזרים.",
"round_robin_emailed_you_and_attendees_recurring": "את/ה נפגש/ת עם {{user}}. שלחנו לכולם אימייל עם הזמנה ליומן הכוללת את הפרטים עבור האירוע הראשון בסדרת האירועים החוזרים.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} נפגש/ת עם {{attendee}}. שלחנו לכולם אימייל עם הזמנה ליומן הכוללת את הפרטים עבור האירוע הראשון בסדרת האירועים החוזרים.",
"emailed_you_and_any_other_attendees": "שלחנו לך ולמשתתפים האחרים, אם ישנם כאלה, הודעת דוא\"ל עם הפרטים האלה.",
"needs_to_be_confirmed_or_rejected": "ההזמנה שלך עדיין ממתינה לאישור או לדחייה.",
"needs_to_be_confirmed_or_rejected_recurring": "עדיין לא אישרת/דחית את הפגישה החוזרת.",
@@ -405,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": "לא ניתן לבטל את ההזמנה הזו",
@@ -1242,8 +1249,8 @@
"number_provided": "מספר הטלפון יסופק",
"before_event_trigger": "לפני שהאירוע מתחיל",
"event_cancelled_trigger": "כאשר אירוע מבוטל",
"after_hosts_cal_video_no_show": "לאחר שהמארחים לא מצטרפים לשיחת הווידאו ב-Cal",
"after_guests_cal_video_no_show": "לאחר שהאורחים לא מצטרפים לשיחת הווידאו ב-Cal",
"after_hosts_cal_video_no_show": "לאחר שהמארחים לא הצטרפו לשיחת הוידאו",
"after_guests_cal_video_no_show": "לאחר שהאורחים לא הצטרפו לשיחת הוידאו",
"new_event_trigger": "כאשר אירוע חדש מוזמן",
"email_host_action": "שלח מייל למארח",
"email_attendee_action": "שליחת דוא\"ל למשתתפים",
@@ -1573,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": "דוא\"ל נשלח לבעלים לאחר שליחת הטופס",
@@ -2210,7 +2218,9 @@
"add_times_to_your_email": "בחר/י כמה מועדים פנויים והטבע/י אותם בדוא\"ל",
"select_time": "בחירת שעה",
"select_date": "בחירת תאריך",
"connecting_you_to_someone": "אנו מחברים אותך למישהו או מישהי.",
"only_show_if_parameter_set": "הצג רק אם הוגדר פרמטר",
"add_parameter": "הוסף פרמטר",
"connecting_you_to_someone": "אנו מחברים אותך למישהו מ-{{orgName}}.",
"please_do_not_close_this_tab": "אנא אל תסגור או תרענן דף זה",
"see_all_available_times": "לצפייה בכל המועדים הפנויים",
"org_team_names_example_1": "לדוגמה, מחלקת שיווק",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "מגבלות ההזמנה עודכנו בהצלחה",
"you_are_unauthorized_to_make_this_change_to_the_booking": "אין לך הרשאה לבצע שינוי זה בהזמנה",
"matching_members": "חברים תואמים",
"x_matching_members": "{{x}} חברים תואמים",
"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": "הסתר פרטי אירועי לוח שנה בלוחות שנה משותפים",
@@ -2717,6 +2730,8 @@
"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": "לוגיקת המאפיינים תואמת",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "האם אתה בטוח שברצונך להקצות מחדש הזמנה זו למארח שאינו זמין?",
"reassign_available_team_member_description": "האם אתה בטוח שברצונך לשייך מחדש הזמנה זו?",
"yes_reassign": "כן, הקצה מחדש",
"filter_by_attributes": "סינון לפי מאפיינים",
"reassign_reason": "סיבה להקצאה מחדש",
"reassigned_by": "הוקצה מחדש על ידי",
"row": "שורה",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "זמן סיום האירוע באזור הזמן של המשתתף",
"salesforce_create_event_on_contact": "צור אירוע על איש קשר, אם קיים. אחרת, חזור ללידים",
"salesforce_owner_name_to_change": "שם הבעלים לשינוי",
"no_filter_set": "לא הוגדר סינון",
"booking_dry_run_successful": "הרצת הניסיון של ההזמנה הצליחה",
"dont_have_access_this_page": "אין לך גישה לדף זה",
"you_need_admin_or_owner_privileges_to_access": "כדי לצפות או לפעול בדף זה, עליך להיות בעל הרשאות מנהל או בעלים",
"field_type": "סוג שדה",
@@ -2755,6 +2773,42 @@
"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": "שם שדה החיפוש",
"filter_operator_is": "הוא",
"filter_operator_is_not": "אינו",
"filter_operator_contains": "מכיל",
"filter_operator_does_not_contain": "לא מכיל",
"filter_operator_starts_with": "מתחיל ב",
"filter_operator_ends_with": "מסתיים ב",
"filter_operator_is_empty": "ריק",
"filter_operator_not_empty": "לא ריק",
"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": "הוצא כתובות דוא\"ל המכילות...",
"exclude_emails_match_found_error_message": "אנא הזן כתובת דוא\"ל עבודה תקינה",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+59 -5
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Átütmezés szükséges: Kérlek foglalj új időpontot a következőre: {{eventType}}, {{name}}",
"hi_user_name": "Szia {{name}}",
"ics_event_title": "{{eventType}} a következővel: {{name}}",
"please_book_a_time_sometime_later": "Hoppá, ezúttal nem tudtuk összekapcsolni. Kérjük, ütemezzen be egy jövőbeli hívást",
"please_book_a_time_sometime_later": "Úgy tűnik, hogy szakértőink jelenleg mind elfoglaltak. Kérjük, foglaljon időpontot egy önnek megfelelő időpontra, és készséggel állunk majd rendelkezésére.",
"schedule_instead": "Időpontfoglalás",
"new_event_subject": "Új esemény: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Csatlakozás a következővel: {{entryPoint}}",
"notes": "Jegyzetek",
@@ -350,9 +351,13 @@
"other": "Egyéb",
"email_sign_in_subject": "Bejelentkezési link a következőhöz: {{appName}}",
"round_robin_emailed_you_and_attendees": "Találkotó a következővel: {{user}}. Mindenkinek küldtünk egy naptári meghívót a részletekkel.",
"round_robin_emailed_host_and_attendee": "{{host}} találkozik {{attendee}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel.",
"emailed_you_and_attendees": "Mindenkinek küldtünk egy e-mailt a naptár meghívóval és a részletekkel.",
"emailed_host_and_attendee": "Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel.",
"emailed_you_and_attendees_recurring": "Mindenkinek küldtünk e-mailben egy naptár meghívót a részletekkel az első ismétlődő eseményről.",
"round_robin_emailed_you_and_attendees_recurring": "Találkozó a következővel: {{user}}. E-mailben küldtünk egy naptári meghívót mindenkinek az első ismétlődő esemény részleteivel.",
"emailed_host_and_attendee_recurring": "Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
"round_robin_emailed_you_and_attendees_recurring": "Ön találkozik {{user}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} találkozik {{attendee}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
"emailed_you_and_any_other_attendees": "Küldtünk egy e-mail-t mindenkinek ezekről az információkról.",
"needs_to_be_confirmed_or_rejected": "Foglalását még meg kell erősíteni vagy el kell utasítani.",
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozót még meg kell erősíteni vagy el kell utasítani.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Vissza a foglalásokhoz",
"free_to_pick_another_event_type": "Nyugodtan válasszon másik eseményt bármikor.",
"cancelled": "Lemondva",
"no_booking": "Nincs foglalás",
"awaiting_host": "Várakozás a házigazdára",
"cancellation_successful": "Sikeres lemondás",
"really_cancel_booking": "Biztosan lemondod a foglalásod?",
"cannot_cancel_booking": "Ezt a foglalást nem tudod lemondani",
@@ -1242,8 +1249,8 @@
"number_provided": "A telefonszámot megadjuk",
"before_event_trigger": "az esemény kezdete előtt",
"event_cancelled_trigger": "amikor az esemény le lett mondva",
"after_hosts_cal_video_no_show": "Miután a házigazdák nem csatlakoznak a cal videóhoz",
"after_guests_cal_video_no_show": "Miután a vendégek nem csatlakoznak a cal videóhoz",
"after_hosts_cal_video_no_show": "Miután a házigazdák nem csatlakoztak a Cal videóhoz",
"after_guests_cal_video_no_show": "Miután a vendégek nem csatlakoztak a Cal videóhoz",
"new_event_trigger": "új esemény lefoglalásakor",
"email_host_action": "küldjön e-mailt a házigazdának",
"email_attendee_action": "küldjön e-mailt a résztvevőknek",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Útvonal",
"routing_forms_description": "Hozzon létre űrlapokat, amelyek a résztvevőket a megfelelő helyre irányítják",
"routing_forms_send_email_owner": "E-mail küldése a tulajdonosnak",
"routing_forms_send_email_owner_description": "Az űrlap elküldésekor e-mailt küld a tulajdonosnak",
@@ -2210,7 +2218,9 @@
"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",
"connecting_you_to_someone": "Összekapcsolunk valakivel.",
"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",
"org_team_names_example_1": "például. Marketing Csapat",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "A foglalási korlátok sikeresen frissítve",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Nincs jogosultsága a foglalás módosításához",
"matching_members": "Megfelelő tagok",
"x_matching_members": "{{x}} egyező tag",
"matching_members_queue_using_attribute_weights": "Tagok sorba állítása (attribútum súlyozás alapján)",
"matching_members_queue_using_event_assignee_weights": "Tagok sorba állítása (esemény felelős súlyozás alapján)",
"no_matching_members": "Nincsenek megfelelő tagok. Az eseménytípushoz rendelt csapattagok kerülnek felhasználásra.",
"no_matching_members_will_fallback_to_all_assigned_members": "Nincs megfelelő tag. Visszaesik az eseménytípushoz rendelt csapattagok használatára. Fontolja meg egy tartalék hozzáadását vagy a using_fallback_members logika javítását",
"hide_calendar_event_details": "Naptáresemény részleteinek elrejtése megosztott naptárakon",
@@ -2717,6 +2730,8 @@
"add_new_field": "Új mező hozzáadása",
"you_dont_have_access_to_reroute_this_booking": "Nincs jogosultságod átirányítani ezt a foglalást",
"form_response_not_found": "Űrlap válasz nem található",
"contact_owner": "Kapcsolattartó",
"contact_owner_not_found": "Nem található",
"using_fallback_members": "Tartalék tagok használata",
"chosen_route": "Választott útvonal",
"attribute_logic_matched": "Attribútum logika egyezik",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "Biztosan újra szeretnéd rendelni ezt a foglalást egy nem elérhető házigazdához?",
"reassign_available_team_member_description": "Biztosan át szeretné ütemezni ezt a foglalást?",
"yes_reassign": "Igen, újrahozzárendelés",
"filter_by_attributes": "Szűrés tulajdonságok szerint",
"reassign_reason": "Újrahozzárendelés oka",
"reassigned_by": "Újrahozzárendelő",
"row": "sor",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "Esemény befejezési ideje a résztvevő időzónájában",
"salesforce_create_event_on_contact": "Esemény létrehozása a kapcsolaton, ha létezik. Egyébként visszalépés a leadre",
"salesforce_owner_name_to_change": "Módosítandó tulajdonos neve",
"no_filter_set": "Nincs beállított szűrő",
"booking_dry_run_successful": "A foglalás tesztfuttatása sikeres",
"dont_have_access_this_page": "Nincs hozzáférése ehhez az oldalhoz",
"you_need_admin_or_owner_privileges_to_access": "Az oldal megtekintéséhez vagy használatához adminisztrátori vagy tulajdonosi jogosultság szükséges",
"field_type": "Mező típusa",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Foglalás kezdő dátuma",
"booking_created_date": "Foglalás létrehozásának dátuma",
"booking_reassigned_to_host": "Foglalás átirányítva a következő házigazdához: {{host}}",
"no_contact_owner": "Nincs kapcsolattartó",
"routing_forms_created": "Létrehozott útvonalformák",
"routing_forms_total_responses": "Összes válasz",
"routing_forms_total_responses_without_booking": "Összes válasz foglalás nélkül",
"routing_forms_total_responses_with_booking": "Összes válasz foglalással",
"routing_form_insights": "Nincs foglalás",
"routing_form_insights_form_name_header": "Űrlap neve",
"routing_form_insights_booking_status_header": "Foglalás",
"routing_form_insights_response_no_booking": "Nincs foglalás",
"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",
"filter_operator_is": "Egyenlő",
"filter_operator_is_not": "Nem egyenlő",
"filter_operator_contains": "Tartalmazza",
"filter_operator_does_not_contain": "Nem tartalmazza",
"filter_operator_starts_with": "Ezzel kezdődik",
"filter_operator_ends_with": "Ezzel végződik",
"filter_operator_is_empty": "Üres",
"filter_operator_not_empty": "Nem üres",
"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",
"rr_distribution_method_availability_description": "Lehetővé teszi a foglalóknak, hogy találkozókat foglaljanak, amikor a házigazda elérhető. Használja ezt a lehetőséget a potenciális találkozók számának maximalizálására, amikor a találkozók egyenletes elosztása a házigazdák között kevésbé fontos.",
"rr_distribution_method_balanced_title": "Terheléselosztás",
"rr_distribution_method_balanced_description": "Figyelemmel kísérjük, hogy hány foglalás történt az egyes házigazdákkal, és összehasonlítjuk másokkal, letiltva azokat a házigazdákat, akik túl sok foglalással rendelkeznek, így a foglalások egyenletesen oszlanak el.",
"exclude_emails_that_contain": "Zárja ki azokat az e-maileket, amelyek tartalmazzák ...",
"exclude_emails_match_found_error_message": "Kérjük, adjon meg egy érvényes munkahelyi e-mail címet",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Adja hozzá az új karakterláncokat fent ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+59 -5
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "Azione richiesta per la riprogrammazione: prenota un nuovo orario per {{eventType}} con {{name}}",
"hi_user_name": "Ciao {{name}}",
"ics_event_title": "{{eventType}} con {{name}}",
"please_book_a_time_sometime_later": "Oops, non siamo riusciti a collegarti. Pianifica una chiamata futura",
"please_book_a_time_sometime_later": "Sembra che tutti i nostri esperti siano occupati al momento. Ti invitiamo a programmare una chiamata in un momento che ti sia più comodo e saremo pronti ad assisterti.",
"schedule_instead": "Programma invece",
"new_event_subject": "Richiesta di nuovo evento: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "Entra da {{entryPoint}}",
"notes": "Osservazioni",
@@ -350,9 +351,13 @@
"other": "Altro",
"email_sign_in_subject": "Link di accesso a {{appName}}",
"round_robin_emailed_you_and_attendees": "Avrai un incontro con {{user}}. Abbiamo inviato a tutti un'email di invito contenente tutte le informazioni.",
"round_robin_emailed_host_and_attendee": "{{host}} si incontra con {{attendee}}. Abbiamo inviato un'email con un invito al calendario con i dettagli a tutti.",
"emailed_you_and_attendees": "Abbiamo inviato via email a te e agli altri partecipanti un invito al calendario con tutti i dettagli.",
"emailed_host_and_attendee": "Abbiamo inviato un'email con un invito al calendario con i dettagli a tutti.",
"emailed_you_and_attendees_recurring": "Abbiamo inviato via email a te e agli altri partecipanti un invito al primo di questi eventi ricorrenti.",
"round_robin_emailed_you_and_attendees_recurring": "Avrai un incontro con {{user}}. Abbiamo inviato a tutti un'email di invito contenente tutte le informazioni per il primo di questi eventi ricorrenti.",
"emailed_host_and_attendee_recurring": "Abbiamo inviato un'email con un invito al calendario con i dettagli a tutti per il primo di questi eventi ricorrenti.",
"round_robin_emailed_you_and_attendees_recurring": "Ti incontri con {{user}}. Abbiamo inviato un'email con un invito al calendario con i dettagli a tutti per il primo di questi eventi ricorrenti.",
"round_robin_emailed_host_and_attendee_recurring": "{{host}} si incontra con {{attendee}}. Abbiamo inviato un'email con un invito al calendario con i dettagli a tutti per il primo di questi eventi ricorrenti.",
"emailed_you_and_any_other_attendees": "Tu e tutti gli altri partecipanti siete stati inviati via email con queste informazioni.",
"needs_to_be_confirmed_or_rejected": "La tua prenotazione deve ancora essere confermata o rifiutata.",
"needs_to_be_confirmed_or_rejected_recurring": "La tua riunione ricorrente deve ancora essere confermata o rifiutata.",
@@ -405,6 +410,8 @@
"back_to_bookings": "Torna alle prenotazioni",
"free_to_pick_another_event_type": "Sentiti libero di scegliere un altro evento in qualsiasi momento.",
"cancelled": "Cancellato",
"no_booking": "Nessuna prenotazione",
"awaiting_host": "In attesa dell'host",
"cancellation_successful": "Cancellazione riuscita",
"really_cancel_booking": "Davvero vuoi annullare la prenotazione?",
"cannot_cancel_booking": "Non puoi annullare questa prenotazione",
@@ -1242,8 +1249,8 @@
"number_provided": "Verrà fornito il numero di telefono",
"before_event_trigger": "prima dell'inizio dell'evento",
"event_cancelled_trigger": "quando l'evento viene cancellato",
"after_hosts_cal_video_no_show": "Dopo che gli host non si uniscono al video di Cal",
"after_guests_cal_video_no_show": "Dopo che gli ospiti non si uniscono al video di Cal",
"after_hosts_cal_video_no_show": "Dopo che gli host non si sono uniti alla videochiamata",
"after_guests_cal_video_no_show": "Dopo che gli ospiti non si sono uniti alla videochiamata",
"new_event_trigger": "quando viene prenotato un nuovo evento",
"email_host_action": "invia e-mail all'organizzatore",
"email_attendee_action": "invia e-mail ai partecipanti",
@@ -1573,6 +1580,7 @@
"exchange_version_2013_SP1": "2013 SP1",
"exchange_version_2015": "2015",
"exchange_version_2016": "2016",
"routing": "Instradamento",
"routing_forms_description": "Crea moduli per indirizzare i partecipanti alle destinazioni corrette",
"routing_forms_send_email_owner": "Invia e-mail al proprietario",
"routing_forms_send_email_owner_description": "Invia un'e-mail al proprietario quando il modulo viene inviato",
@@ -2210,7 +2218,9 @@
"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",
"connecting_you_to_someone": "Ti stiamo collegando con qualcuno.",
"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",
"org_team_names_example_1": "ad es., team di marketing",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "Limiti di prenotazione aggiornati con successo",
"you_are_unauthorized_to_make_this_change_to_the_booking": "Non sei autorizzato a effettuare questa modifica alla prenotazione",
"matching_members": "Membri corrispondenti",
"x_matching_members": "{{x}} membri corrispondenti",
"matching_members_queue_using_attribute_weights": "Coda di membri corrispondenti (utilizzando i pesi degli attributi)",
"matching_members_queue_using_event_assignee_weights": "Coda di membri corrispondenti (utilizzando i pesi degli assegnatari dell'evento)",
"no_matching_members": "Nessun membro corrispondente. Verrà utilizzato il fallback con i membri del team assegnati al tipo di evento.",
"no_matching_members_will_fallback_to_all_assigned_members": "Nessun membro corrispondente. Si utilizzeranno i membri del team assegnati al tipo di evento come fallback. Considera di aggiungere un fallback o correggere la logica di using_fallback_members",
"hide_calendar_event_details": "Nascondi i dettagli degli eventi del calendario nei calendari condivisi",
@@ -2717,6 +2730,8 @@
"add_new_field": "Aggiungi nuovo campo",
"you_dont_have_access_to_reroute_this_booking": "Non hai l'accesso per reindirizzare questa prenotazione",
"form_response_not_found": "Risposta del modulo non trovata",
"contact_owner": "Proprietario del contatto",
"contact_owner_not_found": "Non trovato",
"using_fallback_members": "Utilizzo dei membri di fallback",
"chosen_route": "Percorso scelto",
"attribute_logic_matched": "Logica degli attributi corrispondente",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "Sei sicuro di voler riassegnare questa prenotazione a un host non disponibile?",
"reassign_available_team_member_description": "Sei sicuro di voler riassegnare questa prenotazione?",
"yes_reassign": "Sì, riassegna",
"filter_by_attributes": "Filtra per attributi",
"reassign_reason": "Motivo della riassegnazione",
"reassigned_by": "Riassegnato da",
"row": "riga",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "Ora di fine evento nel fuso orario del partecipante",
"salesforce_create_event_on_contact": "Crea evento sul contatto, se esiste. Altrimenti, passa al lead",
"salesforce_owner_name_to_change": "Nome del proprietario da modificare",
"no_filter_set": "Nessun filtro impostato",
"booking_dry_run_successful": "Test di prenotazione completato con successo",
"dont_have_access_this_page": "Non hai accesso a questa pagina",
"you_need_admin_or_owner_privileges_to_access": "Per visualizzare o interagire con questa pagina, devi avere privilegi di amministratore o proprietario",
"field_type": "Tipo di campo",
@@ -2755,6 +2773,42 @@
"booking_start_date": "Data di inizio prenotazione",
"booking_created_date": "Data di creazione della prenotazione",
"booking_reassigned_to_host": "Prenotazione riassegnata a {{host}}",
"no_contact_owner": "Nessun proprietario del contatto",
"routing_forms_created": "Moduli di instradamento creati",
"routing_forms_total_responses": "Risposte totali",
"routing_forms_total_responses_without_booking": "Risposte totali senza prenotazione",
"routing_forms_total_responses_with_booking": "Risposte totali con prenotazione",
"routing_form_insights": "Nessuna prenotazione",
"routing_form_insights_form_name_header": "Nome del modulo",
"routing_form_insights_booking_status_header": "Prenotazione",
"routing_form_insights_response_no_booking": "Nessuna prenotazione",
"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",
"filter_operator_is": "È",
"filter_operator_is_not": "Non è",
"filter_operator_contains": "Contiene",
"filter_operator_does_not_contain": "Non contiene",
"filter_operator_starts_with": "Inizia con",
"filter_operator_ends_with": "Finisce con",
"filter_operator_is_empty": "È vuoto",
"filter_operator_not_empty": "Non è vuoto",
"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à",
"rr_distribution_method_availability_description": "Permette ai prenotanti di fissare incontri ogni volta che un host è disponibile. Usa questa opzione per massimizzare il numero di potenziali incontri prenotabili e quando la distribuzione uniforme degli incontri tra gli host è meno importante.",
"rr_distribution_method_balanced_title": "Bilanciamento del carico",
"rr_distribution_method_balanced_description": "Monitoreremo quante prenotazioni sono state effettuate con ciascun host e le confronteremo con gli altri, disabilitando alcuni host che sono troppo avanti per garantire una distribuzione uniforme delle prenotazioni.",
"exclude_emails_that_contain": "Escludi le email che contengono ...",
"exclude_emails_match_found_error_message": "Inserisci un indirizzo email di lavoro valido",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Aggiungi le tue nuove stringhe qui sopra ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+59 -5
View File
@@ -139,7 +139,8 @@
"requested_to_reschedule_subject_attendee": "スケジュール変更のアクションが必要です: {{name}} との {{eventType}} に新しい時間を予約してください",
"hi_user_name": "こんにちは、{{name}} さん",
"ics_event_title": "{{name}} との {{eventType}}",
"please_book_a_time_sometime_later": "おっと、今回は接続できませんでした。後で再度スケジュールしてください。",
"please_book_a_time_sometime_later": "現在、すべての専門家が対応中のようです。ご都合の良い時間にお電話を予約していただければ、準備してお待ちしております。",
"schedule_instead": "代わりに予約する",
"new_event_subject": "新規イベント: {{attendeeName}} - {{date}} - {{eventType}}",
"join_by_entrypoint": "{{entryPoint}} に参加する",
"notes": "メモ",
@@ -350,9 +351,13 @@
"other": "その他",
"email_sign_in_subject": "{{appName}} のサインインリンク",
"round_robin_emailed_you_and_attendees": "{{user}}とのミーティングが設定されました。詳細を含むカレンダー招待を全員にメールで送信しました。",
"round_robin_emailed_host_and_attendee": "{{host}}が{{attendee}}と面談予定です。詳細を含む予定表の招待メールを全員に送信しました。",
"emailed_you_and_attendees": "あなたと他の出席者にすべての詳細を記載したカレンダーの招待状を電子メールで送信しました。",
"emailed_host_and_attendee": "詳細を含む予定表の招待メールを全員に送信しました。",
"emailed_you_and_attendees_recurring": "あなたと他の出席者にこの繰り返しイベント初回のカレンダーの招待状を電子メールで送信しました。",
"round_robin_emailed_you_and_attendees_recurring": "{{user}}との定期的なミーティングが設定されました。最初のイベントの詳細を含むカレンダー招待を全員にメールで送信しました。",
"emailed_host_and_attendee_recurring": "定期的な予定の最初の回について、詳細を含む予定表の招待メールを全員に送信しました。",
"round_robin_emailed_you_and_attendees_recurring": "あなたは{{user}}と面談予定です。定期的な予定の最初の回について、詳細を含む予定表の招待メールを全員に送信しました。",
"round_robin_emailed_host_and_attendee_recurring": "{{host}}が{{attendee}}と面談予定です。定期的な予定の最初の回について、詳細を含む予定表の招待メールを全員に送信しました。",
"emailed_you_and_any_other_attendees": "あなたと他の参加者にこの情報を電子メールで送信しました。",
"needs_to_be_confirmed_or_rejected": "予約は依然として確認または拒否が必要です。",
"needs_to_be_confirmed_or_rejected_recurring": "繰り返しミーティングは、まだ確認または拒否が必要です。",
@@ -405,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": "この予約はキャンセルできません",
@@ -1242,8 +1249,8 @@
"number_provided": "電話番号が提供されます",
"before_event_trigger": "イベント開始前に",
"event_cancelled_trigger": "イベントキャンセル時",
"after_hosts_cal_video_no_show": "ホストがCalビデオに参加しない場合",
"after_guests_cal_video_no_show": "ゲストがCalビデオに参加しない場合",
"after_hosts_cal_video_no_show": "ホストがCalビデオに参加しなかった後",
"after_guests_cal_video_no_show": "ゲストがCalビデオに参加しなかった後",
"new_event_trigger": "新しいイベント予約時に",
"email_host_action": "ホストにメールを送信",
"email_attendee_action": "出席者にメールを送る",
@@ -1573,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": "フォームの送信時に所有者にメールを送信します",
@@ -2210,7 +2218,9 @@
"add_times_to_your_email": "出席できる時間帯をいくつか選んで、メールに埋め込みます",
"select_time": "時間帯を選ぶ",
"select_date": "日付を選ぶ",
"connecting_you_to_someone": "誰かに接続しています。",
"only_show_if_parameter_set": "パラメーターが設定されている場合のみ表示",
"add_parameter": "パラメーターを追加",
"connecting_you_to_someone": "{{orgName}}の担当者におつなぎしています。",
"please_do_not_close_this_tab": "このページを閉じたり、リフレッシュしたりしないでください",
"see_all_available_times": "出席できる時間帯をすべて表示",
"org_team_names_example_1": "例:マーケティングチーム",
@@ -2668,6 +2678,9 @@
"booking_limits_updated_successfully": "予約制限が正常に更新されました",
"you_are_unauthorized_to_make_this_change_to_the_booking": "この予約の変更を行う権限がありません",
"matching_members": "一致するメンバー",
"x_matching_members": "一致するメンバー{{x}}名",
"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": "共有カレンダーのイベント詳細を非表示にする",
@@ -2717,6 +2730,8 @@
"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": "属性ロジックが一致しました",
@@ -2733,6 +2748,7 @@
"reassign_unavailable_team_member_description": "利用できないホストにこの予約を再割り当てしてもよろしいですか?",
"reassign_available_team_member_description": "この予約を再割り当てしてもよろしいですか?",
"yes_reassign": "はい、再割り当てします",
"filter_by_attributes": "属性でフィルター",
"reassign_reason": "再割り当ての理由",
"reassigned_by": "再割り当て者",
"row": "行",
@@ -2745,6 +2761,8 @@
"event_end_time_in_attendee_timezone_info": "参加者のタイムゾーンでのイベント終了時間",
"salesforce_create_event_on_contact": "連絡先が存在する場合はイベントを作成し、存在しない場合はリードにフォールバック",
"salesforce_owner_name_to_change": "変更する所有者名",
"no_filter_set": "フィルター未設定",
"booking_dry_run_successful": "予約のテストが正常に完了しました",
"dont_have_access_this_page": "このページにアクセスする権限がありません",
"you_need_admin_or_owner_privileges_to_access": "このページを表示または操作するには、管理者または所有者の権限が必要です",
"field_type": "フィールドタイプ",
@@ -2755,6 +2773,42 @@
"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": "可能な場合、取引先責任者の所有者がラウンドロビンのホストになります",
"salesforce_do_not_route_to_owner": "取引先責任者の所有者は強制されません(属性とラウンドロビンの基準に一致する場合はホストになる可能性があります)",
"salesforce_route_to_custom_lookup_field": "取引先の参照項目に一致するユーザーに割り当てる",
"salesforce_option": "Salesforceオプション",
"lookup_field_name": "参照項目名",
"filter_operator_is": "である",
"filter_operator_is_not": "ではない",
"filter_operator_contains": "を含む",
"filter_operator_does_not_contain": "を含まない",
"filter_operator_starts_with": "で始まる",
"filter_operator_ends_with": "で終わる",
"filter_operator_is_empty": "が空である",
"filter_operator_not_empty": "が空ではない",
"translate_description_button": "AIを使用して説明文を訪問者のブラウザ言語に翻訳",
"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": "...を含むメールアドレスを除外",
"exclude_emails_match_found_error_message": "有効な勤務用メールアドレスを入力してください",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ この上に新しい文字列を追加してください ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

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