Compare commits

..
178 Commits
Author SHA1 Message Date
Alex van Andel 88e57d48fe chore: Release v4.6.18 (#17521)
release.yml / release (push) Canceled after 0s
2024-11-06 20:39:39 +00:00
Morgan 6619701c88 fix: event-type atom tabs controlled by prop (#17480)
* fix: event-type atom tabs controlled by prop

* fixup! fix: event-type atom tabs controlled by prop
2024-11-06 20:38:29 +00:00
Alex van Andel ebb8bb9c67 fix: saml does not add future members to eventtype (#17522)
* fix: trigger createOrUpdateMemberships from dsync so SAML signup triggers side-effects

* forEach does not wait for async/await done-ness
2024-11-06 20:38:08 +00:00
Alex van Andel 993bf23986 fix: common schedule toggling incorrectly (#17517)
* fix: common schedule toggling incorrectly

* type fixes to util.ts

* fix another type error
2024-11-06 20:21:25 +00:00
Benny Joo 4b38d73120 chore: Replace prisma actions Credential Repository / SelectedCalendar Repository methods (#17443)
* create lib/google.ts

* add updateAvatar method to UserRepository

* refactor googlecalendar callback

* add methods to CredentialRepository and SelectedCalendarRepository

* refactor

* remove not needed code

* refactor

* make credential repository and selected calendar repository more generic

* create GoogleRepository
2024-11-06 14:48:45 -05:00
Joe Au-Yeung 8f92c9b884 fix: Logic fix in Salesforce no-show (#17516) 2024-11-06 16:49:31 +00:00
Joe Au-Yeung edecc7e60e feat: Round Robin Skip If contact owner first available slot is more than 2 weeks, round robin as normal [CAL-4651] (#17416)
* If contact owner first available slot is more than 2 weeks. Normal RR

* If contact owner first available slot is more than 2 weeks. Normal RR

* Type fix

* Add test
2024-11-06 15:51:36 +00:00
sean-brydon 4eefb08a0d fix: copy to clipboard button (#17512)
* fixes copy to clipboard button

* fix type error
2024-11-06 15:11:55 +00:00
Calcom BotandReplexica 3f7e632d5c feat: update translations (#17474)
Co-authored-by: Replexica <support@replexica.com>
2024-11-06 10:08:15 -03:00
Peer RichelsenandhbjORbj ef0fd67646 chore: Update SettingsLayout.tsx (#17498)
* chore: Update SettingsLayout.tsx

* Update settings layout app dir

---------

Co-authored-by: hbjORbj <sldisek783@gmail.com>
2024-11-06 09:34:30 -03:00
Hariom Balhara 8e7567b6a3 fix: Don't let the modal close accidentally atleast when loading (#17508)
* Dont close during loading state

* Dont allow accidental close when loader is running
2024-11-06 09:34:04 -03:00
Amit Sharma 193855d60f fix: routing form: dont disable routing and reporting on mobile and fix mobile layout instead (#17444) 2024-11-06 09:29:51 -03:00
Vincent Lam 92fafa7344 feat: team availability - vertical selector and align times (#17502)
* - aligns timezones offset by minutes and add minutes to the label in the teams availability view
- added a vertical guidebar to view alligned schedules

* fixed type-check issues
2024-11-06 10:37:21 +00:00
Somay Chauhan 21aaff01fa fix: disable all emails modal crash on platform (#17485) 2024-11-06 10:43:57 +02:00
e07a28a2d1 feat: Salesforce - on booking write to fields on person record [CAL-4575] (#17477)
* Create data type

* Start FE component

* Rename and add new enums

* Add new schema for write on record options

* Add options on the frontend

* Backend write to custom fields

* refactor: Salesforce - on booking write to fields on person record (#17481)

* Add translations

* fix: type err

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-11-06 08:20:52 +00:00
Benny Joo 9a1b5ad149 fix: broken social media preview and title (#17504)
* fix: broken social media preview and title

* remove code not needed
2024-11-06 13:28:35 +05:30
Benny Joo 1c20a257d3 fix: missing responsive design in org members page (#17495) 2024-11-05 17:26:41 +00:00
Keith Williams 656fcafcde chore: v4.6.17 (#17493)
release.yml / release (push) Canceled after 0s
2024-11-05 14:05:23 -03:00
Nizzy 6d7080d9fc posthog vars (#17494) 2024-11-05 16:37:00 +00:00
Rajiv Sahal f943debda2 feat: docs for event type atom (#17466)
* init docs for event types atom

* images for event type atom

* update images for destination and selected calendar atoms

* update event types docs
2024-11-05 13:32:45 -03:00
Benny Joo 803fc488c5 feat: Add feature to download routing form reports (#17400)
* remove unused code

* add i18n for some strings in widget

* improve UI and add download button to routing forms reporting page

* add more margin

* add downloadAsCsv util

* integrate download button to routing forms report

* refactor

* address comment

* escape comma multiselect items

* fix

* preserve new lines and wrapping

* refactor

* ensure fetching all rows
2024-11-05 11:21:13 -05:00
Rajiv Sahal 423b284278 feat: docs for payment form atom (#17360)
* init docs for stripe payment form

* update payment form page

* fixup
2024-11-05 15:48:51 +00:00
sean-brydon f1111c9d42 show who a RR was reassigned to on auto (#17454) 2024-11-05 12:44:49 -03:00
Keith Williams ed6d88e30f perf: Allow bypassing 3rd party calendar busy times (#17492)
* perf: Allow bypassing 3rd party calendar busy times

* Fix typing issues

* Fixed API v1 build

* Another fix for API v1
2024-11-05 12:31:33 -03:00
Morgan e675ad0c6c fix: assign/remove team members to team event-types (#17483)
* fix: assign/remove team members to team event-types

* fixup! fix: assign/remove team members to team event-types

* fixup! fixup! fix: assign/remove team members to team event-types

* fixup! fixup! fixup! fix: assign/remove team members to team event-types
2024-11-05 17:19:43 +02:00
Udit Takkar a01d6aa932 feat: instant meeting modal improvements (#17489)
* feat: instant meeting modal improvements

* fix: type error
2024-11-05 09:02:30 -05:00
Lauris Skraucis f868b86e6c fix: creating recurring seated bookings for same timeslot (#17482)
* fix: creating multiple seated bookings for same timeslot

* add test
2024-11-05 15:38:21 +02:00
Peer Richelsen db0a7f3797 chore: Update build-an-app.mdx (#17486) 2024-11-05 13:38:07 +00:00
Morgan 5646229cd4 fix: cancelling invalid booking seat apiv2 (#17484) 2024-11-05 12:30:38 +01:00
Anik Dhabal Babu f39e817d86 fix: Update the common schedule toggle check logic (#17439) 2024-11-05 05:10:12 +00:00
Benny Joo 42c4c134fa chore: add missing i18n to metadata in settings/platform pages (#17476) 2024-11-05 02:37:12 +00:00
Benny Joo ffc4b69edd fix: api/routing-forms/responses (#17473)
* add i18n

* fix api/routing-forms/responses
2024-11-04 13:19:58 -06:00
Benny Joo 7263f3b00b chore: settings/platform, settings/license-key - remove pages router and use app router (#17384)
* remove pages router and use app router

* remove pages router and use app router for platform

* add billing page and plans page

* fix metadata for settings/platform/billing

* fix metadata for settings/platform/oauth-clients
2024-11-04 15:56:43 -03:00
ddfbe25903 feat: adding posthog libs for node + js (#16862)
* adding posthog libs for node + js

* updating lock

* move to init posthog client - pageview component

* adding identify in shell

* removing test event captures

* add signout tracking to dropdown as it was missed in merge

---------

Co-authored-by: sean <sean@brydon.io>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-11-04 11:46:25 -05:00
Udit Takkar dd944bace1 chore: add webhook in payload (#17468)
* chore: add webhook in payload

* chore

* chore: add more fields

* add host email

* chore: change name
2024-11-04 16:12:34 +00:00
Keith Williams e4353af839 chore: v4.6.16 (#17465)
release.yml / release (push) Canceled after 0s
2024-11-04 10:57:04 -03:00
Keith Williams ff9936fdeb perf: Remove extra spans (#17464) 2024-11-04 13:50:49 +00:00
Benny Joo e74718ac66 fix: ensure fetching all members before exporting csv (#17461) 2024-11-04 11:35:37 +00:00
Udit Takkar a78f10223f chore: remove no option text (#17457) 2024-11-04 09:47:36 +00:00
Benny Joo c850af7409 feat: allow users to generate csv tables for org members (#17458)
* add downloadAsCsv util

* add sanitizeValue util

* write logic

* refactor

* add tests
2024-11-04 09:47:10 +00:00
Udit Takkar 06dccd7a73 fix: filter by team url (#17456) 2024-11-04 09:45:14 +00:00
Rajiv Sahal 3b59185f75 fix typo (#17451) 2024-11-04 08:39:42 +00:00
Morgan c907bcc646 fix: docs mdx syntax errors (#17450)
* fix: docs mdx syntax errors

* fixup! fix: docs mdx syntax errors
2024-11-04 08:20:33 +00:00
Keith Williams 6c5b12db57 perf: Add more specific spans to getBusyTimes (#17437)
* perf: Add more specific spans to getBusyTimes

* fix types
2024-11-03 12:21:35 -03:00
Benny Joo bcc1a8e279 fix: create a wrapper for google calendar fetching (#17440)
* fix: create a wrapper for google cal fetching

* improve log
2024-11-01 20:22:57 -03:00
Benny Joo a277b8515f fix (#17441) 2024-11-01 20:51:02 +00:00
Benny Joo 42a02cbb60 fix: update profile photo logic in google cal integration (#17438) 2024-11-01 16:21:42 -04:00
sean-brydon 0c60ad1c82 fix: no availability email frequency fix (#17430)
* pass teamID to only send to relevant team admins not org admins

* a lot more tests aroundthis

* improve test cases

* change reddis cache key to take into account teamID

* add start/endtime to template

* fix text in email

* add endTime to tests
2024-11-01 14:54:34 -04:00
Keith Williams 1add8e73ee Revert "perf: Cache app store imports (#17425)" (#17428)
This reverts commit b2d395de52.
2024-10-31 19:26:55 +00:00
Alex van Andel ff15ec77c3 chore: Release v4.6.15 (#17422)
release.yml / release (push) Canceled after 0s
2024-10-31 18:20:08 +00:00
Keith Williams b2d395de52 perf: Cache app store imports (#17425) 2024-10-31 18:19:07 +00:00
Joe Au-Yeung 9dd35c7ab3 feat: Salesforce only get active users (#17426)
* Add isActive boolean when querying owners

* Add conditional
2024-10-31 18:18:35 +00:00
Calcom BotandReplexica bd30bf335b feat: update translations (#17421)
Co-authored-by: Replexica <support@replexica.com>
2024-10-31 17:56:22 +00:00
Hariom BalharaandCarinaWolli ab65ce73c2 feat: Support Attribute Logic fallback (#17290)
* Add fallback

* Support attribute query fallback

* Refactor

* Add tests and cleanup SingleFofrm

* small text fixes

* With fallback in picture, we dont throw error in preview now instead we capture errors and show them gracefully

* Get attribute logic preview working without saving Fixes CAL-4582

* Abstract useRoutes out

* Update e2e

* Dont define Page component again and again

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-31 12:53:34 -04:00
Joe Au-Yeung 06f83e6d0d feat: Send Attendee No Show Data To Salesforce (#17423) 2024-10-31 16:39:58 +00:00
Udit Takkarandsean ebd5ca6ceb fix: people filter in /bookings and create OOO modal (#17396)
* fix: people filter in /bookings

* refactor: lazy loading people filters and OOO

* chore: type errr

* chore: type errr

* fallback to username if no name is set

---------

Co-authored-by: sean <sean@brydon.io>
2024-10-31 14:57:07 +00:00
Benny Joo bcaf067ed6 fix: routing-forms reporting - "in" operator logic is broken + overall refactor (#17398)
* fix: routing-forms reporting - "in" operator logic is broken

* fix

* refactor to make it more readable

* add tests
2024-10-31 14:09:10 +00:00
Morgan 120cfed8a1 fix: booker platform atom multi duration selector (#17419) 2024-10-31 13:14:58 +00:00
sean-brydonandPeer Richelsen e728ed74ce fix: add reassignedBy and reassign reason to both status (#17418)
* add reassignedBy and reassign reason to both status

* fix w-full issue in dialog

* fix types

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-10-31 12:32:05 +00:00
Keith Williams 64551a506a perf: Allow configuring Sentry:debug (#17420) 2024-10-31 12:15:07 +00:00
fee3b73bda feat: payment card atom (#17203)
* init stripe payment card atom

* cleanup

* update platform libraries

* fix import paths

* add function to get user payment info

* fixup

* resolve merge conflicts

* fixup fixup

* resolve merge conflicts

* fixup

* update payments page

* add props for event payment success and failure

* update props and fixup

* bump platform libraries

* fixup! bump platform libraries

* fixup! fixup! bump platform libraries

* resolve merge conflicts

* fix: add isActive to paymentsTab

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-10-31 11:21:13 +02:00
Carina WollendorferandCarinaWolli a5806cb100 fix: buffers in handleNewBooking (#17415)
* send missing buffer params

* add tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-31 08:48:36 +00:00
Anik Dhabal Babu f649f8b78d fix: huddle01 is not generating meeting link (#17417)
* fix: huddle01 not generating link

* update
2024-10-31 04:59:34 +00:00
Keith Williams ced7f282a5 perf: Reduce spans in highPerf temporarily (#17413)
* perf: Reduce spans in highPerf temporarily

* Just completely removed them
2024-10-30 16:15:51 +00:00
Keith Williams dfa4e10c83 perf: Add ability to configure Sentry maxSpans (#17411) 2024-10-30 13:54:22 +00:00
c3f03c8000 fix: stripe connect atom for teams (#17294)
* add teamId

* add team app credentials method

* add handler to get all user admin teams

* update stripe logic to handle teams

* enable teamId for stripe connect

* update stripe module

* implement PR feedback

* update stripe module

* add method to get credential by team id

* handle check for teamId

* abstract team and individual stripe connection checks into two separate parts

* hide badge for platform user

* update typing

* add logic to handle team stripe integration

* bump platform libraries

* bump platform libraries

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-30 15:28:26 +02:00
Benny Joo aaa5531a3a fix: main google calendar not being connected (#17404) 2024-10-30 11:58:44 +00:00
Morgan 2784564427 Revert "chore: ensure idempotency cancel booking api v2" (#17407)
This reverts commit 85246f0754.
2024-10-30 11:37:17 +00:00
Keith Williams 4b6b9badf5 perf: Sentry perf tracing (#17202)
* chore: Add Sentry perf tracing

* Just to build

* perf: Add Sentry perf tracing

* Removed the extra integrations tracing config

* Got spans working

* Now only tracing individual endpoints

* Removed check for booking event type

* Fixed types

* Added back for spans

* Consolidate sentry imports

* Added more traces

* Moved the start of the span
2024-10-30 00:35:25 +00:00
Keith Williams 5a9197f357 chore: v4.6.14 (#17390)
release.yml / release (push) Canceled after 0s
2024-10-29 19:45:41 -03:00
Calcom BotandReplexica 315077f6c5 feat: update translations (#17393)
Co-authored-by: Replexica <support@replexica.com>
2024-10-29 21:47:57 +00:00
Joe Au-Yeung b8b270e4e5 feat: Prioritize Contacts Over Leads If They Exist [CAL-4640] (#17380) 2024-10-29 19:53:44 +00:00
Udit Takkar 04a49e74d4 fix: round robin re assignment google meet bug (#17378) 2024-10-29 15:27:37 -04:00
Joe Au-Yeung 8605fb4518 feat: Salesforce change owner on booking [CAL-4641] (#17391)
* Let user set owner name to change

* Add backend logic

* Add message

* Type fixes
2024-10-29 14:03:35 -04:00
sean-brydon 55671db5fd fix: virtualised member list for reassign (#17386)
* wip virtual list for reassignemnt

* order in alpha and prio order

* loading state

* add flex shrink so status symbol doesnt move

* add gap to each item to make hovering not feel squished

* a11y indexing tab group

* useInViewObserver hook and move to lib

* use correct intersection target

* fix type errors

* remove type
2024-10-29 17:47:43 +00:00
Benny Joo 1a55f2d8e2 chore: settings/admin - remove pages router and use app router & add i18n to metadata (#16773)
* remove pages router and  use app router

* remove testBothFutureAndLegacyRoutes for settings-admin e2e test

* add i18n for metadata
2024-10-29 12:49:21 -03:00
Morgan b82b39d12d chore: add logs api-v2 booking controllers (#17389)
* chore: add logs apiv2 booking controller

* chore: add logs apiv2 booking controller
2024-10-29 12:02:22 +00:00
Morgan 85246f0754 chore: ensure idempotency cancel booking api v2 (#17385) 2024-10-29 12:20:42 +02:00
Anik Dhabal Babu c5becb0990 fix: few ui fixes on booking page (#17383) 2024-10-29 07:45:06 +00:00
Calcom BotandReplexica 13d4102dc8 feat: update translations (#17231)
Co-authored-by: Replexica <support@replexica.com>
2024-10-28 21:50:48 +00:00
Keith Williams 8ab73caeb9 chore: v4.6.13 (#17362)
release.yml / release (push) Canceled after 0s
2024-10-28 17:57:19 -03:00
Joe Au-Yeung e6dbb19eff feat: Salesforce RR Skip Accounts - Match On Email Domain (#17350)
* If RR skip is set to account, match on email domain of existing contacts

* Revert changes on main merge

* Refactor
2024-10-28 13:54:32 -04:00
Avishay Maor 2efa593d2b Update RerouteDialog.test.tsx resolve lint errors (#17352) 2024-10-28 22:49:11 +05:30
Ali NawazandAnik Dhabal Babu dfc73ea307 fix: Inconsistent phrasing and remove duplicate words in localization (#17339)
* Fix inconsistent phrasing and remove duplicate words in localization strings in common.json

* Update common.json

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-28 14:10:55 +00:00
Lauris Skraucis ed6c94b941 docs: platform api calls (#17356) 2024-10-28 13:42:37 +00:00
Lauris Skraucis f8cbf4693b docs: AvailabilitySettings toggle animation fix (#17355) 2024-10-28 08:24:30 +00:00
Benny Joo 7d51741de0 fix: lint errors (#17351) 2024-10-27 08:05:27 +05:30
Anik Dhabal Babu 7e3f60bbdb fix: wrong attende details shows for seated event (#17338) 2024-10-26 11:46:59 +05:30
Omar López f81024e3f0 chore: v4.6.12 (#17345)
release.yml / release (push) Canceled after 0s
2024-10-25 23:24:55 +00:00
Syed Ali Shahbaz 097c14ca64 feat: add workflow vars and search for event types (#17343)
* Udit:goat - thank you for this

* improve searchbar

* remove unused code

* add workflow vars

* change requested

* requested changes

* changes

* type-fix??

* type-fix??

* ----
2024-10-25 22:38:48 +00:00
Alex van Andel b9128a7a0b chore: Add ./components/[name]/index.ts map to package.json (#17319)
* chore: Add ./components/[name]/index.ts map to package.json

* fix: Clarify Popover exports

* fix: re-add dropdown to calcom/ui barrel

* chore: Add icon barrel file re-export

* Fix all calcom/ui imports of its own barrel

* Never say 'fix all remaining..' it's never true

* Some type fixeS

* Linking fixes

* Rename sheet.tsx to Sheet.tsx

Done through UI, console does NOT like this.

* Fixed some test failures
2024-10-25 23:05:29 +01:00
Omar López 5c86522e28 fix: Tasker new Date is fixed to lambda lifetime (#17342) 2024-10-25 20:54:51 +00:00
Anik Dhabal Babu b6ce06b515 chore: Add search ability to the people filter within insights (#17320)
* chore: Add search ability to the people filter within insights

* update
2024-10-25 19:46:48 +00:00
Omar López 13bc6d8fee Revert "fix: use UTC date in tasker (#17329)" (#17341)
This reverts commit 96571df308.
2024-10-25 19:45:27 +00:00
c5402d8bd7 fix: give reason to reassign - allow team members to reassign even if busy (#17328)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2024-10-25 19:13:26 +00:00
Benny Joo b53abe93bc feat: display number of rows in routing form reporting (#17334)
* add rows and row to english common.json

* feat: display number of rows in routing form reporting
2024-10-25 18:51:33 +00:00
Anik Dhabal Babu 377cae9fd8 chore: Add scheduler first name to event title (#17309)
* chore: Add scheduler first name to event title

* Update event.ts

* add test

* minor
2024-10-25 14:49:13 -04:00
Keith Williams fd1edae706 fix: Typo in common schedule text (#17325) 2024-10-25 14:44:57 -04:00
Omar López 2fab54546e chore: v4.6.11 (#17333)
release.yml / release (push) Canceled after 0s
2024-10-25 11:07:17 -07:00
Benny Joo 820230d61d fix: primaryCal is missing (#17332) 2024-10-25 17:39:18 +00:00
Omar López af363e975a fix: disables cache for tasker cron (#17331) 2024-10-25 10:19:37 -07:00
Udit Takkar 96571df308 fix: use UTC date in tasker (#17329) 2024-10-25 09:11:14 -07:00
Morgan 9868926ec1 chore: webhooks meeting started / ended with oauthClientId (#17324)
* chore: oauth webhooks meeting started / ended

* fixup! Merge branch 'main' into chore-webhooks-platform-meeting-started-ended
2024-10-25 13:28:49 +00:00
Lauris Skraucis 69ca57e45a fix: v2 event types booking fields & enable booker atom google-meet location (#17321)
* fix: v2 phone system booking field

* feat: atom support google-meet

* feat: unknown booking field return type

* tests

* bump platform libraries

* driveby: improve create-booking-input
2024-10-25 15:55:29 +03:00
Hariom Balhara 9157e1faf3 fix: 404s due to wrong booking link(without org domain) (#17318) 2024-10-25 08:18:08 +00:00
Steve Xu 87baf0363e fix(docs): Update contributors-guide.mdx (#17316) 2024-10-25 11:42:41 +05:30
Hariom Balhara 4c6f242345 fix: is Empty and is Not empty operators (#17317) 2024-10-25 04:46:46 +00:00
Keith WilliamsandOmar López 1c8cf62582 feat: add more routing forms data (#17229)
Co-authored-by: Omar López <zomars@me.com>
2024-10-25 02:00:37 +00:00
sean-brydon 52e1a58904 handle gracefully and log error (#17310) 2024-10-24 21:59:42 +00:00
Bailey PumfleetandBenny Joo b6cef49ee6 fix: app store title (#17307)
* Fix app store title

* refactor apps-view

* revert unneeded changes

* remove withoutMain

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2024-10-24 21:18:38 +00:00
sean-brydon af94c64f46 catch error when no users are found (#17308) 2024-10-24 20:55:10 +00:00
Alex van Andel e251a5afea chore: Release v4.6.10 (#17296)
release.yml / release (push) Canceled after 0s
2024-10-24 16:27:20 -03:00
62d162f001 fix: Salesforce if contact was created don't create new lead as well (#17299)
* fix: salesforce event creation duplicate error

* chore: message

* If contact was created, don't create a new lead

* Add boolean for no accountId

* Remove debug artifact

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-10-24 19:27:08 +00:00
Carina Wollendorfer 8811ebcc33 fix: workflow reminder emails on reassigned bookings (#17305) 2024-10-24 19:15:30 +00:00
Alex van Andel c0529a26c4 fix: Broken 'Build Atoms' workflow bash syntax (force merge) 2024-10-24 19:46:21 +01:00
Alex van Andel a28d9bb840 chore: push minor improvements to gh actions & yarn.lock (#17302)
* chore: push minor improvements to gh actions & yarn.lock

* Remove bash conditionals for readability
2024-10-24 18:05:20 +00:00
06ec89e787 feat: Org owners should be able to see all bookings in bookings tab. (#15314)
* feat: Org owners should be able to see all bookings in bookings tab.

* perf: optimize the query

* perf: make it a separate query

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Omar López <zomars@me.com>
2024-10-24 14:57:38 -03:00
Benny Joo a4d733fb9d fix: Wrong primary calendar selected for Google Calendar (#17301) 2024-10-24 17:50:53 +00:00
Syed Ali Shahbaz c3d56f736a fix: insights event timeline perf and accuracy (#17218)
* aggregate multiple calls to one for events timeline

* raw query

* fix inside wrong val

* fix timeline view entirely

* fix avg event duration

* fix timeline

* type-fix

* fixes

* remove unnecessary comments
2024-10-24 17:46:28 +00:00
Lauris Skraucis c629d08a9c fix: v2 event type locations (#17288)
* refactor: split internal locations in separate file

* refactor: rename TransformedLocationsSchema to InternalLocationsSchema

* feat: add missing locations in internal schema

* refactor: explicit input locations only for input

* refactor: explicit output locations only for output

* fix: v2

* fix: atoms

* test: internal to api locations transformation

* e2e tests

* upgrade platform libraries
2024-10-24 17:21:17 +00:00
Udit Takkar 50a41109d0 chore: add event has been rescheduled title (#17286) 2024-10-24 17:18:55 +00:00
Udit Takkar ca5b83f876 fix: salesforce event creation duplicate error (#17292)
* fix: salesforce event creation duplicate error

* chore: message
2024-10-24 17:11:12 +00:00
Hariom Balhara c29e37a03f fix: Keep 'Verify new route' enabled in the initial state. (#17295) 2024-10-24 16:16:58 +00:00
Shrey TannaandAlex van Andel 14ae664941 fix:CityNotShownInOption (#17064)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-24 09:10:27 -04:00
sean-brydon 0949bbf167 feat: add availability shown in rr reassign (#17287)
* (feat) add availability status to select

* add logic for confirmation modal on unavailable host

* fix type error

* fix type error

* more type fixes

* fix types
2024-10-24 12:38:03 +01:00
Rajiv Sahal d0a1e90199 fix: platform dashboard buttons (#17284) 2024-10-24 16:25:32 +05:30
Morgan 077f29305d fix: default booking status filter booking.controller (#17282) 2024-10-24 09:02:37 +00:00
Udit Takkar 4c528afe65 chore: add logs for debugging tasker (#17283) 2024-10-24 08:31:03 +00:00
RaghulandAnik Dhabal Babu 57ed383554 fix: [CAL-4296] Fix Booking Text wrap issue for multi emails (#16578)
* [CAL-4296] Fix Booking Text wrap issue for multi emails

* Update Button.tsx

* Update Components.tsx

* Update Button.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-24 06:13:45 +00:00
Benny Joo d53cabd163 chore: improve perf for fetching bookings in insights (#17281) 2024-10-24 04:17:38 +00:00
Joe Au-Yeung 817d8738a9 feat: Salesforce - on booking, write to custom fields [CAL-4570] (#17257)
* Save and enable custom fields

* Write to custom fields on booking

* Add validations
2024-10-24 03:07:13 +00:00
d6dbbd1472 fix: new reassignment email for RR host reassignment (#17279)
* add new reassignment email

* improve reassign email

* fixes

* Minor copy fixup

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-10-23 22:57:15 +00:00
Benny Joo 4021801698 feat: attach booker name/email to insights CSV (#17280) 2024-10-23 22:55:11 +00:00
Keith Williams b38d8e7ae4 perf: New getTeamSchedule route with higher vCPUs (#17277) 2024-10-23 13:38:15 -07:00
Benny Joo 123a39bd9a fix: Attributes Not Updating (#17275) 2024-10-23 14:42:42 -04:00
Keith Williams 596dc3fbd7 perf: Load OOO in bulk for getSlots (#17270)
* perf: Load OOO in bulk for getSlots

* Added back the failsafe of running the query when no initial data is passed in

* Changed length check to be more explicit
2024-10-23 14:25:10 +00:00
Lauris Skraucis 058d58a6f3 revert chore: make rate limit logs verbose api-v2 (#17273)
This reverts commit e2c54b1fc4.
2024-10-23 14:17:54 +00:00
Anik Dhabal BabuandKeith Williams 4caec83c8f feat: add Dialpad conferencing (#15444)
* feat: Dialpad conferencing

* update

* final update

* refactor

* update

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-23 19:47:09 +05:30
sean-brydon 3ddef59761 feat: add no-show-to-edit-list (#17265) 2024-10-23 12:07:13 +01:00
Maximilian FranzkeandAnik Dhabal Babu c7edfd5bd3 fix(a11y): added description for icon buttons (#17184)
* fix(a11y): added description for icon buttons

* Update Header.tsx

* Revert "Update Header.tsx"

This reverts commit b12515c8ce2f652adbf441d7488b877a50c8b539.

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-23 12:41:00 +05:30
Morgan 23d1a21e23 chore: remove json stringify from requestId logs api-v2 (#17263) 2024-10-23 07:05:31 +00:00
Morgan e2c54b1fc4 chore: make rate limit logs verbose api-v2 (#17262) 2024-10-23 06:53:51 +00:00
Ali Nawaz 622b2795f5 fix: correct typos in E-Mail Settings and Organizations sections .env.example (#17258)
- Corrected "Sengrid" to "Sendgrid" in E-Mail Settings.
- Fixed "sing-ups" to "sign-ups" in the Organizations section.
- Corrected "envrionment" to "environment" in LICENSE (DEPRECATED).
2024-10-23 04:08:31 +00:00
Anik Dhabal Babu c0298248dc fix: private team does not hide team members from event type page (#17099)
* fix: private team does not hide team members from event type page

* update

* Update
2024-10-23 09:24:06 +05:30
Omar López db07738514 chore: v4.6.9 (#17255)
release.yml / release (push) Canceled after 0s
2024-10-22 20:38:33 +00:00
Joe Au-Yeung 700f0aeb35 fix: Salesforce - Check for existing lead when "Account" is set (#17254) 2024-10-22 19:36:39 +00:00
Carina WollendorferandCarinaWolli f3fd043255 add enableOverflow (#17249)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-22 15:39:21 +00:00
a0e4580f4e fix: added aria-label to timezone input (#17241)
* fix: 17186 timezone accessibility fix

* Update common.json

* update

* Update EventMeta.tsx

---------

Co-authored-by: Shivam Bhatia <shivambhatia@Shivams-MacBook-Pro.local>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-10-22 15:29:06 +00:00
Keith Williams 659b60aac0 fix: API v1 hashedLink zod (#17248) 2024-10-22 15:07:02 +00:00
Lauris Skraucis 7131996722 fix: Booker atom weekly view (#17245) 2024-10-22 14:39:30 +00:00
Keith Williams 42cbd582f2 chore: v4.6.8 (#17243)
release.yml / release (push) Canceled after 0s
2024-10-22 10:58:27 -03:00
Keith Williams 5481248c1a chore: Back down vCPUs for public/slots routers (#17242) 2024-10-22 13:51:19 +00:00
Lauris Skraucis bd4a949a7c fix: v2 seated bookings (#17045)
* fix: rely on booking uid instead of id from handleNewBooking

* docs: managed user create input timeZone

* fix: make bookingResponsesSchema nullable

* Revert "fix: make bookingResponsesSchema nullable"

This reverts commit f3127b31dd259d23cae520c994d8e712fc0fa17e.

* refactor: define inputs

* refactor: define outputs

* refactor: bookings controller

* test: first seated event test

* test: recurring seated event booking

* test: multiple bookings for seated event

* test: fetching individual seated bookings

* test: fetch all bookings

* test: make reschedule seated work and test it

* wip: cancel seated booking

* test: cancel seated booking

* refactor: dont return attendee email in seat booking responses

* refactor: order of properties in response

* refactor: test cleanup

* refactor: simplify output booking service

* refactor: flaky test

* refactor: try to fix test by splitting them

* try to fix test

* try to fix tests
2024-10-22 13:31:59 +00:00
Lauris Skraucis b768cc5b44 feat: v2 and atoms attendee defined meeting locations (#17224)
* request: allow saving attendee address, location and custom location

* response: return new event type locations

* atoms: make elsewhere location work

* update swagger

* chore: update and use platform libraries

* fix schema

* republish libraries

* update lock file

* fix: make somewhereElse optional in response location

* fix: tests
2024-10-22 13:24:16 +00:00
Lauris Skraucis de80ca2aaa fix: reset v2 & AvailabilitySettings atom availabilities and overrides (#17236) 2024-10-22 13:19:36 +00:00
Lauris Skraucis 4afbe26506 refactor: v2 response in case of duplicate managed user (#17214) 2024-10-22 13:17:12 +00:00
Benny Joo f22d585c7d chore: Do not generate pages per language at build time (#17227) 2024-10-22 14:10:49 +01:00
1ee4fa0906 fix: fixes for round robin reassignment (#17233)
* fixes scheduled email for new host

* fixes emails and calendar event

* type fixes

* add test to check if caneclation email is called

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: sean <sean@brydon.io>
2024-10-22 10:08:25 -03:00
Hariom BalharaandUdit Takkar 1fc376c51a fix: slots troubleshooter data (#17217)
* Fix troubleshooter data slots

* Remove email

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-10-22 13:05:55 +00:00
PonikarandAkash Mondal 9382b862e4 feat: Updated Huddle01 App Flow (#16907)
* fix: migrated to new flow

* feat: apis added

* fix: meetingId

* fix: working with apis

* fix: appName

* fix: WEB APP URL

* fix: update url

* fix: remove logs

* feat: remove lock file

* fix: lock file

* fix: override buildErrors

* fix: removed lock file

* fix: migrated to new flow

* feat: apis added

* fix: meetingId

* fix: working with apis

* fix: appName

* fix: WEB APP URL

* fix: update url

* fix: remove logs

* feat: remove lock file

* fix: lock file

* fix: override buildErrors

* fix: yarn install, lock file

* temp: debugging issue

* feat: api updated

* fix: updated endpoints

* fix: reverted changes

* revert: yarn.lock

* feat: readme

* Update yarn.lock remove spacing

* fix: spacing

---------

Co-authored-by: Akash Mondal <akmo3901@gmail.com>
2024-10-22 11:50:16 +00:00
Morgan c2662638a5 fix: booking.controller clone req to avoid side effect apiv2 (#17239) 2024-10-22 08:44:09 +00:00
sean-brydon c2291164fa fix invite modal when on mobile in orgs (#17238) 2024-10-22 14:01:03 +05:30
Anik Dhabal Babu 9d01025a1f revert: team availability view (#17222) 2024-10-22 13:55:20 +05:30
Hariom Balhara 86af6bd626 Allow non-admin team members also to reroute (#17234) 2024-10-22 13:46:30 +05:30
2f4dd328c3 feat: new webhook 'Form submitted, no event booked' (#17119)
* add new webhook

* schedule trigger functions

* implement tasker

* fix type errors

* add tests

* remove unused code

* finish tests

* remove unsued code from debugging

* Fix: client-server mixed exports

* Update tasker.ts

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: zomars <zomars@me.com>
2024-10-22 03:38:33 +00:00
6df994389b feat: round robin handover to specific host (#17215)
* frontend dialog

* handler + fe calling handler

* reafctors + start work on fix hosts tests

* restore rr

* update tests + fixing types

* use a getRRHostsToReasign handler + error handle + i18n

* typefixes

* assert type

* fix: adds missing translation

* Update roundRobinManualReassignment.ts

* Update roundRobinManualReassignment.ts

* Update apps/web/components/dialog/ReassignDialog.tsx

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update roundRobinManualReassignment.ts

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-10-21 19:52:09 -07:00
Calcom BotandReplexica 93e93a9c7a feat: update translations (#17219)
Co-authored-by: Replexica <support@replexica.com>
2024-10-21 23:06:54 +00:00
Morgan 9087f5307b fix: platform event-type atom team delete (#17225)
* fix: platform event-type atom team delete

* fixup! fix: platform event-type atom team delete

* fixup! fixup! fix: platform event-type atom team delete
2024-10-21 16:03:04 +00:00
5f6032131b feat: payments tab for event types atom (#16892)
* update translations

* add payments tab to event types platform

* payments tab for event types platform wrapper

* update event types props

* fix icon

* add stripe form

* update event type platform wrapper

* testing EventTypeAppCard component

* update atoms module

* add endpoint to get app integration based on slug

* input dtos for atoms app integration endpoint

* update platform libraries

* add queries for app integration endpoint

* chore: fixup

* custom hook to get app integration

* export TeamsQuery type to use for platform

* update props for stripe atom

* update payments tab

* update platform libraries in local (temperory)

* fix merge conflicts

* fixup

* resolve merge conflicts

* fixup

* Update package.json

* update platform libraries

* fix imports

* resolve merge conflicts

* more import paths fixes

* fixup

* cleanup comments

* include payments tab

* resolve merge conflicts

* fixup

* Update package.json

* revert yarn.lock

* Update yarn.lock

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-21 18:49:35 +05:30
Lauris Skraucis 4e90b65884 refactor: v2 and booker atom booking fields (#17168)
* handle storing disableOnPrefill

* feat: allow overriding properties of default name and email fields

* feat: allow overriding properties of default name and email fields

* feat: output booking fields

* fix: event type test

* refactor: booker atom booking fields handling

* tests

* fix: boolean booking field

* update platform libraries

* fix: unit tests
2024-10-21 13:45:30 +03:00
Lauris Skraucis b7792d0d0a feat: AvailabilitySettings atom toggle toasts (#17216) 2024-10-21 10:03:52 +00:00
Syed Ali Shahbaz c8cd260112 fix: docs typo fix for deployment (#17140)
* Update and rename elastio.mdx to elestio.mdx

* Update mint.json

* Rename deploy-elastio.png to deploy-elestio.png
2024-10-21 06:26:34 -03:00
Calcom BotandReplexica 242268d580 feat: update translations (#17209)
Co-authored-by: Replexica <support@replexica.com>
2024-10-21 08:47:34 +00:00
Morgan 3999f84eff chore: better request/response logging apiv2 (#17210)
* chore: better request/response logging apiv2

* fixup! chore: better request/response logging apiv2
2024-10-21 08:19:31 +00:00
MorganandSomay Chauhan 995bf09dde fix: eventType atom team availability tab (#17158)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-10-21 10:59:07 +03:00
Maximilian FranzkeandAnik Dhabal Babu 59aff41ca3 refactor: make new copy localizable (#17207)
* refactor: make this localizable

base on https://github.com/calcom/cal.com/pull/17198 making the new copy localizable

* Update common.json

* Update common.json

* Update common.json

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-10-21 06:38:25 +00:00
Anik Dhabal BabuandSomay Chauhan b0e3e4c82f fix: Save button is not working on the app install configuration step (#17176)
* fix: Save button is not working on the app install configuration step

* update

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-10-21 05:41:01 +00:00
Calcom BotandReplexica 3e0d53228e feat: update translations (#17180)
Co-authored-by: Replexica <support@replexica.com>
2024-10-20 22:46:04 +00:00
551 changed files with 20591 additions and 6446 deletions
+5
View File
@@ -5,6 +5,7 @@
# - DAILY.CO VIDEO
# - GOOGLE CALENDAR/MEET/LOGIN
# - HUBSPOT
# - HUDDLE01
# - OFFICE 365
# - SLACK
# - STRIPE
@@ -137,3 +138,7 @@ REVERT_PUBLIC_TOKEN=
# NOTE: If you're self hosting Revert, update this URL to point to your own instance.
REVERT_API_URL=https://api.revert.dev/
# *********************************************************************************************************
# - Huddle01
# Used for the huddle01 integration
HUDDLE01_API_TOKEN=
+14 -6
View File
@@ -16,7 +16,7 @@
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
#
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
CALCOM_LICENSE_KEY=
# Signature token for the Cal.com License API (used for self-hosted integrations)
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
@@ -102,6 +102,11 @@ NEXT_PUBLIC_INTERCOM_APP_ID=
# Secret to enable Intercom Identity Verification
INTERCOM_SECRET=
# Posthog Config
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
# Zendesk Config
NEXT_PUBLIC_ZENDESK_KEY=
@@ -128,7 +133,7 @@ GOOGLE_API_CREDENTIALS=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
# Sengrid
# Sendgrid
# Used for email reminders in workflows and internal sync services
SENDGRID_API_KEY=
SENDGRID_EMAIL=
@@ -137,6 +142,9 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
# Sentry
# Used for capturing exceptions and logging messages
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DEBUG=
SENTRY_MAX_SPANS=
SENTRY_TRACES_SAMPLE_RATE=
# Formbricks Experience Management Integration
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
@@ -262,7 +270,7 @@ ORGANIZATIONS_ENABLED=
NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=30
NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE=3700 # $37.00 per seat
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
# This variable should only be set to 1 or true if you want to autolink external provider sign-ups with
# existing organizations based on email domain address
ORGANIZATIONS_AUTOLINK=
@@ -334,9 +342,6 @@ AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=0
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
APP_ROUTER_SETTINGS_ORG_ENABLED=0
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
APP_ROUTER_APPS_SLUG_ENABLED=0
@@ -385,6 +390,9 @@ UNKEY_ROOT_KEY=
# https://retellai.com
RETELL_AI_KEY=
# Used for the huddle01 integration
HUDDLE01_API_TOKEN=
# Used to disallow emails as being added as guests on bookings
BLACKLISTED_GUEST_EMAILS=
@@ -83,11 +83,12 @@ jobs:
**/.turbo/**
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
- name: Log Cache Hit
if: steps.cache-api-v1-build.outputs.cache-hit == 'true'
run: echo "Cache hit for API v1 build. Skipping build."
- name: Run build
if: steps.cache-api-v1-build.outputs.cache-hit != 'true'
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-api-v1-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for API v1 build. Skipping build."
else
yarn turbo run build --filter=@calcom/api...
fi
yarn turbo run build --filter=@calcom/api...
shell: bash
+10 -9
View File
@@ -49,14 +49,15 @@ jobs:
path: |
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
- name: Log Cache Hit
if: steps.cache-api-v2-build.outputs.cache-hit == 'true'
run: echo "Cache hit for API v2 build. Skipping build."
- name: Run build
if: steps.cache-api-v2-build.outputs.cache-hit != 'true'
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-api-v2-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for API v2 build. Skipping build."
else
yarn workspace @calcom/api-v2 run generate-schemas
rm -rf apps/api/v2/node_modules
yarn install
yarn workspace @calcom/api-v2 run build
fi
yarn workspace @calcom/api-v2 run generate-schemas
rm -rf apps/api/v2/node_modules
yarn install
yarn workspace @calcom/api-v2 run build
shell: bash
+10 -9
View File
@@ -5,7 +5,7 @@ on:
jobs:
build:
name: Build atoms
name: Build Atoms
permissions:
contents: read
runs-on: buildjet-4vcpu-ubuntu-2204
@@ -28,13 +28,14 @@ jobs:
path: |
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
- name: Log Cache Hit
if: steps.cache-atoms-build.outputs.cache-hit == 'true'
run: echo "Cache hit for Atoms build. Skipping build."
- name: Run build
if: steps.cache-atoms-build.outputs.cache-hit != 'true'
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-atoms-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for atoms build. Skipping build."
else
rm -rf packages/platform/atoms/node_modules
yarn install
yarn workspace @calcom/atoms run build
fi
rm -rf packages/platform/atoms/node_modules
yarn install
yarn workspace @calcom/atoms run build
shell: bash
+9 -8
View File
@@ -26,16 +26,17 @@ jobs:
path: |
**/docs/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
# Log cache hit
- name: Log Cache Hit
if: steps.cache-docs-build.outputs.cache-hit == 'true'
run: echo "Cache hit for Docs build. Skipping build."
- name: Run build
if: steps.cache-docs-build.outputs.cache-hit != 'true'
working-directory: docs
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-docs-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for Docs build. Skipping build."
else
npm install -g mintlify
mintlify dev &
sleep 5 # Let it run for 5 seconds
kill $!
fi
npm install -g mintlify
mintlify dev &
sleep 5 # Let it run for 5 seconds
kill $!
shell: bash
+4 -1
View File
@@ -168,7 +168,10 @@ export const schemaEventTypeReadPublic = EventType.pick({
metadata: jsonSchema.nullable(),
customInputs: customInputSchema.array().optional(),
link: z.string().optional(),
hashedLink: z.object({ link: z.string() }).optional().nullable(),
hashedLink: z
.array(z.object({ link: z.string() }))
.optional()
.default([]),
bookingFields: eventTypeBookingFields.optional().nullable(),
})
);
@@ -199,6 +199,7 @@ async function handler(req: NextApiRequest) {
eventTypeId,
userId,
returnDateOverrides: true,
bypassBusyCalendarTimes: false,
});
const team = await prisma.team.findUnique({
where: { id: teamId },
@@ -235,6 +236,7 @@ async function handler(req: NextApiRequest) {
dateTo,
eventTypeId,
returnDateOverrides: true,
bypassBusyCalendarTimes: false,
}),
};
});
+1 -1
View File
@@ -28,7 +28,7 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.46",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.56",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -89,7 +89,7 @@ const DEFAULT_PLATFORM_PARAMS = {
@UseGuards(PermissionsGuard)
@DocsExcludeController(true)
export class BookingsController_2024_04_15 {
private readonly logger = new Logger("BookingsController");
private readonly logger = new Logger("BookingsController_2024_04_15");
constructor(
private readonly oAuthFlowService: OAuthFlowService,
@@ -111,8 +111,9 @@ export class BookingsController_2024_04_15 {
@Query() queryParams: GetBookingsInput_2024_04_15
): Promise<GetBookingsOutput_2024_04_15> {
const { filters, cursor, limit } = queryParams;
const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"];
const bookings = await getAllUserBookings({
bookingListingByStatus: [filters.status],
bookingListingByStatus: [bookingListingByStatus],
skip: cursor ?? 0,
take: limit ?? 10,
filters,
@@ -355,13 +356,22 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const requestId = req.get("X-Request-Id");
const clone = { ...req };
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(req, { userId, ...oAuthParams, platformBookingLocation });
req.body = { ...req.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
this.logger.log(`createNextApiBookingRequest_2024_04_15`, {
requestId,
ownerId: userId,
platformBookingLocation,
oAuthClientId,
...oAuthParams,
});
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
clone.body = { ...clone.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
private async createNextApiRecurringBookingRequest(
@@ -369,17 +379,26 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const clone = { ...req };
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
Object.assign(req, {
const requestId = req.get("X-Request-Id");
this.logger.log(`createNextApiRecurringBookingRequest_2024_04_15`, {
requestId,
ownerId: userId,
platformBookingLocation,
oAuthClientId,
...oAuthParams,
});
Object.assign(clone, {
userId,
...oAuthParams,
platformBookingLocation,
noEmail: !oAuthParams.arePlatformEmailsEnabled,
});
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
private handleBookingErrors(
@@ -6,6 +6,8 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -16,7 +18,7 @@ import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule],
imports: [PrismaModule, RedisModule, TokensModule, BillingModule, UsersModule, BookingSeatModule],
providers: [
TokensRepository,
OAuthFlowService,
@@ -26,6 +28,7 @@ import { Module } from "@nestjs/common";
OutputBookingsService_2024_08_13,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
ApiKeyRepository,
],
controllers: [BookingsController_2024_08_13],
@@ -29,6 +29,25 @@ export class BookingsRepository_2024_08_13 {
});
}
async getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids: number[]) {
return this.dbRead.prisma.booking.findMany({
where: {
id: {
in: ids,
},
},
include: {
attendees: {
include: {
bookingSeat: true,
},
},
user: true,
eventType: true,
},
});
}
async getByUid(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
@@ -50,6 +69,23 @@ export class BookingsRepository_2024_08_13 {
});
}
async getByIdWithAttendeesWithBookingSeatAndUserAndEvent(id: number) {
return this.dbRead.prisma.booking.findUnique({
where: {
id,
},
include: {
attendees: {
include: {
bookingSeat: true,
},
},
user: true,
eventType: true,
},
});
}
async getByUidWithAttendeesAndUserAndEvent(uid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
@@ -63,6 +99,31 @@ export class BookingsRepository_2024_08_13 {
});
}
async getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid,
},
include: {
attendees: {
include: {
bookingSeat: true,
},
},
user: true,
eventType: true,
},
});
}
async getRecurringByUid(uid: string) {
return this.dbRead.prisma.booking.findMany({
where: {
recurringEventId: uid,
},
});
}
async getRecurringByUidWithAttendeesAndUserAndEvent(uid: string) {
return this.dbRead.prisma.booking.findMany({
where: {
@@ -34,7 +34,14 @@ import { User } from "@prisma/client";
import { Request } from "express";
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
import {
CancelBookingInput,
CancelBookingInputPipe,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
RescheduleBookingInput,
RescheduleBookingInputPipe,
} from "@calcom/platform-types";
import {
CreateBookingInputPipe,
CreateBookingInput,
@@ -59,7 +66,7 @@ import {
required: true,
})
export class BookingsController_2024_08_13 {
private readonly logger = new Logger("BookingsController");
private readonly logger = new Logger("BookingsController_2024_08_13");
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
@@ -166,7 +173,8 @@ export class BookingsController_2024_08_13 {
})
async rescheduleBooking(
@Param("bookingUid") bookingUid: string,
@Body() body: RescheduleBookingInput_2024_08_13,
@Body(new RescheduleBookingInputPipe())
body: RescheduleBookingInput,
@Req() request: Request
): Promise<RescheduleBookingOutput_2024_08_13> {
const newBooking = await this.bookingsService.rescheduleBooking(request, bookingUid, body);
@@ -185,7 +193,8 @@ export class BookingsController_2024_08_13 {
async cancelBooking(
@Req() request: Request,
@Param("bookingUid") bookingUid: string,
@Body() body: CancelBookingInput_2024_08_13
@Body(new CancelBookingInputPipe())
body: CancelBookingInput
): Promise<CancelBookingOutput_2024_08_13> {
const cancelledBooking = await this.bookingsService.cancelBooking(request, bookingUid, body);
File diff suppressed because it is too large Load Diff
@@ -29,6 +29,7 @@ import {
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
@@ -369,7 +370,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(team1EventTypeId);
});
@@ -384,7 +389,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].eventTypeId).toEqual(team2EventTypeId);
});
@@ -399,7 +408,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data.find((booking) => booking.eventTypeId === team1EventTypeId)).toBeDefined();
expect(data.find((booking) => booking.eventTypeId === team2EventTypeId)).toBeDefined();
@@ -428,6 +441,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(teamUser.email);
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
await app.close();
});
@@ -28,7 +28,11 @@ import {
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
} from "@calcom/platform-constants";
import { GetBookingOutput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
import {
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
@@ -478,7 +482,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(5);
});
});
@@ -492,7 +500,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
});
});
@@ -506,7 +518,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
});
});
@@ -520,7 +536,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(4);
});
});
@@ -534,7 +554,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
});
});
@@ -548,7 +572,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(5);
});
});
@@ -562,7 +590,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
});
});
@@ -576,7 +608,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(1);
});
});
@@ -590,7 +626,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
});
});
@@ -604,7 +644,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
});
});
@@ -618,7 +662,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(5);
});
});
@@ -632,7 +680,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
});
});
@@ -646,7 +698,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(3);
});
});
@@ -660,9 +716,12 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
console.log("asap data", JSON.stringify(data, null, 2));
expect(data[0].start).toEqual(createdBooking.start);
expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString());
});
@@ -677,7 +736,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString());
expect(data[1].start).toEqual(createdBooking.start);
@@ -693,7 +756,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data[0].start).toEqual(createdBooking.start);
expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString());
@@ -709,7 +776,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString());
expect(data[1].start).toEqual(createdBooking.start);
@@ -725,7 +796,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data[0].start).toEqual(createdBooking.start);
expect(data[1].start).toEqual(bookingInThePast.startTime.toISOString());
@@ -741,7 +816,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = responseBody.data;
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(2);
expect(data[0].start).toEqual(bookingInThePast.startTime.toISOString());
expect(data[1].start).toEqual(createdBooking.start);
@@ -765,6 +844,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: RescheduleBookingOutput_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.reschedulingReason).toEqual(body.reschedulingReason);
expect(data.start).toEqual(body.start);
@@ -793,6 +874,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: RescheduleBookingOutput_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.status).toEqual("cancelled");
@@ -3,7 +3,12 @@ import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
import {
BookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
export class CancelBookingOutput_2024_08_13 {
@@ -17,10 +22,19 @@ export class CancelBookingOutput_2024_08_13 {
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
{ type: "array", items: { $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) } },
{ $ref: getSchemaPath(GetSeatedBookingOutput_2024_08_13) },
{ $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) },
{ type: "array", items: { $ref: getSchemaPath(GetRecurringSeatedBookingOutput_2024_08_13) } },
],
description:
"Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects",
})
@Type(() => Object)
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[];
data!:
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13[]
| GetSeatedBookingOutput_2024_08_13
| GetRecurringSeatedBookingOutput_2024_08_13
| GetRecurringSeatedBookingOutput_2024_08_13[];
}
@@ -3,7 +3,12 @@ import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
import {
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
export class CreateBookingOutput_2024_08_13 {
@@ -21,5 +26,9 @@ export class CreateBookingOutput_2024_08_13 {
"Booking data, which can be either a BookingOutput object or an array of RecurringBookingOutput objects",
})
@Type(() => Object)
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13[];
data!:
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13[]
| CreateSeatedBookingOutput_2024_08_13
| CreateRecurringSeatedBookingOutput_2024_08_13[];
}
@@ -3,7 +3,14 @@ import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
import {
BookingOutput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
@ApiExtraModels(BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13)
export class RescheduleBookingOutput_2024_08_13 {
@@ -15,11 +22,17 @@ export class RescheduleBookingOutput_2024_08_13 {
oneOf: [
{ $ref: getSchemaPath(BookingOutput_2024_08_13) },
{ $ref: getSchemaPath(RecurringBookingOutput_2024_08_13) },
{ $ref: getSchemaPath(CreateSeatedBookingOutput_2024_08_13) },
{ $ref: getSchemaPath(CreateRecurringSeatedBookingOutput_2024_08_13) },
],
description:
"Booking data, which can be either a BookingOutput object or a RecurringBookingOutput object",
})
@ValidateNested()
@Type(() => Object)
data!: BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13;
data!:
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| CreateSeatedBookingOutput_2024_08_13
| CreateRecurringSeatedBookingOutput_2024_08_13;
}
@@ -3,6 +3,7 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
@@ -18,7 +19,6 @@ import {
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
RescheduleBookingInput_2024_08_13,
CreateBookingInput,
CreateRecurringBookingInput_2024_08_13,
GetBookingsInput_2024_08_13,
@@ -27,6 +27,10 @@ import {
MarkAbsentBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
RescheduleBookingInput,
CancelBookingInput,
} from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -43,6 +47,7 @@ export class BookingsService_2024_08_13 {
private readonly inputService: InputBookingsService_2024_08_13,
private readonly outputService: OutputBookingsService_2024_08_13,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly bookingSeatRepository: BookingSeatRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly prismaReadService: PrismaReadService,
private readonly billingService: BillingService
@@ -54,9 +59,19 @@ export class BookingsService_2024_08_13 {
return await this.createInstantBooking(request, body);
}
if (await this.isRecurring(body)) {
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
const isRecurring = !!eventType?.recurringEvent;
const isSeated = !!eventType?.seatsPerTimeSlot;
if (isRecurring && isSeated) {
return await this.createRecurringSeatedBooking(request, body);
}
if (isRecurring && !isSeated) {
return await this.createRecurringBooking(request, body);
}
if (isSeated) {
return await this.createSeatedBooking(request, body);
}
return await this.createRegularBooking(request, body);
} catch (error) {
@@ -83,47 +98,71 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputBooking(databaseBooking);
}
async isRecurring(body: CreateBookingInput) {
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
return !!eventType?.recurringEvent;
}
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
const bookings = await handleNewRecurringBooking(bookingRequest);
const uid = bookings[0].recurringEventId;
if (!uid) {
throw new Error("Recurring booking was not created");
}
const ids = bookings.map((booking) => booking.id || 0);
return this.outputService.getOutputRecurringBookings(ids);
}
const recurringBooking = await this.bookingsRepository.getRecurringByUidWithAttendeesAndUserAndEvent(uid);
return this.outputService.getOutputRecurringBookings(recurringBooking);
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
const bookings = await handleNewRecurringBooking(bookingRequest);
return this.outputService.getOutputCreateRecurringSeatedBookings(
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
);
}
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
const booking = await handleNewBooking(bookingRequest);
if (!booking.id) {
throw new Error("Booking was not created");
if (!booking.uid) {
throw new Error("Booking missing uid");
}
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(booking.uid);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} was not found in the database`);
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
return this.outputService.getOutputBooking(databaseBooking);
}
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
const booking = await handleNewBooking(bookingRequest);
if (!booking.uid) {
throw new Error("Booking missing uid");
}
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(
booking.uid
);
if (!databaseBooking) {
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
}
async getBooking(uid: string) {
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(uid);
const booking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(uid);
if (booking) {
const isRecurring = !!booking.recurringEventId;
if (isRecurring) {
const isSeated = !!booking.eventType?.seatsPerTimeSlot;
if (isRecurring && !isSeated) {
return this.outputService.getOutputRecurringBooking(booking);
}
if (isRecurring && isSeated) {
return this.outputService.getOutputRecurringSeatedBooking(booking);
}
if (isSeated) {
return this.outputService.getOutputSeatedBooking(booking);
}
return this.outputService.getOutputBooking(booking);
}
@@ -131,8 +170,13 @@ export class BookingsService_2024_08_13 {
if (!recurringBooking.length) {
throw new NotFoundException(`Booking with uid=${uid} was not found in the database`);
}
const ids = recurringBooking.map((booking) => booking.id);
const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot;
if (isRecurringSeated) {
return this.outputService.getOutputRecurringSeatedBookings(ids);
}
return this.outputService.getOutputRecurringBookings(recurringBooking);
return this.outputService.getOutputRecurringBookings(ids);
}
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
@@ -151,12 +195,17 @@ export class BookingsService_2024_08_13 {
// note(Lauris): fetchedBookings don't have attendees information and responses and i don't want to add them to the handler query,
// because its used elsewhere in code that does not need that information, so i get ids, fetch bookings and then return them formatted in same order as ids.
const ids = fetchedBookings.bookings.map((booking) => booking.id);
const bookings = await this.bookingsRepository.getByIdsWithAttendeesAndUserAndEvent(ids);
const bookings = await this.bookingsRepository.getByIdsWithAttendeesWithBookingSeatAndUserAndEvent(ids);
const bookingMap = new Map(bookings.map((booking) => [booking.id, booking]));
const orderedBookings = ids.map((id) => bookingMap.get(id));
const formattedBookings: (BookingOutput_2024_08_13 | RecurringBookingOutput_2024_08_13)[] = [];
const formattedBookings: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
| GetRecurringSeatedBookingOutput_2024_08_13
)[] = [];
for (const booking of orderedBookings) {
if (!booking) {
continue;
@@ -172,8 +221,13 @@ export class BookingsService_2024_08_13 {
};
const isRecurring = !!formatted.recurringEventId;
if (isRecurring) {
const isSeated = !!formatted.eventType?.seatsPerTimeSlot;
if (isRecurring && !isSeated) {
formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted));
} else if (isRecurring && isSeated) {
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted));
} else if (isSeated) {
formattedBookings.push(this.outputService.getOutputSeatedBooking(formatted));
} else {
formattedBookings.push(this.outputService.getOutputBooking(formatted));
}
@@ -182,7 +236,7 @@ export class BookingsService_2024_08_13 {
return formattedBookings;
}
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput_2024_08_13) {
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
try {
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
request,
@@ -190,18 +244,34 @@ export class BookingsService_2024_08_13 {
body
);
const booking = await handleNewBooking(bookingRequest);
if (!booking.id) {
throw new Error("Booking was not created");
if (!booking.uid) {
throw new Error("Booking missing uid");
}
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
const databaseBooking =
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} was not found in the database`);
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
if (databaseBooking.recurringEventId) {
const isRecurring = !!databaseBooking.recurringEventId;
const isSeated = !!databaseBooking.eventType?.seatsPerTimeSlot;
if (isRecurring && !isSeated) {
return this.outputService.getOutputRecurringBooking(databaseBooking);
}
if (isRecurring && isSeated) {
return this.outputService.getOutputCreateRecurringSeatedBooking(
databaseBooking,
booking?.seatReferenceUid || ""
);
}
if (isSeated) {
return this.outputService.getOutputCreateSeatedBooking(
databaseBooking,
booking.seatReferenceUid || ""
);
}
return this.outputService.getOutputBooking(databaseBooking);
} catch (error) {
if (error instanceof Error) {
@@ -213,7 +283,21 @@ export class BookingsService_2024_08_13 {
}
}
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput_2024_08_13) {
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
if (this.inputService.isCancelSeatedBody(body)) {
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
if (!seat) {
throw new BadRequestException(
"Invalid seatUid: this seat does not exist or has already been cancelled."
);
}
if (seat && bookingUid !== seat.booking.uid) {
throw new BadRequestException("Invalid seatUid: this seat does not belong to this booking.");
}
}
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
await handleCancelBooking(bookingRequest);
return this.getBooking(bookingUid);
@@ -249,7 +333,7 @@ export class BookingsService_2024_08_13 {
}
async billBooking(booking: CreatedBooking) {
const hostId = booking.hosts[0].id;
const hostId = booking.hosts?.[0]?.id;
if (!hostId) {
this.logger.error(`Booking with uid=${booking.uid} has no host`);
return;
@@ -1,8 +1,12 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { bookingResponsesSchema } from "@/ee/bookings/2024-08-13/services/output.service";
import {
bookingResponsesSchema,
seatedBookingResponsesSchema,
} from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
@@ -16,13 +20,17 @@ import { z } from "zod";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import {
CancelBookingInput,
CancelBookingInput_2024_08_13,
CancelSeatedBookingInput_2024_08_13,
CreateBookingInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
GetBookingsInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
RescheduleBookingInput,
RescheduleBookingInput_2024_08_13,
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
@@ -73,7 +81,8 @@ export class InputBookingsService_2024_08_13 {
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly config: ConfigService,
private readonly apiKeyRepository: ApiKeyRepository
private readonly apiKeyRepository: ApiKeyRepository,
private readonly bookingSeatRepository: BookingSeatRepository
) {}
async createBookingRequest(
@@ -82,6 +91,7 @@ export class InputBookingsService_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 newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
@@ -90,6 +100,13 @@ export class InputBookingsService_2024_08_13 {
: DEFAULT_PLATFORM_PARAMS;
const location = request.body.location || request.body.meetingUrl;
this.logger.log(`createBookingRequest_2024_04_15`, {
requestId,
ownerId: userId,
location,
oAuthClientId,
...oAuthParams,
});
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
@@ -239,9 +256,11 @@ export class InputBookingsService_2024_08_13 {
async createRescheduleBookingRequest(
request: Request,
bookingUid: string,
body: RescheduleBookingInput_2024_08_13
body: RescheduleBookingInput
): Promise<BookingRequest> {
const bodyTransformed = await this.transformInputRescheduleBooking(bookingUid, body);
const bodyTransformed = this.isRescheduleSeatedBody(body)
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
: await this.transformInputRescheduleBooking(bookingUid, body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const newRequest = { ...request };
@@ -258,6 +277,60 @@ export class InputBookingsService_2024_08_13 {
return newRequest as unknown as BookingRequest;
}
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
return body.hasOwnProperty("seatUid");
}
async transformInputRescheduleSeatedBooking(
bookingUid: string,
inputBooking: RescheduleSeatedBookingInput_2024_08_13
) {
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
// todo create booking seat module, repository and fetch the seat to get info
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} not found`);
}
if (!booking.eventTypeId) {
throw new NotFoundException(`Booking with uid=${bookingUid} is missing event type`);
}
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(booking.eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event type with id=${booking.eventTypeId} not found`);
}
const seat = await this.bookingSeatRepository.getByReferenceUid(inputBooking.seatUid);
if (!seat) {
throw new NotFoundException(`Seat with uid=${inputBooking.seatUid} does not exist.`);
}
const { responses: bookingResponses } = seatedBookingResponsesSchema.parse(seat.data);
const attendee = booking.attendees.find((attendee) => attendee.email === bookingResponses.email);
if (!attendee) {
throw new NotFoundException(
`Attendee with e-mail ${bookingResponses.email} for booking with uid=${bookingUid} and seatUid=${inputBooking.seatUid} not found`
);
}
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(attendee.timeZone);
const endTime = startTime.plus({ minutes: eventType.length });
return {
start: startTime.toISO(),
end: endTime.toISO(),
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: {},
hasHashedBookingLink: false,
guests: [],
responses: { ...bookingResponses },
rescheduleUid: inputBooking.seatUid,
};
}
async transformInputRescheduleBooking(bookingUid: string, inputBooking: RescheduleBookingInput_2024_08_13) {
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
if (!booking) {
@@ -371,9 +444,11 @@ export class InputBookingsService_2024_08_13 {
async createCancelBookingRequest(
request: Request,
bookingUid: string,
body: CancelBookingInput_2024_08_13
body: CancelBookingInput
): Promise<BookingRequest> {
const bodyTransformed = await this.transformInputCancelBooking(bookingUid, body);
const bodyTransformed = this.isCancelSeatedBody(body)
? await this.transformInputCancelSeatedBooking(bookingUid, body)
: await this.transformInputCancelBooking(bookingUid, body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const newRequest = { ...request };
@@ -389,6 +464,10 @@ export class InputBookingsService_2024_08_13 {
return newRequest as unknown as BookingRequest;
}
isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 {
return body.hasOwnProperty("seatUid");
}
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
let allRemainingBookings = false;
let uid = bookingUid;
@@ -410,6 +489,31 @@ export class InputBookingsService_2024_08_13 {
};
}
async transformInputCancelSeatedBooking(
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;
}
return {
uid,
cancellationReason: "",
allRemainingBookings,
seatReferenceUid: inputBooking.seatUid,
};
}
transformInputMarkAbsentBooking(inputBooking: MarkAbsentBookingInput_2024_08_13) {
return {
noShowHost: inputBooking.host,
@@ -4,15 +4,34 @@ import { plainToClass } from "class-transformer";
import { DateTime } from "luxon";
import { z } from "zod";
import { BookingOutput_2024_08_13, RecurringBookingOutput_2024_08_13 } from "@calcom/platform-types";
import { Booking } from "@calcom/prisma/client";
import {
BookingOutput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
SeatedAttendee,
} from "@calcom/platform-types";
import { Booking, BookingSeat } from "@calcom/prisma/client";
export const bookingResponsesSchema = z
.object({
email: z.string(),
name: z.string(),
guests: z.array(z.string()).optional(),
rescheduledReason: z.string().optional(),
rescheduleReason: z.string().optional(),
})
.passthrough();
export const seatedBookingResponsesSchema = z
.object({
responses: z
.object({
email: z.string(),
name: z.string(),
})
.passthrough(),
})
.passthrough();
@@ -27,6 +46,7 @@ type DatabaseBooking = Booking & {
timeZone: string;
locale: string | null;
noShow: boolean | null;
bookingSeat?: BookingSeat | null;
}[];
} & { user: { id: number; name: string | null; email: string } | null };
@@ -72,17 +92,18 @@ export class OutputBookingsService_2024_08_13 {
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
// note(Lauris): I don't know why plainToClass erases bookings responses so attaching manually
bookingTransformed.bookingFieldsResponses = bookingResponses;
return bookingTransformed;
}
async getOutputRecurringBookings(databaseBookings: DatabaseBooking[]) {
async getOutputRecurringBookings(bookingsIds: number[]) {
const transformed = [];
for (const booking of databaseBookings) {
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(booking.id);
for (const bookingId of bookingsIds) {
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(bookingId);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} was not found in the database`);
throw new Error(`Booking with id=${bookingId} was not found in the database`);
}
transformed.push(this.getOutputRecurringBooking(databaseBooking));
@@ -132,4 +153,159 @@ export class OutputBookingsService_2024_08_13 {
return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
}
getOutputCreateSeatedBooking(
databaseBooking: DatabaseBooking,
seatUid: string
): CreateSeatedBookingOutput_2024_08_13 {
const getSeatedBookingOutput = this.getOutputSeatedBooking(databaseBooking);
return { ...getSeatedBookingOutput, seatUid };
}
getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const booking = {
id: databaseBooking.id,
uid: databaseBooking.uid,
title: databaseBooking.title,
description: databaseBooking.description,
hosts: [databaseBooking.user],
status: databaseBooking.status.toLowerCase(),
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
duration,
eventType: databaseBooking.eventType,
// note(Lauris): eventTypeId is deprecated
eventTypeId: databaseBooking.eventTypeId,
attendees: [],
location: databaseBooking.location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
absentHost: !!databaseBooking.noShowHost,
};
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
parsed.attendees = databaseBooking.attendees.map((attendee) => {
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
const attendeeData = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
seatUid: attendee.bookingSeat?.referenceUid,
bookingFieldsResponses: {},
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
return attendeeParsed;
});
return parsed;
}
async getOutputRecurringSeatedBookings(bookingsIds: number[]) {
const transformed = [];
for (const bookingId of bookingsIds) {
const databaseBooking =
await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(bookingId);
if (!databaseBooking) {
throw new Error(`Booking with id=${bookingId} was not found in the database`);
}
transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking));
}
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
}
async getOutputCreateRecurringSeatedBookings(bookings: { uid: string; seatUid: string }[]) {
const transformed = [];
for (const booking of bookings) {
const databaseBooking =
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
if (!databaseBooking) {
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid));
}
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
}
getOutputCreateRecurringSeatedBooking(
databaseBooking: DatabaseBooking,
seatUid: string
): CreateRecurringSeatedBookingOutput_2024_08_13 {
const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(databaseBooking);
return { ...getRecurringSeatedBookingOutput, seatUid };
}
getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking) {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const booking = {
id: databaseBooking.id,
uid: databaseBooking.uid,
title: databaseBooking.title,
description: databaseBooking.description,
hosts: [databaseBooking.user],
status: databaseBooking.status.toLowerCase(),
cancellationReason: databaseBooking.cancellationReason || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
duration,
eventType: databaseBooking.eventType,
// note(Lauris): eventTypeId is deprecated
eventTypeId: databaseBooking.eventTypeId,
attendees: [],
location: databaseBooking.location,
// note(Lauris): meetingUrl is deprecated
meetingUrl: databaseBooking.location,
recurringBookingUid: databaseBooking.recurringEventId,
absentHost: !!databaseBooking.noShowHost,
};
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
strategy: "excludeAll",
});
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
parsed.attendees = databaseBooking.attendees.map((attendee) => {
const { responses } = seatedBookingResponsesSchema.parse(attendee.bookingSeat?.data);
const attendeeData = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
seatUid: attendee.bookingSeat?.referenceUid,
bookingFieldsResponses: {},
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
return attendeeParsed;
});
return parsed;
}
}
@@ -1,4 +1,4 @@
import { IntegrationLocation } from "@calcom/lib";
import { OrganizerIntegrationLocation } from "@calcom/lib";
type BaseEventType = {
length: number;
@@ -7,7 +7,7 @@ type BaseEventType = {
};
type EventTypeWithLocation = BaseEventType & {
locations: IntegrationLocation[];
locations: OrganizerIntegrationLocation[];
};
const thirtyMinutes: BaseEventType = {
@@ -31,6 +31,7 @@ import {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
NameFieldInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -199,6 +200,13 @@ describe("Event types Endpoints", () => {
});
it("should create an event type", async () => {
const nameBookingField: NameFieldInput_2024_06_14 = {
type: "name",
label: "Your name sir / madam",
placeholder: "john doe",
disableOnPrefill: false,
};
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class",
slug: "coding-class",
@@ -209,8 +217,18 @@ describe("Event types Endpoints", () => {
type: "integration",
integration: "cal-video",
},
{
type: "attendeePhone",
},
{
type: "attendeeAddress",
},
{
type: "attendeeDefined",
},
],
bookingFields: [
nameBookingField,
{
type: "select",
label: "select which language you want to learn",
@@ -218,6 +236,7 @@ describe("Event types Endpoints", () => {
required: true,
placeholder: "select language",
options: ["javascript", "python", "cobol"],
disableOnPrefill: true,
},
],
scheduleId: firstSchedule.id,
@@ -304,12 +323,16 @@ describe("Event types Endpoints", () => {
);
expect(createdEventType.color).toEqual(body.color);
const responseBookingFields = body.bookingFields || [];
const requestBookingFields = body.bookingFields || [];
const expectedBookingFields = [
{ isDefault: true, required: true, slug: "name", type: "name" },
{ isDefault: true, required: true, slug: "name", ...nameBookingField },
{ isDefault: true, required: true, slug: "email", type: "email" },
// note(Lauris): location booking field is added if multiple locations are passed
{ isDefault: true, required: false, slug: "location", type: "radioInput" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
...responseBookingFields.map((field) => ({ isDefault: false, ...field })),
...requestBookingFields
.filter((field) => field.type !== "name" && field.type !== "email")
.map((field) => ({ isDefault: false, ...field })),
];
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
@@ -630,11 +653,30 @@ describe("Event types Endpoints", () => {
});
it("should update event type", async () => {
const nameBookingField: NameFieldInput_2024_06_14 = {
type: "name",
label: "Your name sir / madam",
placeholder: "john doe",
disableOnPrefill: true,
};
const newTitle = "Coding class in Italian!";
const body: UpdateEventTypeInput_2024_06_14 = {
title: newTitle,
scheduleId: secondSchedule.id,
bookingFields: [
nameBookingField,
{
type: "select",
label: "select which language you want to learn",
slug: "select-language",
required: true,
placeholder: "select language",
options: ["javascript", "python", "cobol"],
disableOnPrefill: false,
},
],
bookingLimitsCount: {
day: 4,
week: 10,
@@ -694,7 +736,19 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.description).toEqual(eventType.description);
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(updatedEventType.locations).toEqual(eventType.locations);
expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
const requestBookingFields = body.bookingFields || [];
const expectedBookingFields = [
{ isDefault: true, required: true, slug: "name", ...nameBookingField },
{ isDefault: true, required: true, slug: "email", type: "email" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
...requestBookingFields
.filter((field) => field.type !== "name" && field.type !== "email")
.map((field) => ({ isDefault: false, ...field })),
];
expect(updatedEventType.bookingFields).toEqual(expectedBookingFields);
expect(updatedEventType.ownerId).toEqual(user.id);
expect(updatedEventType.scheduleId).toEqual(secondSchedule.id);
expect(updatedEventType.bookingLimitsCount).toEqual(body.bookingLimitsCount);
@@ -732,6 +786,7 @@ describe("Event types Endpoints", () => {
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
eventType.color = updatedEventType.color;
eventType.bookingFields = updatedEventType.bookingFields;
});
});
@@ -860,7 +915,7 @@ describe("Event types Endpoints", () => {
});
});
describe("Handle legacy event-types booking fields", () => {
describe("Handle event-types booking fields", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
@@ -885,6 +940,7 @@ describe("Event types Endpoints", () => {
{ isDefault: true, required: false, slug: "notes", type: "textarea" },
{ isDefault: true, required: false, slug: "guests", type: "multiemail" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
{ isDefault: true, type: "phone", slug: "attendeePhoneNumber", required: false },
];
beforeAll(async () => {
@@ -1037,6 +1093,21 @@ describe("Event types Endpoints", () => {
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
{
name: "attendeePhoneNumber",
type: "phone",
hidden: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "phone_number",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
@@ -1152,6 +1223,21 @@ describe("Event types Endpoints", () => {
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
{
name: "attendeePhoneNumber",
type: "phone",
hidden: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "phone_number",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
@@ -1179,6 +1265,204 @@ describe("Event types Endpoints", () => {
});
});
it("should return event type with unknown bookingField", async () => {
const unknownSystemField = {
name: "unknown-whatever",
type: "unknown-whatever",
label: "your team",
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: true,
placeholder: "FC Barcelona",
};
const eventTypeInput = {
title: "unknown field event type two",
description: "unknown field event type description two",
length: 40,
hidden: false,
slug: "unknown-field-type-two",
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [unknownSystemField],
};
const eventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
return request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([
{
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(unknownSystemField),
},
]);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
try {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
} catch (e) {
// Event type might have been deleted by the test
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
describe("Handle event-types locations", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "locations-event-types-test-e2e@api.com";
const name = "bob-the-locations-builder";
const username = name;
let user: User;
let legacyEventTypeId1: number;
let legacyEventTypeId2: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
name,
username,
});
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should return integration location with link and credentialId", async () => {
const eventTypeInput = {
title: "event type discord",
description: "event type description",
length: 40,
hidden: false,
slug: "discord-event-type",
locations: [
{
type: "integrations:discord_video",
link: "https://discord.com/users/100",
credentialId: 100,
},
],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [],
};
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
legacyEventTypeId1 = legacyEventType.id;
return request(app.getHttpServer())
.get(`/api/v2/event-types/${legacyEventTypeId1}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.locations).toEqual([
{
type: "integration",
integration: "discord-video",
link: eventTypeInput.locations[0].link,
credentialId: eventTypeInput.locations[0].credentialId,
},
]);
});
});
it("should return unsupported location", async () => {
const eventTypeInput = {
title: "event type not existing",
description: "event type description",
length: 40,
hidden: false,
slug: "not-existing-event-type",
locations: [
{
type: "this-type-does-not-exist",
},
],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [],
};
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
legacyEventTypeId1 = legacyEventType.id;
return request(app.getHttpServer())
.get(`/api/v2/event-types/${legacyEventTypeId1}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.locations).toEqual([
{ type: "unknown", location: JSON.stringify(eventTypeInput.locations[0]) },
]);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -20,6 +20,8 @@ import {
transformEventColorsApiToInternal,
validateCustomEventName,
transformSeatsApiToInternal,
SystemField,
CustomField,
} from "@calcom/platform-libraries";
import {
CreateEventTypeInput_2024_06_14,
@@ -211,16 +213,27 @@ export class InputEventTypesService_2024_06_14 {
inputBookingFields: CreateEventTypeInput_2024_06_14["bookingFields"],
hasMultipleLocations: boolean
) {
const defaultFieldsBefore = [systemBeforeFieldName, systemBeforeFieldEmail];
const customFields: (SystemField | CustomField)[] = inputBookingFields
? transformBookingFieldsApiToInternal(inputBookingFields)
: [];
const customFieldsWithoutNameEmail = customFields.filter(
(field) => field.type !== "name" && field.type !== "email"
);
const customNameField = customFields?.find((field) => field.type === "name");
const customEmailField = customFields?.find((field) => field.type === "email");
const defaultFieldsBefore: (SystemField | CustomField)[] = [
customNameField || systemBeforeFieldName,
customEmailField || systemBeforeFieldEmail,
];
// note(Lauris): if event type has multiple locations then a radio button booking field has to be displayed to allow booker to pick location
if (hasMultipleLocations) {
defaultFieldsBefore.push(systemBeforeFieldLocation);
}
const customFields = transformBookingFieldsApiToInternal(inputBookingFields);
const defaultFieldsAfter = [systemAfterFieldRescheduleReason];
return [...defaultFieldsBefore, ...customFields, ...defaultFieldsAfter];
return [...defaultFieldsBefore, ...customFieldsWithoutNameEmail, ...defaultFieldsAfter];
}
transformInputIntervalLimits(inputBookingFields: CreateEventTypeInput_2024_06_14["bookingLimitsCount"]) {
@@ -7,8 +7,7 @@ import {
transformLocationsInternalToApi,
transformBookingFieldsInternalToApi,
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
InternalLocationSchema,
SystemField,
CustomField,
parseBookingLimit,
@@ -20,12 +19,16 @@ import {
transformEventTypeColorsInternalToApi,
parseEventTypeColor,
transformSeatsInternalToApi,
InternalLocation,
BookingFieldSchema,
} from "@calcom/platform-libraries";
import {
TransformFutureBookingsLimitSchema_2024_06_14,
BookerLayoutsTransformedSchema,
NoticeThresholdTransformedSchema,
EventTypeOutput_2024_06_14,
OutputUnknownLocation_2024_06_14,
OutputUnknownBookingField_2024_06_14,
} from "@calcom/platform-types";
type EventTypeRelations = {
@@ -118,7 +121,7 @@ export class OutputEventTypesService_2024_06_14 {
const locations = this.transformLocations(databaseEventType.locations);
const customName = databaseEventType?.eventName ?? undefined;
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
? this.transformBookingFields(databaseEventType.bookingFields)
: [];
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
@@ -190,7 +193,20 @@ export class OutputEventTypesService_2024_06_14 {
transformLocations(locations: any) {
if (!locations) return [];
return transformLocationsInternalToApi(TransformedLocationsSchema.parse(locations));
const knownLocations: InternalLocation[] = [];
const unknownLocations: OutputUnknownLocation_2024_06_14[] = [];
for (const location of locations) {
const result = InternalLocationSchema.safeParse(location);
if (result.success) {
knownLocations.push(result.data);
} else {
unknownLocations.push({ type: "unknown", location: JSON.stringify(location) });
}
}
return [...transformLocationsInternalToApi(knownLocations), ...unknownLocations];
}
transformDestinationCalendar(destinationCalendar?: DestinationCalendar | null) {
@@ -201,10 +217,26 @@ export class OutputEventTypesService_2024_06_14 {
};
}
transformBookingFields(bookingFields: (SystemField | CustomField)[] | null) {
transformBookingFields(bookingFields: any) {
if (!bookingFields) return [];
return transformBookingFieldsInternalToApi(bookingFields);
const knownBookingFields: (SystemField | CustomField)[] = [];
const unknownBookingFields: OutputUnknownBookingField_2024_06_14[] = [];
for (const bookingField of bookingFields) {
const result = BookingFieldSchema.safeParse(bookingField);
if (result.success) {
knownBookingFields.push(result.data);
} else {
unknownBookingFields.push({
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(bookingField),
});
}
}
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
}
transformRecurringEvent(recurringEvent: any) {
@@ -121,11 +121,13 @@ describe("Schedules Endpoints", () => {
expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
const outputScheduleAvailability = outputSchedule?.availability[0];
expect(outputScheduleAvailability).toBeDefined();
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
if (expectedAvailabilityLength) {
const outputScheduleAvailability = outputSchedule?.availability[0];
expect(outputScheduleAvailability).toBeDefined();
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
}
expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
}
@@ -222,6 +224,33 @@ describe("Schedules Endpoints", () => {
});
});
it("should empty availabilities and overrides", async () => {
const body: UpdateScheduleInput_2024_06_11 = {
availability: [],
overrides: [],
};
return request(app.getHttpServer())
.patch(`/api/v2/schedules/${createdSchedule.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_11)
.send(body)
.expect(200)
.then((response: any) => {
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
const responseSchedule = responseData.data;
const expectedSchedule = {
...createdSchedule,
overrides: body.overrides,
availability: body.availability,
};
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 0);
createdSchedule = responseSchedule;
});
});
it("should delete schedule", async () => {
return request(app.getHttpServer()).delete(`/api/v2/schedules/${createdSchedule.id}`).expect(200);
});
@@ -119,14 +119,17 @@ export class SchedulesRepository_2024_06_11 {
timeZone: schedule.timeZone,
};
const availabilitiesAndOverrides: Prisma.AvailabilityCreateManyInput[] = [];
const createAvailabilityStatements: Prisma.AvailabilityCreateManyInput[] = [];
const createOverridesStatements: Prisma.AvailabilityCreateManyInput[] = [];
const deleteAvailabilityStatements: Prisma.AvailabilityWhereInput[] = [];
const deleteOverridesStatements: Prisma.AvailabilityWhereInput[] = [];
const deleteConditions = [];
if (availability) {
// note(Lauris): availabilities and overrides are stored in the same "Availability" table,
// but availabilities have "date" field as null, while overrides have it as not null, so delete
// condition below results in deleting only rows from Availability table that are availabilities.
deleteConditions.push({
deleteAvailabilityStatements.push({
scheduleId: { equals: scheduleId },
date: null,
});
@@ -136,7 +139,7 @@ export class SchedulesRepository_2024_06_11 {
// note(Lauris): availabilities and overrides are stored in the same "Availability" table,
// but overrides have "date" field as not-null, while availabilities have it as null, so delete
// condition below results in deleting only rows from Availability table that are overrides.
deleteConditions.push({
deleteOverridesStatements.push({
scheduleId: { equals: scheduleId },
NOT: { date: null },
});
@@ -144,7 +147,7 @@ export class SchedulesRepository_2024_06_11 {
if (availability && availability.length > 0) {
availability.forEach((availability) => {
availabilitiesAndOverrides.push({
createAvailabilityStatements.push({
days: availability.days,
startTime: availability.startTime,
endTime: availability.endTime,
@@ -155,7 +158,7 @@ export class SchedulesRepository_2024_06_11 {
if (overrides && overrides.length > 0) {
overrides.forEach((override) => {
availabilitiesAndOverrides.push({
createOverridesStatements.push({
date: override.date,
startTime: override.startTime,
endTime: override.endTime,
@@ -164,11 +167,21 @@ export class SchedulesRepository_2024_06_11 {
});
}
if (availabilitiesAndOverrides.length > 0) {
const deleteStatements = [...deleteAvailabilityStatements, ...deleteOverridesStatements];
const createStatements = [...createAvailabilityStatements, ...createOverridesStatements];
if (deleteStatements.length > 0) {
updateScheduleData.availability = {
deleteMany: deleteConditions,
deleteMany: deleteStatements,
};
}
if (createStatements.length > 0) {
updateScheduleData.availability = {
// note(Lauris): keep deleteMany statements
...(updateScheduleData.availability || {}),
createMany: {
data: availabilitiesAndOverrides,
data: createStatements,
},
};
}
@@ -1,8 +1,11 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger } from "@nestjs/common";
import { Request, Response } from "express";
import { tap } from "rxjs/operators";
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
private readonly logger = new Logger("ResponseInterceptor - NestInterceptor");
intercept(context: ExecutionContext, next: CallHandler) {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
@@ -10,7 +13,24 @@ export class ResponseInterceptor implements NestInterceptor {
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
const { method, url } = request;
const startTime = Date.now();
return next.handle();
return next.handle().pipe(
tap((data) => {
const { statusCode } = response;
const responseTime = Date.now() - startTime;
this.logger.log("Outgoing Response", {
requestId,
method,
url,
statusCode,
responseTime,
responseBody: data,
timestamp: new Date().toISOString(),
});
})
);
}
}
@@ -1,11 +1,25 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Injectable, NestMiddleware, Logger } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
import { v4 as uuid } from "uuid";
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
private readonly logger = new Logger("RequestIdMiddleware - NestMiddleware");
use(req: Request, res: Response, next: NextFunction) {
req.headers["X-Request-Id"] = uuid();
const requestId = uuid();
req.headers["X-Request-Id"] = requestId;
const { method, headers, body: requestBody, baseUrl } = req;
this.logger.log("Incoming Request", {
requestId,
method,
url: baseUrl,
headers,
requestBody,
timestamp: new Date().toISOString(),
});
next();
}
}
@@ -21,4 +21,15 @@ export class AppsRepository {
},
});
}
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
type: type,
key: key,
teamId: teamId,
appId: appId,
},
});
}
}
@@ -1,7 +1,9 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -14,7 +16,9 @@ import { Module } from "@nestjs/common";
providers: [
EventTypesAtomService,
MembershipsRepository,
CredentialsRepository,
UsersRepository,
AtomsRepository,
UsersService,
SchedulesRepository_2024_06_11,
],
@@ -0,0 +1,17 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { paymentDataSelect } from "@calcom/platform-libraries";
@Injectable()
export class AtomsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getRawPayment(uid: string) {
return await this.dbRead.prisma.payment.findFirst({
where: { uid },
select: paymentDataSelect,
});
}
}
@@ -1,4 +1,5 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { EventTypesAppInput } from "@/modules/atoms/inputs/event-types-app.input";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
@@ -13,6 +14,7 @@ import {
VERSION_NEUTRAL,
Patch,
Body,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
@@ -50,6 +52,38 @@ export class AtomsController {
};
}
@Get("event-types-app/:appSlug")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventTypeApp(
@GetUser() user: UserWithProfile,
@Param("appSlug") appSlug: string,
@Query() queryParams: EventTypesAppInput
): Promise<ApiResponse<unknown>> {
const { teamId } = queryParams;
const app = await this.eventTypesService.getEventTypesAppIntegration(appSlug, user.id, user.name, teamId);
return {
status: SUCCESS_STATUS,
data: {
app,
},
};
}
@Get("payment/:uid")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
const data = await this.eventTypesService.getUserPaymentInfo(uid);
return {
status: SUCCESS_STATUS,
data,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -0,0 +1,9 @@
import { Transform } from "class-transformer";
import { IsNumber, IsOptional } from "class-validator";
export class EventTypesAppInput {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@IsOptional()
teamId?: number;
}
@@ -1,5 +1,8 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
@@ -12,13 +15,36 @@ import {
TUpdateEventTypeInputSchema,
systemBeforeFieldEmail,
getEventTypeById,
getEnabledAppsFromCredentials,
getAppFromSlug,
MembershipRole,
EventTypeMetaDataSchema,
} from "@calcom/platform-libraries";
import type {
App,
CredentialDataWithTeamName,
LocationOption,
TeamQuery,
CredentialOwner,
TDependencyData,
CredentialPayload,
} from "@calcom/platform-libraries";
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma/client";
type EnabledAppType = App & {
credential: CredentialDataWithTeamName;
credentials: CredentialDataWithTeamName[];
locationOption: LocationOption | null;
};
@Injectable()
export class EventTypesAtomService {
constructor(
private readonly membershipsRepository: MembershipsRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly atomsRepository: AtomsRepository,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly usersService: UsersService,
private readonly dbWrite: PrismaWriteService,
private readonly dbRead: PrismaReadService,
@@ -146,4 +172,149 @@ export class EventTypesAtomService {
);
}
}
async getEventTypesAppIntegration(slug: string, userId: number, userName: string | null, teamId?: number) {
let credentials = await this.credentialsRepository.getAllUserCredentialsById(userId);
let userTeams: TeamQuery[] = [];
if (teamId) {
const teamsQuery = await this.organizationsTeamsRepository.getUserTeamsById(userId);
// If a team is a part of an org then include those apps
// Don't want to iterate over these parent teams
const filteredTeams: TeamQuery[] = [];
const parentTeams: TeamQuery[] = [];
// Only loop and grab parent teams if a teamId was given. If not then all teams will be queried
if (teamId) {
teamsQuery.forEach((team) => {
if (team?.parent) {
const { parent, ...filteredTeam } = team;
filteredTeams.push(filteredTeam);
// Only add parent team if it's not already in teamsQuery
if (!teamsQuery.some((t) => t.id === parent.id)) {
parentTeams.push(parent);
}
}
});
}
userTeams = [...teamsQuery, ...parentTeams];
const teamAppCredentials: CredentialPayload[] = userTeams.flatMap((teamApp) => {
return teamApp.credentials ? teamApp.credentials.flat() : [];
});
if (teamId) {
credentials = teamAppCredentials;
} else {
credentials = credentials.concat(teamAppCredentials);
}
}
const enabledApps = await getEnabledAppsFromCredentials(credentials, {
where: { slug },
});
const apps = await Promise.all(
enabledApps
.filter(({ ...app }) => app.slug === slug)
.map(
async ({
credentials: _,
credential,
key: _2 /* don't leak to frontend */,
...app
}: EnabledAppType) => {
const userCredentialIds = credentials
.filter((c) => c.appId === app.slug && !c.teamId)
.map((c) => c.id);
const invalidCredentialIds = credentials
.filter((c) => c.appId === app.slug && c.invalid)
.map((c) => c.id);
const teams = await Promise.all(
credentials
.filter((c) => c.appId === app.slug && c.teamId)
.map(async (c) => {
const team = userTeams.find((team) => team.id === c.teamId);
if (!team) {
return null;
}
return {
teamId: team.id,
name: team.name,
logoUrl: team.logoUrl,
credentialId: c.id,
isAdmin:
team.members[0].role === MembershipRole.ADMIN ||
team.members[0].role === MembershipRole.OWNER,
};
})
);
const isSetupAlready = credential && app.categories.includes("payment") ? true : undefined;
let dependencyData: TDependencyData = [];
if (app.dependencies?.length) {
dependencyData = app.dependencies.map((dependency) => {
const dependencyInstalled = enabledApps.some(
(dbAppIterator: EnabledAppType) =>
dbAppIterator.credentials.length && dbAppIterator.slug === dependency
);
// If the app marked as dependency is simply deleted from the codebase,
// we can have the situation where App is marked installed in DB but we couldn't get the app.
const dependencyName = getAppFromSlug(dependency)?.name;
return { name: dependencyName, installed: dependencyInstalled };
});
}
const credentialOwner: CredentialOwner = {
name: userName,
teamId,
};
return {
...app,
...(teams.length && {
credentialOwner,
}),
userCredentialIds,
invalidCredentialIds,
teams,
isInstalled: !!userCredentialIds.length || !!teams.length || app.isGlobal,
isSetupAlready,
...(app.dependencies && { dependencyData }),
};
}
)
);
return apps[0];
}
async getUserPaymentInfo(uid: string) {
const rawPayment = await this.atomsRepository.getRawPayment(uid);
if (!rawPayment) throw new NotFoundException(`Payment with uid ${uid} not found`);
const { data, booking: _booking, ...restPayment } = rawPayment;
const payment = {
...restPayment,
data: data as Record<string, unknown>,
};
if (!_booking) throw new NotFoundException(`Booking with uid ${uid} not found`);
const { startTime, endTime, eventType, ...restBooking } = _booking;
const booking = {
...restBooking,
startTime: startTime.toString(),
endTime: endTime.toString(),
};
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
if (eventType.users.length === 0 && !!!eventType.team)
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
const [user] = eventType?.users.length
? eventType.users
: [{ name: null, theme: null, hideBranding: null, username: null }];
const profile = {
name: eventType.team?.name || user?.name || null,
theme: (!eventType.team?.name && user?.theme) || null,
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
};
return {
user,
eventType: {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
},
booking,
payment,
clientSecret: getClientSecretFromPayment(payment),
profile,
};
}
}
@@ -0,0 +1,42 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsUserInOrgTeam implements CanActivate {
constructor(private organizationsRepository: OrganizationsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId: string = request.params.teamId;
const orgId: string = request.params.orgId;
const userId: string = request.params.userId;
if (!userId) {
throw new ForbiddenException("No user id found in request params.");
}
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
}
if (!teamId) {
throw new ForbiddenException("No team id found in request params.");
}
const user = await this.organizationsRepository.findOrgTeamUser(
Number(orgId),
Number(teamId),
Number(userId)
);
if (user) {
request.user = user;
return true;
}
return false;
}
}
@@ -0,0 +1,10 @@
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
providers: [BookingSeatRepository],
exports: [BookingSeatRepository],
})
export class BookingSeatModule {}
@@ -0,0 +1,20 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
@Injectable()
export class BookingSeatRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getByReferenceUid(referenceUid: string) {
return this.dbRead.prisma.bookingSeat.findUnique({
where: {
referenceUid,
},
include: {
booking: { select: { uid: true } },
},
});
}
}
@@ -4,6 +4,7 @@ import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
import { APPS_TYPE_ID_MAPPING } from "@calcom/platform-constants";
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
@Injectable()
export class CredentialsRepository {
@@ -36,6 +37,10 @@ export class CredentialsRepository {
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
}
getByTypeAndTeamId(type: string, teamId: number) {
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
}
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
}
@@ -65,6 +70,18 @@ export class CredentialsRepository {
});
}
async getAllUserCredentialsById(userId: number) {
return await this.dbRead.prisma.credential.findMany({
where: {
userId,
},
select: credentialForCalendarServiceSelect,
orderBy: {
id: "asc",
},
});
}
async getUserCredentialById(userId: number, credentialId: number, type: string) {
return await this.dbRead.prisma.credential.findUnique({
where: {
@@ -248,6 +248,30 @@ describe("OAuth Client Users Endpoints", () => {
expect(schedule?.userId).toEqual(userId);
}
it(`should fail /POST using already used managed user email`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
.set("x-cal-secret-key", oAuthClient.secret)
.send(requestBody)
.expect(409);
const responseBody: CreateManagedUserOutput = response.body;
const error = responseBody.error;
expect(error).toBeDefined();
expect(error?.message).toEqual(
`User with the provided e-mail already exists. Existing user ID=${postResponseData.user.id}`
);
});
it(`/GET: return list of managed users`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/oauth-clients/${oAuthClient.id}/users?limit=10&offset=0`)
@@ -486,7 +510,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(oAuthClient1).toBeDefined();
});
it(`should create managed user and update team event-types of OAuthClient marked as assignAllTeamMembers: true`, async () => {
it(`should create managed user and update team event-types`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
@@ -508,48 +532,13 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
await userHasCorrectEventTypes(responseBody.data.user.id);
await teamHasCorrectEventTypes(team1.id);
expect(responseBody.data.user.name).toEqual(requestBody.name);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(5);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): default event types
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
).toBeTruthy();
}
async function teamHasCorrectEventTypes(teamId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
expect(eventTypes?.length).toEqual(2);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
expect(collective).toBeTruthy();
expect(collective?.hosts).toBeDefined();
expect(collective?.hosts?.length).toEqual(1);
expect(collective?.hosts[0].userId).toEqual(postResponseData.user.id);
}
afterAll(async () => {
@@ -34,4 +34,6 @@ export class CreateManagedUserOutput {
@ValidateNested()
@Type(() => CreateManagedUserData)
data!: CreateManagedUserData;
error?: Error;
}
@@ -5,7 +5,7 @@ import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
@@ -26,9 +26,11 @@ export class OAuthClientUsersService {
isPlatformManaged: boolean,
organizationId?: number
) {
const existsWithEmail = await this.managedUserExistsWithEmail(oAuthClientId, body.email);
if (existsWithEmail) {
throw new BadRequestException("User with the provided e-mail already exists.");
const existingUser = await this.getExistingUserByEmail(oAuthClientId, body.email);
if (existingUser) {
throw new ConflictException(
`User with the provided e-mail already exists. Existing user ID=${existingUser.id}`
);
}
let user: User;
@@ -81,8 +83,6 @@ export class OAuthClientUsersService {
user.defaultScheduleId = defaultSchedule.id;
}
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
return {
user,
tokens: {
@@ -93,10 +93,9 @@ export class OAuthClientUsersService {
};
}
async managedUserExistsWithEmail(oAuthClientId: string, email: string) {
async getExistingUserByEmail(oAuthClientId: string, email: string) {
const oAuthEmail = this.getOAuthUserEmail(oAuthClientId, email);
const user = await this.userRepository.findByEmail(oAuthEmail);
return !!user;
return await this.userRepository.findByEmail(oAuthEmail);
}
async updateOAuthClientUser(oAuthClientId: string, userId: number, body: UpdateManagedUserInput) {
@@ -387,15 +387,18 @@ describe("Organizations Event Types Endpoints", () => {
1
);
const responseTeamEvent = responseBody.data[0];
expect(responseTeamEvent?.teamId).toEqual(team.id);
const responseTeamEvent = responseBody.data.find((event) => event.teamId === team.id);
expect(responseTeamEvent).toBeDefined();
if (!responseTeamEvent) {
throw new Error("Team event not found");
}
const responseTeammate1Event = responseBody.data[1];
expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id);
const responseTeammate1Event = responseBody.data.find((event) => event.ownerId === teammate1.id);
expect(responseTeammate1Event).toBeDefined();
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
const responseTeammate2Event = responseBody.data[2];
expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id);
const responseTeammate2Event = responseBody.data.find((event) => event.ownerId === teammate2.id);
expect(responseTeammate2Event).toBeDefined();
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
managedEventType = responseTeamEvent;
@@ -2,14 +2,16 @@ import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/outputs/organization-teams-memberships.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { EventType, User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -25,6 +27,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
@@ -35,9 +38,11 @@ describe("Organizations Teams Memberships Endpoints", () => {
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
let teamEventType: EventType;
let managedEventType: EventType;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: Membership;
let membershipCreatedViaApi: OrgTeamMembershipOutputDto;
const userEmail = "org-admin-membership-teams-controller-e2e@api.com";
const userEmail2 = "org-member-membership-teams-controller-e2e@api.com";
@@ -65,6 +70,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -96,6 +102,32 @@ describe("Organizations Teams Memberships Endpoints", () => {
parent: { connect: { id: org.id } },
});
teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: orgTeam.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: orgTeam.id },
},
title: "Managed Event Type",
slug: "managed-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: "Non Org Team",
isOrganization: false,
@@ -200,7 +232,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership.id);
expect(responseBody.data[1].id).toEqual(membership2.id);
@@ -219,7 +251,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership2.id);
expect(responseBody.data[0].userId).toEqual(user2.id);
@@ -238,10 +270,11 @@ describe("Organizations Teams Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membership.id);
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.user.email).toEqual(user.email);
});
});
@@ -262,7 +295,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
.expect(404);
});
it("should create the membership of the org's team", async () => {
it("should have created the membership of the org's team and assigned team wide events", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.send({
@@ -272,15 +305,29 @@ describe("Organizations Teams Memberships Endpoints", () => {
} satisfies CreateOrgTeamMembershipDto)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id);
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
});
});
async function userHasCorrectEventTypes(userId: number) {
const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(orgTeam.id);
expect(managedEventTypes?.length).toEqual(1);
expect(teamEventTypes?.length).toEqual(2);
const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug);
expect(collectiveEvenType).toBeTruthy();
const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId);
expect(userHost).toBeTruthy();
expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
}
it("should fail to create the membership of the org's team for a non org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
@@ -300,7 +347,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
} satisfies UpdateOrgTeamMembershipDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.role).toEqual("OWNER");
@@ -312,7 +359,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
.delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
});
@@ -15,6 +15,7 @@ import {
OrgTeamMembershipsOutputResponseDto,
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/outputs/organization-teams-memberships.output";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
import {
Controller,
@@ -35,6 +36,7 @@ import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
@@ -46,6 +48,7 @@ import { SkipTakePagination } from "@calcom/platform-types";
export class OrganizationsTeamsMembershipsController {
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private oganizationsEventTypesService: OrganizationsEventTypesService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@@ -112,6 +115,9 @@ export class OrganizationsTeamsMembershipsController {
teamId,
membershipId
);
await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
@@ -129,15 +135,25 @@ export class OrganizationsTeamsMembershipsController {
@Param("membershipId", ParseIntPipe) membershipId: number,
@Body() data: UpdateOrgTeamMembershipDto
): Promise<OrgTeamMembershipOutputResponseDto> {
const membership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
const currentMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership(
orgId,
teamId,
membershipId
);
const updatedMembership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
orgId,
teamId,
membershipId,
data
);
if (!currentMembership.accepted && updatedMembership.accepted) {
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }),
};
}
@@ -158,6 +174,9 @@ export class OrganizationsTeamsMembershipsController {
}
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
if (membership.accepted) {
await updateNewTeamMemberEventTypes(user.id, teamId);
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
@@ -0,0 +1,43 @@
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard";
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, IsTeamInOrg, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Teams / Schedules")
export class OrganizationsTeamsSchedulesController {
constructor(private schedulesService: SchedulesService_2024_06_11) {}
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(IsUserInOrgTeam)
@Get("/users/:userId/schedules")
@DocsTags("Orgs / Teams / Users / Schedules")
@ApiOperation({ summary: "Get schedules of a team member" })
async getUserSchedules(
@Param("userId", ParseIntPipe) userId: number
): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(userId);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
}
@@ -0,0 +1,227 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.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 { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
import { Team, Schedule } from "@calcom/prisma/client";
describe("Organizations Teams Schedules Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
let user2Schedule: Schedule;
const userEmail = "org-admin-schedules-teams-controller-e2e@api.com";
const userEmail2 = "org-member-schedules-teams-controller-e2e@api.com";
const nonOrgUserEmail = "non-org-member-schedules-teams-controller-e2e@api.com";
const invitedUserEmail = "org-member-invited-schedules-teams-controller-e2e@api.com";
let user: User;
let user2: User;
let nonOrgUser: User;
let userToInviteViaApi: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
});
user2Schedule = await scheduleRepositoryFixture.create({
user: {
connect: {
id: user2.id,
},
},
name: "User 2 schedule",
timeZone: "America/New_York",
});
nonOrgUser = await userRepositoryFixture.create({
email: nonOrgUserEmail,
username: nonOrgUserEmail,
});
userToInviteViaApi = await userRepositoryFixture.create({
email: invitedUserEmail,
username: invitedUserEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
orgTeam = await teamsRepositoryFixture.create({
name: "Org Team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: "Non Org Team",
isOrganization: false,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: orgTeam.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: orgTeam.id } },
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: userToInviteViaApi.id } },
team: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: nonOrgUser.id } },
team: { connect: { id: nonOrgTeam.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${user2.id}`,
username: userEmail2,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user2.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${userToInviteViaApi.id}`,
username: invitedUserEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: userToInviteViaApi.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should get all the schedule of the org's team's member", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/users/${user2.id}/schedules`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<ScheduleOutput_2024_06_11[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.find((d) => d.id === user2Schedule.id)?.name).toEqual(user2Schedule.name);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await organizationsRepositoryFixture.delete(org.id);
await teamsRepositoryFixture.delete(nonOrgTeam.id);
await app.close();
});
});
});
@@ -469,34 +469,13 @@ describe("Organizations Users Endpoints", () => {
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
userHasCorrectEventTypes(userData.id);
createdUser = userData;
teamHasCorrectEventTypes(team.id);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(1);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
}
async function teamHasCorrectEventTypes(teamId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
expect(eventTypes?.length).toEqual(2);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
expect(collective).toBeTruthy();
expect(collective?.hosts).toBeDefined();
expect(collective?.hosts?.length).toEqual(1);
expect(collective?.hosts[0].userId).toEqual(createdUser.id);
}
afterAll(async () => {
@@ -11,6 +11,7 @@ import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/contro
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/controllers/teams/schedules/organizations-teams-schedules.controller";
import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
import { OrganizationsWebhooksController } from "@/modules/organizations/controllers/webhooks/organizations-webhooks.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
@@ -112,6 +113,7 @@ import { Module } from "@nestjs/common";
OrganizationsAttributesController,
OrganizationsOptionsAttributesController,
OrganizationsWebhooksController,
OrganizationsTeamsSchedulesController,
],
})
export class OrganizationsModule {}
@@ -103,6 +103,24 @@ export class OrganizationsRepository {
});
}
async findOrgTeamUser(organizationId: number, teamId: number, userId: number) {
return this.dbRead.prisma.user.findUnique({
where: {
id: userId,
profiles: {
some: {
organizationId,
},
},
teams: {
some: {
teamId: teamId,
},
},
},
});
}
async fetchOrgAdminApiStatus(organizationId: number) {
return this.dbRead.prisma.organizationSettings.findUnique({
where: {
@@ -5,6 +5,27 @@ import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class MembershipUserOutputDto {
@IsOptional()
@IsString()
@Expose()
readonly avatarUrl?: string;
@IsOptional()
@IsString()
@Expose()
readonly username?: string;
@IsOptional()
@IsString()
@Expose()
readonly name?: string;
@IsBoolean()
@Expose()
readonly email!: string;
}
export class OrgTeamMembershipOutputDto {
@IsInt()
@Expose()
@@ -31,6 +52,11 @@ export class OrgTeamMembershipOutputDto {
@IsBoolean()
@Expose()
readonly disableImpersonation?: boolean;
@ValidateNested()
@Type(() => MembershipUserOutputDto)
@Expose()
user!: MembershipUserOutputDto;
}
export class OrgTeamMembershipsOutputResponseDto {
@@ -70,4 +70,26 @@ export class OrganizationsEventTypesRepository {
include: { children: true },
});
}
async deleteUserManagedTeamEventTypes(userId: number, teamId: number) {
return this.dbWrite.prisma.eventType.deleteMany({
where: {
parent: {
teamId,
},
userId,
},
});
}
async removeUserFromTeamEventTypesHosts(userId: number, teamId: number) {
return this.dbWrite.prisma.host.deleteMany({
where: {
userId,
eventType: {
teamId,
},
},
});
}
}
@@ -9,13 +9,14 @@ export class OrganizationsTeamsMembershipsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async findOrgTeamMembershipsPaginated(organizationId: number, teamId: number, skip: number, take: number) {
return this.dbRead.prisma.membership.findMany({
return await this.dbRead.prisma.membership.findMany({
where: {
teamId: teamId,
team: {
parentId: organizationId,
},
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
skip,
take,
});
@@ -30,6 +31,7 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
});
}
async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) {
@@ -59,12 +61,14 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
});
}
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
});
}
}
@@ -4,6 +4,8 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { credentialForCalendarServiceSelect } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -119,4 +121,53 @@ export class OrganizationsTeamsRepository {
return team.members.map((member) => member.userId);
}
async getUserTeamsById(userId: number) {
const userTeams = await this.dbRead.prisma.team.findMany({
where: {
members: {
some: {
userId,
accepted: true,
},
},
},
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
parent: {
select: {
id: true,
credentials: {
select: credentialForCalendarServiceSelect,
},
name: true,
logoUrl: true,
members: {
where: {
userId,
},
select: {
role: true,
},
},
},
},
},
});
return userTeams;
}
}
@@ -6,13 +6,15 @@ import { DatabaseTeamEventType } from "@/modules/organizations/services/event-ty
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsEventTypesService {
private readonly logger = new Logger("OrganizationsEventTypesService");
constructor(
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly dbWrite: PrismaWriteService,
@@ -145,4 +147,17 @@ export class OrganizationsEventTypesService {
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.organizationEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.organizationEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
}
}
@@ -5,8 +5,6 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/repositori
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsService {
constructor(
@@ -69,23 +67,4 @@ export class OrganizationsTeamsService {
}
return team;
}
async addUserToTeamEvents(userId: number, organizationId: number) {
const orgTeams = await this.organizationsTeamRepository.findOrgTeams(organizationId);
for (const team of orgTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
async addUserToPlatformTeamEvents(userId: number, organizationId: number, oAuthClientId: string) {
const oAuthClientTeams = await this.organizationsTeamRepository.getPlatformOrgTeams(
organizationId,
oAuthClientId
);
for (const team of oAuthClientTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
}
@@ -79,8 +79,6 @@ export class OrganizationsUsersService {
updateUserBody
);
await this.organizationsTeamsService.addUserToTeamEvents(user.id, org.id);
// Need to send email to new user to create password
await this.emailService.sendSignupToOrganizationEmail({
usernameOrEmail,
@@ -21,6 +21,7 @@ import {
Req,
BadRequestException,
Headers,
Param,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiOperation } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
@@ -46,7 +47,8 @@ export class StripeController {
@Headers("Authorization") authorization: string,
@GetUser() user: UserWithProfile,
@Query("redir") redir?: string | null,
@Query("errorRedir") errorRedir?: string | null
@Query("errorRedir") errorRedir?: string | null,
@Query("teamId") teamId?: string | null
): Promise<StripConnectOutputResponseDto> {
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
@@ -56,6 +58,7 @@ export class StripeController {
fromApp: false,
returnTo: !!redir ? redir : origin,
accessToken,
teamId: Number(teamId) ?? null,
};
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
@@ -99,6 +102,16 @@ export class StripeController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check stripe connection" })
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfStripeAccountConnected(user.id);
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
}
@Get("/check/:teamId")
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check team stripe connection" })
async checkTeamStripeConnection(
@Param("teamId") teamId: string
): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
}
}
@@ -1,5 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
import { StripeService } from "@/modules/stripe/stripe.service";
@@ -10,7 +11,7 @@ import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule, PrismaModule],
exports: [StripeService],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
controllers: [StripeController],
})
export class StripeModule {}
@@ -1,13 +1,14 @@
import { AppConfig } from "@/config/type";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import type { Prisma, Credential } from "@prisma/client";
import Stripe from "stripe";
import { z } from "zod";
@@ -17,6 +18,14 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
import stringify = require("qs-stringify");
type IntegrationOAuthCallbackState = {
accessToken: string;
returnTo: string;
onErrorReturnTo: string;
fromApp: boolean;
teamId?: number | null;
};
@Injectable()
export class StripeService {
public stripe: Stripe;
@@ -27,7 +36,8 @@ export class StripeService {
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository
private readonly tokensRepository: TokensRepository,
private readonly membershipRepository: MembershipsRepository
) {
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
apiVersion: "2020-08-27",
@@ -76,6 +86,7 @@ export class StripeService {
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
@@ -92,6 +103,19 @@ export class StripeService {
data["default_currency"] = account.default_currency;
}
if (oAuthCallbackState.teamId) {
await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
await this.appsRepository.createTeamAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
oAuthCallbackState.teamId,
"stripe"
);
return { url: getReturnToValueFromQueryState(state) };
}
await this.appsRepository.createAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
@@ -102,18 +126,39 @@ export class StripeService {
return { url: getReturnToValueFromQueryState(state) };
}
async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
if (!stripeCredentials) {
return await this.validateStripeCredentials(stripeCredentials);
}
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId);
return await this.validateStripeCredentials(stripeCredentials);
}
async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) {
const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId);
const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER";
if (!hasAdminAccessToTeam) {
throw new BadRequestException("You must be team owner or admin to do this");
}
}
async validateStripeCredentials(
credentials?: Credential | null
): Promise<{ status: typeof SUCCESS_STATUS }> {
if (!credentials) {
throw new NotFoundException("Credentials for stripe not found.");
}
if (stripeCredentials.invalid) {
if (credentials.invalid) {
throw new BadRequestException("Invalid stripe credentials.");
}
const stripeKey = JSON.stringify(stripeCredentials.key);
const stripeKey = JSON.stringify(credentials.key);
const stripeKeyObject = JSON.parse(stripeKey);
const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id);
@@ -33,7 +33,8 @@ export class CreateManagedUserInput {
@ApiProperty({
example: "America/New_York",
description: `Timezone is used to create user's default schedule from Monday to Friday from 9AM to 5PM. If it is not passed then user does not have
a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked.`,
a default schedule and it must be created manually via the /schedules endpoint. Until the schedule is created, the user can't access availability atom to set his / her availability nor booked.
It will default to Europe/London if not passed.`,
})
timeZone?: string;
File diff suppressed because it is too large Load Diff
-3
View File
@@ -8,9 +8,6 @@ import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME }
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
["/settings/developer/:path*", process.env.APP_ROUTER_SETTINGS_DEVELOPER_ENABLED === "1"] as const,
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
["/settings/my-account/:path*", process.env.APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED === "1"] as const,
["/settings/platform/:path*", process.env.APP_ROUTER_SETTINGS_PLATFORM_ENABLED === "1"] as const,
["/settings/organizations/:path*", process.env.APP_ROUTER_SETTINGS_ORG_ENABLED === "1"] as const,
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const,
+6
View File
@@ -1 +1,7 @@
export { GET } from "@calcom/features/tasker/api/cron";
/**
* This runs each minute and we need fresh data each time
* @see https://nextjs.org/docs/app/building-your-application/caching#opting-out-2
**/
export const revalidate = 0;
+1 -2
View File
@@ -4,7 +4,7 @@ import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/apps/getServerSideProps";
import AppsPage, { LayoutWrapper } from "~/apps/apps-view";
import AppsPage from "~/apps/apps-view";
export const generateMetadata = async () => {
return await _generateMetadata(
@@ -14,7 +14,6 @@ export const generateMetadata = async () => {
};
export default WithLayout({
getLayout: LayoutWrapper,
getData: withAppDirSsr(getServerSideProps),
Page: AppsPage,
});
@@ -1,20 +0,0 @@
import { _generateMetadata } from "app/_utils";
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Feature Flags",
() => "Here you can toggle your Cal.com instance features."
);
const Page = async () => {
return (
<SettingsHeader title="Feature Flags" description="Here you can toggle your Cal.com instance features.">
<FlagListingView />
</SettingsHeader>
);
};
export default Page;
@@ -1,21 +0,0 @@
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LockedSMSView from "~/settings/admin/locked-sms-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Locked SMS",
() => "Lock or unlock SMS sending for users"
);
const Page = () => {
return (
<SettingsHeader title="Locked SMS" description="Lock or unlock SMS sending for users">
<LockedSMSView />
</SettingsHeader>
);
};
export default Page;
@@ -1,21 +0,0 @@
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LegacyPage from "~/settings/admin/oauth-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth",
() => "Add new OAuth Clients"
);
const Page = () => {
return (
<SettingsHeader title="OAuth" description="Add new OAuth Clients">
<LegacyPage />
</SettingsHeader>
);
};
export default Page;
@@ -1,20 +0,0 @@
import { _generateMetadata } from "app/_utils";
import UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Add new user",
() => "Here you can add a new user."
);
const Page = () => {
return (
<SettingsHeader title="Add new user" description="Here you can add a new user">
<UsersAddView />
</SettingsHeader>
);
};
export default Page;
@@ -5,7 +5,7 @@ import EditView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-vie
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth client updation form",
(t) => t("oAuth_client_updation_form"),
() => ""
);
@@ -5,7 +5,7 @@ import EditWebhooksView from "~/settings/platform/oauth-clients/[clientId]/edit/
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth client updation form",
(t) => t("oAuth_client_updation_form"),
() => ""
);
+1
View File
@@ -25,6 +25,7 @@ export function WithLayout<T extends Record<string, any>>({
isBookingPage,
requiresLicense,
}: WithLayoutParams<T>) {
// eslint-disable-next-line react/display-name
return async <P extends "P" | "L">(p: P extends "P" ? PageProps : LayoutProps) => {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
@@ -0,0 +1,23 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("feature_flags"),
(t) => t("admin_flags_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("feature_flags")} description={t("admin_flags_description")}>
<FlagListingView />
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,24 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LockedSMSView from "~/settings/admin/locked-sms-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("lockedSMS"),
(t) => t("admin_lockedSMS_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("lockedSMS")} description={t("admin_lockedSMS_description")}>
<LockedSMSView />
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,24 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LegacyPage from "~/settings/admin/oauth-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("oAuth"),
(t) => t("admin_oAuth_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("oAuth")} description={t("admin_oAuth_description")}>
<LegacyPage />
</SettingsHeader>
);
};
export default Page;
@@ -1,8 +1,9 @@
import { type Params } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { _generateMetadata, getFixedT } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -14,16 +15,16 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
const input = orgIdSchema.safeParse(params);
if (!input.success) {
return await _generateMetadata(
() => `Editing organization`,
() => "Here you can edit an organization."
(t) => t("editing_org"),
(t) => t("admin_orgs_edit_description")
);
}
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
return await _generateMetadata(
() => `Editing organization: ${org.name}`,
() => "Here you can edit an organization."
(t) => `${t("editing_org")}: ${org.name}`,
(t) => t("admin_orgs_edit_description")
);
};
@@ -34,11 +35,12 @@ const Page = async ({ params }: { params: Params }) => {
try {
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader
title={`Editing organization: ${org.name}`}
description="Here you can edit an organization.">
title={`${t("editing_org")}: ${org.name}`}
description={t("admin_orgs_edit_description")}>
<LicenseRequired>
<OrgForm org={org} />
</LicenseRequired>
@@ -2,8 +2,8 @@ import { _generateMetadata } from "app/_utils";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Admin",
() => "admin_description"
(t) => t("admin"),
() => ""
);
const Page = () => <h1>Admin index</h1>;
@@ -1,8 +1,9 @@
import { type Params } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { _generateMetadata, getFixedT } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -14,16 +15,16 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
const input = userIdSchema.safeParse(params);
if (!input.success) {
return await _generateMetadata(
() => `Editing user`,
() => "Here you can edit a current user."
(t) => t("editing_user"),
(t) => t("admin_users_edit_description")
);
}
const user = await UserRepository.adminFindById(input.data.id);
return await _generateMetadata(
() => `Editing user: ${user.username}`,
() => "Here you can edit a current user."
(t) => `${t("editing_user")}: ${user.username}`,
(t) => t("admin_users_edit_description")
);
};
@@ -36,9 +37,11 @@ const Page = async ({ params }: { params: Params }) => {
try {
const user = await UserRepository.adminFindById(input.data.id);
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title="Editing user" description="Here you can edit a current user">
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
<LicenseRequired>
<UsersEditView user={user} />
</LicenseRequired>
@@ -0,0 +1,24 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("add_new_user"),
(t) => t("admin_users_add_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("add_new_user")} description={t("admin_users_add_description")}>
<UsersAddView />
</SettingsHeader>
);
};
export default Page;
@@ -1,20 +1,23 @@
import { _generateMetadata } from "app/_utils";
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import UsersListingView from "@calcom/features/ee/users/pages/users-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { Button } from "@calcom/ui";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Users",
() => "A list of all the users in your account including their name, title, email and role."
(t) => t("users"),
(t) => t("admin_users_description")
);
const Page = () => {
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader
title="Users"
description="A list of all the users in your account including their name, title, email and role."
title={t("users")}
description={t("admin_users_description")}
CTA={
<div className="mt-4 space-x-5 sm:ml-16 sm:mt-0 sm:flex-none">
{/* TODO: Add import users functionality */}
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import PlatformBillingUpgrade from "~/settings/platform/billing/billing-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("platform_billing"),
(t) => t("manage_billing_description")
);
};
export default WithLayout({
Page: PlatformBillingUpgrade,
});
@@ -0,0 +1,21 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
import LegacyPage, { LayoutWrapper } from "~/settings/platform/new/create-new-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("set_up_your_platform_organization"),
(t) => t("platform_organization_description")
);
export default WithLayout({
getLayout: LayoutWrapper,
Page: LegacyPage,
getData: withAppDirSsr<inferSSRProps<typeof getServerSideProps>>(getServerSideProps),
requiresLicense: true,
});
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import EditView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("oAuth_client_updation_form"),
() => ""
);
export default WithLayout({
getLayout: null,
Page: EditView,
});
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import EditWebhooksView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("webhook_update_form"),
() => ""
);
export default WithLayout({
getLayout: null,
Page: EditWebhooksView,
});
@@ -0,0 +1,16 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import CreateNewView from "~/settings/platform/oauth-clients/create-new-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("oAuth_client_creation_form"),
() => ""
);
};
export default WithLayout({
getLayout: null,
Page: CreateNewView,
});
+16
View File
@@ -0,0 +1,16 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import PlatformView from "~/settings/platform/platform-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("platform"),
(t) => t("platform_description")
);
};
export default WithLayout({
getLayout: null,
Page: PlatformView,
});
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import PlatformPlansView from "~/settings/platform/plans/platform-plans-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${t("platform")} ${t("plans")}`,
() => ""
);
};
export default WithLayout({
Page: PlatformPlansView,
});
+2 -2
View File
@@ -165,8 +165,8 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => {
if (!item.teams.length) return <ChildAppCard item={item} />;
.map((item, i) => {
if (!item.teams.length) return <ChildAppCard key={i} item={item} />;
})}
</List>
{locationType && (
@@ -126,7 +126,7 @@ const EventTypeGroup = ({
}: ConfigureStepCardProps & {
groupIndex: number;
setUpdatedEventTypesStatus: Dispatch<SetStateAction<TUpdatedEventTypesStatus>>;
submitRefs: Array<React.RefObject<HTMLButtonElement>>;
submitRefs: React.MutableRefObject<(HTMLButtonElement | null)[]>;
}) => {
const { control } = useFormContext<TEventTypesForm>();
const { fields, update } = useFieldArray({
@@ -174,7 +174,9 @@ const EventTypeGroup = ({
return res;
});
}}
ref={submitRefs[index]}
ref={(el) => {
submitRefs.current[index] = el;
}}
{...props}
/>
)
@@ -193,17 +195,8 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
keyName: "fieldId",
});
const eventTypeGroups = watch("eventTypeGroups");
const submitRefs = useRef<Array<Array<React.RefObject<HTMLButtonElement>>>>([]);
const submitRefs = useRef<(HTMLButtonElement | null)[]>([]);
submitRefs.current = eventTypeGroups.reduce(
(arr: Array<Array<React.RefObject<HTMLButtonElement>>>, field) => {
const res = field.eventTypes
.filter((eventType) => eventType.selected)
.map((_ref) => React.createRef<HTMLButtonElement>());
return [...arr, res];
},
[]
);
const mainForSubmitRef = useRef<HTMLButtonElement>(null);
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState<TUpdatedEventTypesStatus>(
eventTypeGroups.reduce((arr: Array<{ id: number; updated: boolean }[]>, field) => {
@@ -245,7 +238,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
<EventTypeGroup
groupIndex={groupIndex}
setUpdatedEventTypesStatus={setUpdatedEventTypesStatus}
submitRefs={submitRefs.current[groupIndex]}
submitRefs={submitRefs}
{...props}
/>
</div>
@@ -258,7 +251,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = (props) => {
type="button"
data-testid="configure-step-save"
onClick={() => {
submitRefs.current.map((group) => group?.map((ref) => ref.current?.click()));
submitRefs.current.forEach((ref) => ref?.click());
setSubmit(true);
}}
loading={loading}>
@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React from "react";
import { ShellMain } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui";
@@ -11,7 +11,7 @@ type AppsLayoutProps = {
children: React.ReactNode;
actions?: (className?: string) => JSX.Element;
emptyStore?: boolean;
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
} & Omit<ComponentProps<typeof Shell>, "actions">;
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
const { t } = useLocale();
@@ -21,7 +21,7 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
if (session.status === "loading") return <></>;
return (
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
<div className="flex flex-col xl:flex-row">
<main className="w-full">
{emptyStore ? (
@@ -37,6 +37,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
)}
</main>
</div>
</ShellMain>
</Shell>
);
}
+100 -9
View File
@@ -103,7 +103,7 @@ const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooki
function BookingListItem(booking: BookingItemProps) {
const parsedBooking = buildParsedBooking(booking);
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const {
t,
i18n: { language },
@@ -113,6 +113,7 @@ function BookingListItem(booking: BookingItemProps) {
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
const cardCharged = booking?.payment[0]?.success;
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
@@ -268,6 +269,17 @@ function BookingListItem(booking: BookingItemProps) {
});
}
if (isBookingInPast) {
editBookingActions.push({
id: "no_show",
label: t("mark_as_no_show"),
onClick: () => {
setIsNoShowDialogOpen(true);
},
icon: "eye-off" as const,
});
}
let bookedActions: ActionType[] = [
{
id: "cancel",
@@ -417,12 +429,14 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenDialog={setIsOpenRescheduleDialog}
bookingUId={booking.uid}
/>
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
/>
{isOpenReassignDialog && (
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
/>
)}
<EditLocationDialog
booking={booking}
saveLocation={saveLocation}
@@ -452,6 +466,14 @@ function BookingListItem(booking: BookingItemProps) {
timeFormat={userTimeFormat ?? null}
/>
)}
{isNoShowDialogOpen && (
<NoShowAttendeesDialog
bookingUid={booking.uid}
attendees={attendeeList}
setIsOpen={setIsNoShowDialogOpen}
isOpen={isNoShowDialogOpen}
/>
)}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
@@ -983,7 +1005,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
/>
))}
<DropdownMenuSeparator />
<div className=" flex justify-end p-2">
<div className="flex justify-end p-2 ">
<Button
data-testid="update-no-show"
color="secondary"
@@ -999,6 +1021,75 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
</Dropdown>
);
};
const NoShowAttendeesDialog = ({
attendees,
isOpen,
setIsOpen,
bookingUid,
}: {
attendees: AttendeeProps[];
isOpen: boolean;
setIsOpen: (value: boolean) => void;
bookingUid: string;
}) => {
const { t } = useLocale();
const [noShowAttendees, setNoShowAttendees] = useState(
attendees.map((attendee) => ({
id: attendee.id,
email: attendee.email,
name: attendee.name,
noShow: attendee.noShow || false,
}))
);
const noShowMutation = trpc.viewer.markNoShow.useMutation({
onSuccess: async (data) => {
const newValue = data.attendees[0];
setNoShowAttendees((old) =>
old.map((attendee) =>
attendee.email === newValue.email ? { ...attendee, noShow: newValue.noShow } : attendee
)
);
showToast(t(data.message), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dialog open={isOpen} onOpenChange={() => setIsOpen(false)}>
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")}>
{noShowAttendees.map((attendee) => (
<form
key={attendee.id}
onSubmit={(e) => {
e.preventDefault();
noShowMutation.mutate({
bookingUid,
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
});
}}>
<div className="bg-muted flex items-center justify-between rounded-md px-4 py-2">
<span className="text-emphasis flex flex-col text-sm">
{attendee.name}
{attendee.email && <span className="text-muted">({attendee.email})</span>}
</span>
<Button color="minimal" type="submit" StartIcon={attendee.noShow ? "eye-off" : "eye"}>
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
</Button>
</div>
</form>
))}
<DialogFooter>
<DialogClose>{t("done")}</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { t } = useLocale();
@@ -1035,7 +1126,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className=" flex justify-end space-x-2 p-2">
<div className="flex justify-end space-x-2 p-2 ">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
+278 -23
View File
@@ -1,9 +1,31 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Dispatch, SetStateAction } from "react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { classNames } from "@calcom/lib";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, Dialog, DialogClose, DialogContent, DialogFooter, showToast } from "@calcom/ui";
import {
Button,
Form,
Label,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
ConfirmationDialogContent,
showToast,
Input,
Icon,
RadioGroup as RadioArea,
TextAreaField,
} from "@calcom/ui";
type ReassignDialog = {
isOpenDialog: boolean;
@@ -12,15 +34,82 @@ type ReassignDialog = {
bookingId: number;
};
type FormValues = {
reassignType: "round_robin" | "team_member";
teamMemberId?: number;
reassignReason?: string;
};
const formSchema = z.object({
reassignType: z.enum(["round_robin", "team_member"]),
teamMemberId: z.number().optional(),
reassignReason: z
.string()
.optional()
.refine((val) => !val || val.length >= 10, {
message: "Reassign reason must be at least 10 characters long if provided",
}),
});
interface TeamMemberOption {
label: string;
value: number;
status: string;
}
export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingId }: ReassignDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const [animationParentRef] = useAutoAnimate<HTMLFormElement>({
duration: 150,
easing: "ease-in-out",
});
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearch = useDebounce(searchTerm, 500);
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
trpc.viewer.teams.getRoundRobinHostsToReassign.useInfiniteQuery(
{
bookingId,
exclude: "fixedHosts",
limit: 10,
searchTerm: debouncedSearch,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const allRows = useMemo(() => {
return data?.pages.flatMap((page) => page.items) ?? [];
}, [data]);
const teamMemberOptions = useMemo(() => {
return allRows.map((member) => ({
label: member.name || member.email,
value: member.id,
status: member.status,
})) as TeamMemberOption[];
}, [allRows]);
const { ref: observerRef } = useInViewObserver(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
}, document.querySelector('[role="dialog"]'));
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
reassignType: "round_robin",
},
});
const roundRobinReassignMutation = trpc.viewer.teams.roundRobinReassign.useMutation({
onSuccess: async () => {
onSuccess: async (data) => {
await utils.viewer.bookings.get.invalidate();
setIsOpenDialog(false);
showToast(t("booking_reassigned"), "success");
showToast(t("booking_reassigned_to_host", { host: data?.reassignedTo.name }), "success");
},
onError: async (error) => {
if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
@@ -31,26 +120,192 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
},
});
const roundRobinManualReassignMutation = trpc.viewer.teams.roundRobinManualReassign.useMutation({
onSuccess: async () => {
await utils.viewer.bookings.get.invalidate();
setIsOpenDialog(false);
showToast(t("booking_reassigned"), "success");
},
onError: async (error) => {
if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
showToast(t("no_available_hosts"), "error");
} else {
showToast(t(error.message), "error");
}
},
});
const [confirmationModal, setConfirmationModal] = useState<{
show: boolean;
membersStatus: "unavailable" | "available" | null;
}>({
show: false,
membersStatus: null,
});
const handleSubmit = (values: FormValues) => {
if (values.reassignType === "round_robin") {
roundRobinReassignMutation.mutate({ teamId, bookingId });
} else {
if (values.teamMemberId) {
const selectedMember = teamMemberOptions?.find((member) => member.value === values.teamMemberId);
if (selectedMember) {
setConfirmationModal({
show: true,
membersStatus: selectedMember.status as "available" | "unavailable",
});
}
}
}
};
const watchedReassignType = form.watch("reassignType");
const watchedTeamMemberId = form.watch("teamMemberId");
return (
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
setIsOpenDialog(open);
}}>
<DialogContent title={t("reassign_round_robin_host")} description={t("reassign_to_another_rr_host")}>
{/* TODO add team member reassignment*/}
<DialogFooter>
<DialogClose />
<Button
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending}
onClick={() => {
roundRobinReassignMutation.mutate({ teamId, bookingId });
}}>
{t("reassign")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<>
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
setIsOpenDialog(open);
}}>
<DialogContent
title={t("reassign_round_robin_host")}
description={t("reassign_to_another_rr_host")}
enableOverflow>
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
<RadioArea.Group
onValueChange={(val) => {
form.setValue("reassignType", val as "team_member" | "round_robin");
}}
className="mt-1 flex flex-col gap-4">
<RadioArea.Item
value="round_robin"
className="w-full text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_reassign_description")}</p>
</RadioArea.Item>
<RadioArea.Item value="team_member" className="text-sm" classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
<p>{t("team_member_round_robin_reassign_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
{watchedReassignType === "team_member" && (
<div className="mb-2">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<div className="mt-2">
<Input
type="text"
placeholder={t("search")}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="scroll-bar flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
{teamMemberOptions.map((member) => (
<label
key={member.value}
tabIndex={watchedTeamMemberId === member.value ? -1 : 0}
role="radio"
aria-checked={watchedTeamMemberId === member.value}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
form.setValue("teamMemberId", member.value);
}
}}
className={classNames(
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring focus:ring-2",
watchedTeamMemberId === member.value && "bg-subtle"
)}>
<div className="flex flex-1 items-center space-x-3">
<input
type="radio"
className="hidden"
checked={watchedTeamMemberId === member.value}
onChange={() => form.setValue("teamMemberId", member.value)}
/>
<div
className={classNames(
"h-3 w-3 flex-shrink-0 rounded-full",
member.status === "unavailable" ? "bg-red-500" : "bg-green-500"
)}
/>
<span className="text-emphasis w-full text-sm">{member.label}</span>
{watchedTeamMemberId === member.value && (
<div className="place-self-end pr-2">
<Icon name="check" className="text-emphasis h-4 w-4" />
</div>
)}
</div>
</label>
))}
<div className="text-default text-center" ref={observerRef}>
<Button
color="minimal"
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
type="submit"
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
{t("reassign")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
<Dialog
open={confirmationModal?.show}
onOpenChange={(open) => setConfirmationModal({ ...confirmationModal, show: open })}>
<ConfirmationDialogContent
variety={confirmationModal?.membersStatus === "unavailable" ? "warning" : "success"}
title={
confirmationModal?.membersStatus === "unavailable"
? t("confirm_reassign_unavailable")
: t("confirm_reassign_available")
}
confirmBtnText={t("yes_reassign")}
cancelBtnText={t("cancel")}
onConfirm={() => {
const teamMemberId = form.getValues("teamMemberId");
if (!teamMemberId) {
return;
}
roundRobinManualReassignMutation.mutate({
bookingId,
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
setConfirmationModal({
show: false,
membersStatus: null,
});
}}>
<p className="mb-4">
{confirmationModal?.membersStatus === "unavailable"
? t("reassign_unavailable_team_member_description")
: t("reassign_available_team_member_description")}
</p>
<TextAreaField
name="reassignReason"
label={t("reassign_reason")}
onChange={(e) => form.setValue("reassignReason", e.target.value)}
required={confirmationModal?.membersStatus === "unavailable"}
/>
</ConfirmationDialogContent>
</Dialog>
</>
);
};
+22 -13
View File
@@ -26,7 +26,7 @@ import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui";
import { Button, Tooltip } from "@calcom/ui";
import { showToast } from "@calcom/ui/components/toast";
import { showToast } from "@calcom/ui";
const enum ReroutingStatusEnum {
REROUTING_NOT_INITIATED = "not_initiated",
@@ -278,7 +278,12 @@ const NewRoutingManager = ({
const router = useRouter();
const bookerUrl = useBookerUrl();
const teamMemberIdsMatchingAttributeLogic =
teamMembersMatchingAttributeLogic?.data?.map((member) => member.id) || null;
teamMembersMatchingAttributeLogic?.data
?.map((member) => member.id)
.filter((id) => {
// We don't want to reroute to the same user who booked the booking
return id !== booking.user?.id;
}) || null;
const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id;
const bookingEventType = booking.eventType;
@@ -696,13 +701,16 @@ const RerouteDialogContentAndFooter = ({
}: Pick<RerouteDialogProps, "isOpenDialog" | "setIsOpenDialog"> & {
booking: TeamEventTypeBookingToReroute;
}) => {
const { data: responseWithForm, isPending: isRoutingFormLoading } =
trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({
formResponseId: booking.routedFromRoutingFormReponse.id,
});
const {
data: responseWithForm,
isPending: isRoutingFormLoading,
error: formResponseFetchError,
} = trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({
formResponseId: booking.routedFromRoutingFormReponse.id,
});
const { t } = useLocale();
// useUpdateIsReroutingQueryParam({ isOpenDialog });
if (isRoutingFormLoading)
return (
<>
@@ -718,7 +726,11 @@ const RerouteDialogContentAndFooter = ({
</>
);
if (!responseWithForm) return <div>{t("something_went_wrong")}</div>;
if (formResponseFetchError) {
return <div className="mb-8">{formResponseFetchError.message}</div>;
}
if (!responseWithForm) return <div className="mb-8">{t("something_went_wrong")}</div>;
return (
<RerouteDialogContentAndFooterWithFormResponse
@@ -786,7 +798,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response: currentResponse,
routeId: route.id,
route,
});
}
@@ -836,10 +848,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
<Button
onClick={verifyRoute}
data-testid="verify-new-route-button"
disabled={
reroutingState.status === ReroutingStatusEnum.REROUTING_IN_PROGRESS ||
isResponseFromOrganizerUnpopulated
}>
disabled={reroutingState.status === ReroutingStatusEnum.REROUTING_IN_PROGRESS}>
{t("verify_new_route")}
</Button>
</DialogFooter>

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