Compare commits

...
187 Commits
Author SHA1 Message Date
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
Keith Williams df2ef84251 chore: v4.6.7 (#17208)
release.yml / release (push) Canceled after 0s
2024-10-20 19:21:00 -03:00
Keith Williams dcd07d1cc6 perf: Remove tRPC event call from server-side props (#17205) 2024-10-20 14:07:09 -07:00
Hariom Balhara 5f0cc805a9 fix: multiselect "Any in" operator for a user with just one option assigned for the attribute (#17200) 2024-10-20 16:53:19 +00:00
c5677f1dd9 fix: Add fallback schedule for non-onboarded team members (#17196)
* chore: increase pagination managed users endpoint (#17147)

* Add fallbackSchedule and troubleshooter data

* Disable troubleshooter by default

* Improve slots existence assertion

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-10-20 19:41:01 +05:30
shivambhatia604andShivam Bhatia bf9be5915c fix: added aria-label to prev and next month buttons (#17198)
* fix: added aria-label to prev and next month buttons

* fix: applied correct labels to buttons

---------

Co-authored-by: Shivam Bhatia <shivambhatia@Shivams-MacBook-Pro.local>
2024-10-20 11:59:19 +00:00
Keith Williams 4cf3627d52 chore: v4.6.6 (#17199)
release.yml / release (push) Canceled after 0s
2024-10-20 07:55:46 -03:00
Maximilian Franzke 0424304a50 fix(a11y): text color contrast (#17166)
As the textual paragraph get's an 80% opacity defined, the text colors contrast violates [Success Critera 1.4.3](https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html) that expects a color contrast of at least 4.5:1
2024-10-20 07:55:34 -03:00
Maximilian Franzke 7ab1288fa6 docs: small optimization (#17183)
after installing a node version via `nvm` that version is automatically being used.
2024-10-20 07:54:46 -03:00
Udit Takkar c7f4f52efa perf: duplicate getSchedule request (#17191)
* fix: duplicate getSchedule request

* fix: add event type id
2024-10-20 08:56:37 +00:00
Syed Ali ShahbazandUdit Takkar f70e8c5189 perf: Single query user enrichment calls by array of userIds instead (#17190)
* single query

* getPublicEvent

* perf: use Map for faster lookup for large objects

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-10-19 19:17:40 -03:00
Omar López fcc2e0f2ae fix: allow gCal installs without a primary calendar (#17189) 2024-10-19 16:56:09 +00:00
Hariom Balhara 731f97ebe4 fix: Add troubleshooting data and ensure no errors in live mode of router. (#17188) 2024-10-19 07:22:04 -07:00
Bailey Pumfleet 474f81f08e refactor: Minor settings style & copy alterations (#17178) 2024-10-19 04:08:49 +00:00
Keith Williams 76a2822753 perf: Increase vCPU for slots and routing forms (#17181) 2024-10-18 22:05:20 +00:00
Hariom BalharaandUdit Takkar b8d4fccbab feat: Rerouting a booking with corrected responses (#17074)
* POC working with no TS error

* cleanup

* Fix query booking

* Simplify things

* Support rerouting to different event

* self-review fixes

* Some improvements

* add more tests

* Remove members relation query

* Udits feedback

* Dont show CTAs when non-eventTypeRedirectUrl action route is chosen

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-10-18 17:11:50 -04:00
Alex van Andel 4f14775a4d chore: Release v4.6.5 (#17174)
release.yml / release (push) Canceled after 0s
2024-10-18 19:01:15 +00:00
Hariom BalharaandMorgan acbcd59c31 perf: Attributes Query - loadTree only once (#17172)
* chore: increase pagination managed users endpoint (#17147)

* perf: Build tree once per call

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-18 18:45:12 +01:00
Hariom BalharaandMorgan 05c1ad885b fix: Embed - Do not reuse the modal on reopening it. (#17171)
* chore: increase pagination managed users endpoint (#17147)

* Dont reuse the existing modal

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-18 16:42:04 +00:00
Alex van Andel d73d0ebb6e chore: Release v4.6.4 (#17170)
release.yml / release (push) Canceled after 0s
2024-10-18 15:35:06 +00:00
Hariom BalharaandMorgan a3971c127b fix: Timings of attributes query endpoint (#17169)
* chore: increase pagination managed users endpoint (#17147)

* Log timings

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-18 15:10:28 +00:00
Udit Takkar 912d3cf5b2 fix: schedule no show webhooks for requires confirmation event (#17089) 2024-10-18 07:50:15 -07:00
73c35ba5a2 fix: data table fixes (#17141)
* tidy up css

* sticky header

* add sticky top and left on columns

* use group for cell hover when overflowing

* strickly type meta as override isnt working

* fix: type err

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-10-18 12:41:21 +00:00
Keith Williams 55f1019908 chore: v4.6.3 (#17161)
release.yml / release (push) Canceled after 0s
2024-10-18 09:01:23 -03:00
Keith Williams 863fc695ec perf: Add index for invalid column in Credential (#17162)
* perf: Add index for invalid column in Credential

* Added missing file change
2024-10-18 11:42:54 +00:00
Keith Williams d931fb1f9c perf: Add indices to Membership (#17160) 2024-10-18 08:31:16 -03:00
Hariom Balhara fd054dfb8d fix: select_any_in operator - Broken only for attributes (#17148)
* Fix select any_in operator for attributes - Broken only for attributres

* Fix ts error
2024-10-18 10:19:25 +00:00
Hariom BalharaandMorgan 699e08b2ee fix: Possible perf improvement and logging for team members routing logic (#17154)
* chore: increase pagination managed users endpoint (#17147)

* Add server-timings and used concurrency 2 for teamMembers matching

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-18 09:44:50 +00:00
Alex van Andel e1228ca03c fix: Hide invite_new_member_description cost from users within an organisation (#17149) 2024-10-18 00:22:40 +01:00
Morgan 9ad69c20cf fix: event-types locations output schema apiv2 (#17150) 2024-10-17 21:07:31 +03:00
Somay Chauhan 9a5efd39ce feat: event team Assignment tab atom (#17145)
* feat: event team Assignment tab atom

* Update event-types-atom.service.ts

* Update event-types-atom.service.ts
2024-10-17 17:33:59 +00:00
Morgan 497b0804d3 feat: create event type atom (#17142)
* feat: create event type atom

* fixup! feat: create event type atom

* fixup! fixup! feat: create event type atom

* docs: base doc for create event type atom
2024-10-17 21:51:23 +05:30
Morgan 8b50f419e5 chore: increase pagination managed users endpoint (#17147) 2024-10-17 14:32:35 +00:00
Alex van Andel 9208827406 chore: Release v4.6.2 (#17146)
release.yml / release (push) Canceled after 0s
2024-10-17 15:22:59 +01:00
Udit Takkar 8891381d89 chore: add logs for debugging (#17111)
* chore: add logs

* fix: filter in prisma query

* test: add test for personal event types

* chore: remove OR condition
2024-10-17 13:43:38 +00:00
Hariom Balhara ab39f0c8b6 Add support for multiselect_some_in for attributes query (#17144) 2024-10-17 12:16:34 +00:00
Vadim Rusin cd2382e091 fix: Add error message for taken username (#17129) 2024-10-17 04:44:52 +00:00
Syed Ali Shahbaz 154c54a2aa fix: Update openapi-v1.json bookings post example (#17132)
* Update openapi-v1.json

* Update openapi-v1.json
2024-10-16 20:59:10 -03:00
sean-brydon e949a4c529 fix: pass from org link to all instances of event.public (#17136)
* pass from org link to all instances of event.public

* fix type error

* fix type error
2024-10-16 14:57:39 +01:00
64b321ac9c feat: added custom class name to booker and availability atom (#17105)
* added custom class name for booker atoms wrapper

* atomsWrapper

* added props to availability atom

* allowDelete
* allowSetToDefault
* added customClassNames -> hiddenSwitchClassname, deleteButtonClassname

* Update availability.tsx

* fixup! Merge branch 'main' into add-custom-classname-to-booker-atom-wrapper

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-10-16 12:04:33 +00:00
e90e060739 feat: Event availability platform wrapper (#17091)
* moved listSchedules to schedule repository

* Create EventAvailabilityTabPlatformWrapper.tsx

* Revert "moved listSchedules to schedule repository"

This reverts commit b5feacfabbadbf9d75eb205dfad8f70b7773a5cb.

* fix: active state of tabs items

* added event availability platform wrapper

* Update event-types.tsx

* better types

only pick the types we need and omit the rest

* fix: type-check failing

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-10-16 13:24:13 +03:00
Hariom Balhara 123074f4ba fix: Hotfix - Revert hideEventTypeDetails handling in Booker.tsx (#17130)
* Revert "fix: DatePicker not showing up in mobile mode when hideEventTypeDetails is true (#17037)"

This reverts commit 1f08bc5a4f.

* Revert "feat: Booker atom enable hiding event details (#16906)"

This reverts commit ba5b9e951a.

* Keep the playground test scenarios
2024-10-16 10:01:04 +00:00
MorganandKeith Williams 8115c86e37 chore: refactor create event -type forms (#17106)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-10-16 08:15:54 +00:00
Syed Ali Shahbaz 92a48b466c chore: developer docs updates (#17112)
* remove mention of data proxy

* fix self hosting broken app link

* fix booking doc

* deploy buttons and more options

* fix capitalization
2024-10-15 22:50:54 +00:00
Calcom BotandReplexica 7a4298dc00 feat: update translations (#17120)
Co-authored-by: Replexica <support@replexica.com>
2024-10-15 22:28:53 +00:00
546 changed files with 22280 additions and 6319 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=
+9 -4
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
@@ -128,7 +128,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 +137,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 +265,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,7 +337,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
@@ -385,6 +387,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
+2 -2
View File
@@ -108,10 +108,10 @@ To develop locally:
nvm use
```
You first might need to install the specific version and then use it:
You first might need to install the specific version:
```sh
nvm install && nvm use
nvm install
```
You can install nvm from [here](https://github.com/nvm-sh/nvm).
+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(),
})
);
+4 -10
View File
@@ -66,11 +66,8 @@ describe("GET /api/slots", () => {
buildMockData();
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
{
"slots": {},
}
`);
const response = JSON.parse(res._getData());
expect(response.slots).toEqual(expect.objectContaining({}));
});
test("Returns and event type available slots with passed timeZone", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
@@ -87,11 +84,8 @@ describe("GET /api/slots", () => {
buildMockData();
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
expect(JSON.parse(res._getData())).toMatchInlineSnapshot(`
{
"slots": {},
}
`);
const response = JSON.parse(res._getData());
expect(response.slots).toEqual(expect.objectContaining({}));
});
});
});
+1 -1
View File
@@ -28,7 +28,7 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.45",
"@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,7 @@ 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 { 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 +17,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,
@@ -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);
@@ -0,0 +1,645 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { DateTime } from "luxon";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
CancelSeatedBookingInput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import {
CreateBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Seated bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "seated-bookings-controller-e2e@api.com";
let user: User;
const seatedTventTypeSlug = "peer-coding-seated";
const recurringSeatedTventTypeSlug = "peer-coding-recurring-seated";
let seatedEventTypeId: number;
let recurringSeatedEventTypeId: number;
const maxRecurrenceCount = 3;
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
const emailAttendeeOne = "mr_proper_seated@gmail.com";
const nameAttendeeOne = "Mr Proper Seated";
const emailAttendeeTwo = "mr_proper_friend_seated@gmail.com";
const nameAttendeeTwo = "Mr Proper Friend Seated";
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const seatedEvent = await eventTypesRepositoryFixture.create(
{
title: "peer coding",
slug: seatedTventTypeSlug,
length: 60,
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
},
user.id
);
seatedEventTypeId = seatedEvent.id;
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: recurringSeatedTventTypeSlug,
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
},
user.id
);
recurringSeatedEventTypeId = recurringSeatedEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should book an event type with seats for the first time", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
const seatUid = data.seatUid;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedTventTypeSlug,
});
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
createdSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should book an event type with seats for the second time", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeId,
attendee: {
name: nameAttendeeTwo,
email: emailAttendeeTwo,
timeZone: "Europe/Rome",
language: "it",
},
bookingFieldsResponses: {
codingLanguage: "Rust",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
const seatUid = data.seatUid;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedTventTypeSlug,
});
expect(data.attendees.length).toEqual(2);
// note(Lauris): first attendee is from previous test request
const firstAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeOne);
expect(firstAttendee).toEqual({
name: createdSeatedBooking.attendees[0].name,
timeZone: createdSeatedBooking.attendees[0].timeZone,
language: createdSeatedBooking.attendees[0].language,
absent: false,
seatUid: createdSeatedBooking.seatUid,
bookingFieldsResponses: {
name: createdSeatedBooking.attendees[0].name,
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
},
});
const secondAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeTwo);
expect(secondAttendee).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
createdSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should book a recurring event type with seats", async () => {
const recurrenceCount = 2;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: recurringSeatedEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(recurrenceCount);
const firstBooking = data[0];
expect(firstBooking.seatUid).toBeDefined();
const seatUid = firstBooking.seatUid;
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(body.start);
expect(firstBooking.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(firstBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(firstBooking.attendees.length).toEqual(1);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.seatUid).toBeDefined();
const secondSeatUid = secondBooking.seatUid;
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
expect(secondBooking.start).toEqual(expectedStart);
expect(secondBooking.end).toEqual(
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(secondBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(secondBooking.attendees.length).toEqual(1);
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
createdRecurringSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should get a booking for an event type with seats", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
const expected = structuredClone(createdSeatedBooking);
// note(Lauris): seatUid in get response resides only in each attendee object
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete expected.seatUid;
expect(data).toEqual(expected);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should get a booking for a recurring event type with seats", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${createdRecurringSeatedBooking[1].recurringBookingUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetRecurringSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetRecurringSeatedBooking(responseBody.data)) {
const data: GetRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
const expected = structuredClone(createdRecurringSeatedBooking);
for (const booking of expected) {
// note(Lauris): seatUid in get response resides only in each attendee object
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete booking.seatUid;
}
expect(data).toEqual(expected);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should get a specific recurrence of a booking for a recurring event type with seats", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${createdRecurringSeatedBooking[0].uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
const expected = structuredClone(createdRecurringSeatedBooking[0]);
// note(Lauris): seatUid in get response resides only in each attendee object
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete expected.seatUid;
expect(data).toEqual(expected);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should get all seated bookings", async () => {
return request(app.getHttpServer())
.get("/v2/bookings?sortCreated=asc")
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(3);
const seatedBooking = responseBody.data[0];
const seatedBookingExpected = structuredClone(createdSeatedBooking);
// note(Lauris): seatUid in get response resides only in each attendee object
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete seatedBookingExpected.seatUid;
expect(seatedBooking).toEqual(seatedBookingExpected);
const recurringSeatedBookings = [responseBody.data[1], responseBody.data[2]];
const recurringSeatedBookingsExpected = structuredClone(createdRecurringSeatedBooking);
for (const booking of recurringSeatedBookingsExpected) {
// note(Lauris): seatUid in get response resides only in each attendee object
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete booking.seatUid;
}
expect(recurringSeatedBookings).toEqual(recurringSeatedBookingsExpected);
});
});
it("should reschedule seated booking", async () => {
const body: RescheduleSeatedBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
seatUid: createdSeatedBooking.seatUid,
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdSeatedBooking.uid}/reschedule`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
const seatUid = data.seatUid;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedTventTypeSlug,
});
expect(data.attendees.length).toEqual(1);
const attendee = createdSeatedBooking.attendees.find((a) => a.seatUid === body.seatUid);
expect(data.attendees[0]).toEqual({
name: attendee?.name,
timeZone: attendee?.timeZone,
language: attendee?.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: attendee?.name,
...attendee?.bookingFieldsResponses,
},
});
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
createdSeatedBooking = data;
} else {
throw new Error("Invalid response data - expected booking but received array response");
}
});
});
it("should cancel seated booking", async () => {
const body: CancelSeatedBookingInput_2024_08_13 = {
seatUid: createdSeatedBooking.seatUid,
};
return request(app.getHttpServer())
.post(`/v2/bookings/${createdSeatedBooking.uid}/cancel`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: RescheduleBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("cancelled");
expect(data.start).toEqual(createdSeatedBooking.start);
expect(data.end).toEqual(createdSeatedBooking.end);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeId);
expect(data.eventType).toEqual({
id: seatedEventTypeId,
slug: seatedTventTypeSlug,
});
expect(data.attendees.length).toEqual(0);
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
} else {
throw new Error("Invalid response data - expected booking but received array response");
}
});
});
function responseDataIsCreateSeatedBooking(data: any): data is CreateSeatedBookingOutput_2024_08_13 {
return data.hasOwnProperty("seatUid");
}
function responseDataIsCreateRecurringSeatedBooking(
data: any
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
}
function responseDataIsGetRecurringSeatedBooking(
data: any
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -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;
}
@@ -18,7 +18,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 +26,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";
@@ -54,9 +57,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 +96,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) => ({ id: booking.id || 0, 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 +168,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 +193,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 +219,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 +234,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 +242,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 +281,7 @@ 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) {
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
await handleCancelBooking(bookingRequest);
return this.getBooking(bookingUid);
@@ -249,7 +317,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: { id: number; seatUid: string }[]) {
const transformed = [];
for (const booking of bookings) {
const databaseBooking =
await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(booking.id);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} 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,19 +1,24 @@
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";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, EventTypesModule_2024_06_14],
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule],
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)
@@ -64,4 +98,20 @@ export class AtomsController {
data: eventType,
};
}
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async updateAtomTeamEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: UpdateEventTypeReturn
): Promise<ApiResponse<UpdateEventTypeReturn>> {
const eventType = await this.eventTypesService.updateTeamEventType(eventTypeId, body, user, teamId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
}
@@ -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,31 +1,58 @@
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";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { Injectable, NotFoundException, ForbiddenException } from "@nestjs/common";
import {
updateEventType,
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,
private readonly eventTypeService: EventTypesService_2024_06_14
private readonly eventTypeService: EventTypesService_2024_06_14,
private readonly teamEventTypeService: OrganizationsEventTypesService
) {}
async getUserEventType(user: UserWithProfile, eventTypeId: number) {
this.eventTypeService.checkUserOwnsEventType(user.id, { id: eventTypeId, userId: user.id });
const organizationId = this.usersService.getUserMainOrgId(user);
const isUserOrganizationAdmin = organizationId
@@ -45,12 +72,22 @@ export class EventTypesAtomService {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
if (eventType?.team?.id) {
await this.checkTeamOwnsEventType(user.id, eventType.eventType.id, eventType.team.id);
} else {
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
}
return eventType;
}
async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) {
this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
async updateTeamEventType(
eventTypeId: number,
body: TUpdateEventTypeInputSchema,
user: UserWithProfile,
teamId: number
) {
await this.checkCanUpdateTeamEventType(user.id, eventTypeId, teamId, body.scheduleId);
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
const bookingFields = [...(body.bookingFields || [])];
@@ -77,4 +114,207 @@ export class EventTypesAtomService {
return eventType.eventType;
}
async updateEventType(eventTypeId: number, body: TUpdateEventTypeInputSchema, user: UserWithProfile) {
await this.eventTypeService.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
const eventTypeUser = await this.eventTypeService.getUserToUpdateEvent(user);
const bookingFields = [...(body.bookingFields || [])];
if (
!bookingFields.find((field) => field.type === "email") &&
!bookingFields.find((field) => field.type === "phone")
) {
bookingFields.push(systemBeforeFieldEmail);
}
const eventType = await updateEventType({
input: { id: eventTypeId, ...body, bookingFields },
ctx: {
user: eventTypeUser,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
prisma: this.dbWrite.prisma,
},
});
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return eventType.eventType;
}
async checkCanUpdateTeamEventType(userId: number, eventTypeId: number, teamId: number, scheduleId: number) {
await this.checkTeamOwnsEventType(userId, eventTypeId, teamId);
await this.teamEventTypeService.validateEventTypeExists(teamId, eventTypeId);
await this.eventTypeService.checkUserOwnsSchedule(userId, scheduleId);
}
async checkTeamOwnsEventType(userId: number, eventTypeId: number, teamId: number) {
const membership = await this.dbRead.prisma.membership.findFirst({
where: {
userId,
teamId,
accepted: true,
OR: [{ role: "ADMIN" }, { role: "OWNER" }],
},
select: {
team: {
select: {
eventTypes: true,
},
},
},
});
if (!membership?.team?.eventTypes?.some((item) => item.id === eventTypeId)) {
throw new ForbiddenException(
`Access denied. Either the team with ID=${teamId} does not own the event type with ID=${eventTypeId}, or your MEMBER role does not have permission to access this resource.`
);
}
}
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,17 @@
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,
},
});
}
}
@@ -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`)
@@ -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;
@@ -93,10 +95,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,6 +2,7 @@ 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";
@@ -37,7 +38,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
let nonOrgTeam: Team;
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";
@@ -200,7 +201,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 +220,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 +239,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);
});
});
@@ -272,12 +274,13 @@ 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);
});
});
@@ -300,7 +303,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 +315,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);
});
@@ -162,6 +162,7 @@ describe("Organizations Team Endpoints", () => {
const responseBody: ApiSuccessResponse<OrgMeTeamOutputDto[]> = response.body;
expect(responseBody.data.find((t) => t.id === teamCreatedViaApi.id)).toBeDefined();
expect(responseBody.data.some((t) => t.accepted)).toBeTruthy();
expect(responseBody.data.find((t) => t.id === teamCreatedViaApi.id)?.role).toBe("OWNER");
});
});
@@ -67,7 +67,7 @@ export class OrganizationsTeamsController {
}
@Get("/me")
@ApiOperation({ summary: "Get team membership for user" })
@ApiOperation({ summary: "Get teams membership for user" })
@Roles("ORG_MEMBER")
@PlatformPlan("ESSENTIALS")
async getMyTeams(
@@ -84,13 +84,14 @@ export class OrganizationsTeamsController {
);
return {
status: SUCCESS_STATUS,
data: teams.map((team) =>
plainToClass(
data: teams.map((team) => {
const me = team.members.find((member) => member.userId === user.id);
return plainToClass(
OrgMeTeamOutputDto,
{ ...team, accepted: team.members.find((member) => member.userId === user.id)?.accepted ?? false },
me ? { ...team, role: me.role, accepted: me.accepted } : team,
{ 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();
});
});
});
@@ -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";
@@ -100,6 +101,7 @@ import { Module } from "@nestjs/common";
OrganizationsWebhooksService,
WebhooksRepository,
WebhooksService,
OrganizationsEventTypesService,
],
controllers: [
OrganizationsTeamsController,
@@ -111,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: {
@@ -1,4 +1,5 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsString, ValidateNested } from "class-validator";
@@ -9,6 +10,14 @@ export class OrgMeTeamOutputDto extends OrgTeamOutputDto {
@IsString()
@Expose()
readonly accepted!: boolean;
@ApiProperty({
example: MembershipRole.MEMBER,
enum: [MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER],
})
@IsEnum([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@Expose()
readonly role!: MembershipRole;
}
export class OrgTeamsOutputResponseDto {
@@ -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 {
@@ -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) {}
@@ -97,7 +99,7 @@ export class OrganizationsTeamsRepository {
},
},
include: {
members: { select: { accepted: true, userId: true } },
members: { select: { accepted: true, userId: true, role: true } },
},
skip,
take,
@@ -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;
}
}
@@ -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
-1
View File
@@ -8,7 +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,
+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;
+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 */}
+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>
);
}
+160 -12
View File
@@ -20,6 +20,7 @@ import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react";
import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import type { ActionType } from "@calcom/ui";
import {
Badge,
@@ -48,6 +49,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
import { ReassignDialog } from "@components/dialog/ReassignDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"];
@@ -65,9 +67,43 @@ type BookingItemProps = BookingItem & {
};
};
function BookingListItem(booking: BookingItemProps) {
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
type ParsedBooking = ReturnType<typeof buildParsedBooking>;
type TeamEvent = Ensure<NonNullable<ParsedBooking["eventType"]>, "team">;
type TeamEventBooking = Omit<ParsedBooking, "eventType"> & {
eventType: TeamEvent;
};
type ReroutableBooking = Ensure<TeamEventBooking, "routedFromRoutingFormReponse">;
function buildParsedBooking(booking: BookingItemProps) {
// The way we fetch bookings there could be eventType object even without an eventType, but id confirms its existence
const bookingEventType = booking.eventType.id
? (booking.eventType as Ensure<
typeof booking.eventType,
// It would only ensure that the props are present, if they are optional in the original type. So, it is safe to assert here.
"id" | "length" | "title" | "slug" | "schedulingType" | "team"
>)
: null;
const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null);
return {
...booking,
eventType: bookingEventType,
metadata: bookingMetadata,
};
}
const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooking => {
// We support only team bookings for now for rerouting
// Though `routedFromRoutingFormReponse` could be there for a non-team booking, we don't want to support it for now.
// Let's not support re-routing for a booking without an event-type for now.
// Such a booking has its event-type deleted and there might not be something to reroute to.
return !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team;
};
function BookingListItem(booking: BookingItemProps) {
const parsedBooking = buildParsedBooking(booking);
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const {
t,
i18n: { language },
@@ -77,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) => {
@@ -107,7 +144,7 @@ function BookingListItem(booking: BookingItemProps) {
const paymentAppData = getPaymentAppData(booking.eventType);
const location = booking.location as ReturnType<typeof getEventLocationValue>;
const locationVideoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl;
const locationVideoCallUrl = parsedBooking.metadata?.videoCallUrl;
const { resolvedTheme, forcedTheme } = useGetTheme();
const hasDarkTheme = !forcedTheme && resolvedTheme === "dark";
@@ -191,6 +228,18 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenRescheduleDialog(true);
},
},
...(isBookingReroutable(parsedBooking)
? [
{
id: "reroute",
label: t("reroute"),
onClick: () => {
setRerouteDialogIsOpen(true);
},
icon: "waypoints" as const,
},
]
: []),
{
id: "change_location",
label: t("edit_location"),
@@ -220,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",
@@ -275,6 +335,7 @@ function BookingListItem(booking: BookingItemProps) {
const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false);
const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false);
const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false);
const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false);
const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({
onSuccess: () => {
showToast(t("location_updated"), "success");
@@ -360,6 +421,7 @@ function BookingListItem(booking: BookingItemProps) {
phoneNumber: attendee.phoneNumber,
};
});
return (
<>
<RescheduleDialog
@@ -367,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}
@@ -402,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>
@@ -620,6 +692,13 @@ function BookingListItem(booking: BookingItemProps) {
)}
</td>
</tr>
{isBookingReroutable(parsedBooking) && (
<RerouteDialog
isOpenDialog={rerouteDialogIsOpen}
setIsOpenDialog={setRerouteDialogIsOpen}
booking={{ ...parsedBooking, eventType: parsedBooking.eventType }}
/>
)}
</>
);
}
@@ -722,7 +801,7 @@ const FirstAttendee = ({
className=" hover:text-blue-500"
href={`mailto:${user.email}`}
onClick={(e) => e.stopPropagation()}>
{user.name}
{user.name || user.email}
</a>
);
};
@@ -926,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"
@@ -942,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();
@@ -978,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"
+276 -21
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,9 +34,76 @@ 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 () => {
@@ -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>
</>
);
};
@@ -0,0 +1,918 @@
import { useMutation } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useCallback } from "react";
import type { z } from "zod";
import FormInputFields, {
FormInputFieldsSkeleton,
} from "@calcom/app-store/routing-forms/components/FormInputFields";
import { getAbsoluteEventTypeRedirectUrl } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
import { getUrlSearchParamsToForwardForReroute } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
import type { FormResponse, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs";
import { createBooking } from "@calcom/features/bookings/lib/create-booking";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { EventType, User, Team, Attendee, Booking as PrismaBooking } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
import type { RouterOutputs } from "@calcom/trpc/react";
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";
const enum ReroutingStatusEnum {
REROUTING_NOT_INITIATED = "not_initiated",
REROUTING_IN_PROGRESS = "in_progress",
REROUTING_COMPLETE = "complete",
REROUTING_FAILED = "failed",
}
type ResponseWithForm = RouterOutputs["viewer"]["appRoutingForms"]["getResponseWithFormFields"];
type BookingToReroute = Pick<PrismaBooking, "metadata" | "responses" | "id" | "uid" | "title" | "status"> & {
routedFromRoutingFormReponse: {
id: number;
};
startTime: string;
metadata: z.infer<typeof bookingMetadataSchema>;
attendees: Pick<Attendee, "locale" | "timeZone" | "email" | "name">[];
eventType: Pick<EventType, "length" | "schedulingType" | "title" | "id" | "slug"> & {
team: Pick<Team, "slug"> | null;
};
user: Pick<User, "id" | "name" | "email"> | null;
};
type TeamEventTypeBookingToReroute = Omit<BookingToReroute, "eventType"> & {
user: NonNullable<BookingToReroute["user"]>;
eventType: Omit<BookingToReroute["eventType"], "team"> & {
team: Ensure<Pick<Team, "slug">, "slug">;
};
};
type RerouteDialogProps = {
isOpenDialog: boolean;
setIsOpenDialog: (isOpen: boolean) => void;
booking: BookingToReroute;
};
type ReroutingState =
| {
type: "reschedule_to_same_event_new_tab" | "reschedule_to_different_event_new_tab";
/**
* UID of the rescheduled booking
*/
newBooking: string | null;
/**
* window for the new tab that is opened for rerouting
*/
reschedulerWindow: Window;
error?: Error;
}
| {
type: "same_timeslot";
newBooking: string | null;
reschedulerWindow: null;
error?: Error;
};
type TeamMemberMatchingAttributeLogic = {
id: number;
name: string | null;
email: string;
};
// We can't let user change email identifier field as that would require contact owner information from salesforce which we don't support yet.
// Also, this isn't a requirement as well to allow correcting the email
const fieldsThatCannotBeCorrected = ["email"];
function getEventTypeUrlsForTheChosenRoute({
chosenRoute,
form,
reroutingFormResponses,
currentResponse,
teamMemberIdsMatchingAttributeLogic,
booking,
searchParams,
}: {
form: ResponseWithForm["form"];
currentResponse: FormResponse;
reroutingFormResponses: Record<string, Pick<FormResponse[keyof FormResponse], "value">>;
teamMemberIdsMatchingAttributeLogic: number[] | null;
chosenRoute: LocalRoute | null;
booking: TeamEventTypeBookingToReroute;
searchParams: URLSearchParams;
}) {
if (!chosenRoute) {
return null;
}
const formFields = form.fields || [];
const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id;
const allURLSearchParams = getUrlSearchParamsToForwardForReroute({
formResponse: currentResponse,
formResponseId: routedFromRoutingFormReponseId,
fields: formFields,
searchParams,
teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic,
attributeRoutingConfig: chosenRoute.attributeRoutingConfig ?? null,
rescheduleUid: booking.uid,
reroutingFormResponses,
});
const eventFullSlug = substituteVariables(chosenRoute.action.value, currentResponse, formFields);
const eventBookingAbsoluteUrl = getAbsoluteEventTypeRedirectUrl({
form,
eventTypeRedirectUrl: eventFullSlug,
allURLSearchParams,
isEmbed: false,
});
return { eventFullSlug, eventBookingAbsoluteUrl };
}
function rescheduleInNewTab({ url }: { url: string }) {
const reschedulerWindow = window.open(url, "_blank");
if (!reschedulerWindow) {
throw new Error("Failed to open new tab");
}
return reschedulerWindow;
}
const getFullSlugForEvent = (
eventType: Pick<TeamEventTypeBookingToReroute["eventType"], "team" | "slug">
) => {
return `team/${eventType.team.slug}/${eventType.slug}`;
};
function isBookingTimeslotInPast(booking: BookingToReroute) {
return dayjs(booking.startTime).isBefore(dayjs());
}
const useReroutingState = ({ isOpenDialog }: Pick<RerouteDialogProps, "isOpenDialog">) => {
const [value, setValue] = useState<ReroutingState | null>(null);
const state = value;
// If dialog is closed but the rerouting window is still open, close it
if (!isOpenDialog && state?.reschedulerWindow) {
state.reschedulerWindow.close();
setValue(null);
}
useEffect(() => {
const checkInterval = setInterval(() => {
if (state?.reschedulerWindow?.closed) {
// Ensure that render happens again
setValue(null);
clearInterval(checkInterval);
}
}, 1);
return () => clearInterval(checkInterval);
}, [value, setValue]);
const status = (() => {
if (!value) return ReroutingStatusEnum.REROUTING_NOT_INITIATED;
if (value.error) return ReroutingStatusEnum.REROUTING_FAILED;
if (!!value.newBooking) return ReroutingStatusEnum.REROUTING_COMPLETE;
return ReroutingStatusEnum.REROUTING_IN_PROGRESS;
})();
return { value: state, setValue, status };
};
// const useUpdateIsReroutingQueryParam = ({ isOpenDialog }: { isOpenDialog: boolean }) => {
// const { setQuery: setIsReroutingQuery } = useRouterQuery("isRerouting");
// useEffect(() => {
// if (isOpenDialog) {
// setIsReroutingQuery("true");
// } else {
// setIsReroutingQuery(undefined);
// }
// }, [isOpenDialog]);
// };
const gotoBookingPage = ({
booking,
router,
}: {
booking: Pick<PrismaBooking, "uid">;
router: ReturnType<typeof useRouter>;
}) => {
// TODO: Do we need to send other params as well?
router.push(`/booking/${booking.uid}?cal.rerouting=true`);
};
const useEventListeners = ({ reroutingState }: { reroutingState: ReturnType<typeof useReroutingState> }) => {
const router = useRouter();
const { t } = useLocale();
const messageListener = useCallback(
(event: MessageEvent) => {
if (event.data.type !== "CAL:rescheduleBookingSuccessfulV2") {
return;
}
const calEventData = event.data.data;
showToast(t("rerouted_booking_successfully_redirecting_to_booking_page"), "success");
if (!calEventData.uid) {
console.error("Booking UID is not there");
throw new Error(t("something_went_wrong"));
}
const newBookingId = calEventData.uid;
reroutingState.setValue((prev) => {
if (!prev) return null;
return { ...prev, newBooking: newBookingId };
});
gotoBookingPage({ router, booking: { uid: newBookingId } });
reroutingState.value?.reschedulerWindow?.close();
},
[reroutingState, reroutingState.value?.reschedulerWindow]
);
// Make sure to close the rescheduler window when this tab closes/reloads
const beforeUnloadListener = useCallback(() => {
reroutingState.value?.reschedulerWindow?.close();
reroutingState.setValue(null);
}, [reroutingState.value?.reschedulerWindow]);
// Ensure listeners are added once the component is mounted and removed once the component is unmounted
useEffect(() => {
window.addEventListener("message", messageListener);
window.addEventListener("beforeunload", beforeUnloadListener);
return () => {
window.removeEventListener("message", messageListener);
window.removeEventListener("beforeunload", beforeUnloadListener);
};
// messageListener and beforeUnloadListener are memoized using useCallback, so the reference remains stable
}, [messageListener, beforeUnloadListener]);
};
const NewRoutingManager = ({
chosenRoute,
booking,
form,
currentResponse,
teamMembersMatchingAttributeLogic,
reroutingState,
}: {
chosenRoute: LocalRoute;
booking: TeamEventTypeBookingToReroute;
form: ResponseWithForm["form"];
currentResponse: FormResponse;
teamMembersMatchingAttributeLogic: {
isPending: boolean;
data: TeamMemberMatchingAttributeLogic[] | null;
};
reroutingState: ReturnType<typeof useReroutingState>;
}) => {
const { t } = useLocale();
const router = useRouter();
const bookerUrl = useBookerUrl();
const teamMemberIdsMatchingAttributeLogic =
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;
// Provide a reason for rescheduling that can be customized by user in the future
// const rescheduleReason = "Rerouted";
const isRoundRobinScheduling = bookingEventType.schedulingType === SchedulingType.ROUND_ROBIN;
const reroutingFormResponses = Object.fromEntries(
Object.entries(currentResponse).map(([key, response]) => [key, { value: response.value }])
);
const chosenEventUrls = getEventTypeUrlsForTheChosenRoute({
chosenRoute,
form,
reroutingFormResponses,
teamMemberIdsMatchingAttributeLogic,
booking,
currentResponse,
searchParams: new URLSearchParams({
// rescheduleReason
}),
});
const chosenEventTypeId = chosenRoute.action.eventTypeId;
const enableChosenEventTypeQuery = !!chosenEventTypeId;
// TODO: Get bare minimum eventType details
const { data: chosenEventTypeData, isPending: isChosenEventTypePending } =
trpc.viewer.eventTypes.get.useQuery(
// enabled prop ensures that the query is not run if the chosenEventTypeId is not there
{ id: chosenEventTypeId! },
{
enabled: enableChosenEventTypeQuery,
}
);
// isPending of the query is true even if enabled=false and no request was sent. So, identify if the request is actually in progress
const isChosenEventTypeRequestInProgress = enableChosenEventTypeQuery ? isChosenEventTypePending : false;
const chosenEventType = chosenEventTypeData?.eventType;
const currentBookingEventFullSlug = getFullSlugForEvent(booking.eventType);
const isReroutingToDifferentEvent = currentBookingEventFullSlug !== chosenEventUrls?.eventFullSlug;
const createBookingMutation = useMutation({
mutationFn: createBooking,
onSuccess: (booking) => {
showToast(t("rerouted_booking_successfully_redirecting_to_booking_page"), "success");
if (!booking.uid) {
console.error("Booking UID is not there");
throw new Error(t("something_went_wrong"));
}
const newBookingId = booking.uid;
reroutingState.setValue((prev) => {
if (!prev) return null;
return {
...prev,
newBooking: newBookingId,
};
});
gotoBookingPage({ router, booking: { uid: newBookingId } });
},
onError: (err, _, ctx) => {
reroutingState.setValue({
error: err,
newBooking: null,
type: "same_timeslot",
reschedulerWindow: null,
});
showToast(
"Looks like the timeslot is not available for the new team members. Try rescheduling with different timeslot",
"error"
);
},
});
useEventListeners({ reroutingState });
if (!chosenRoute) return null;
const isReroutingInNewTab =
reroutingState.value?.type === "reschedule_to_same_event_new_tab" ||
reroutingState.value?.type === "reschedule_to_different_event_new_tab";
return (
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
<h2 className="text-emphasis font-medium">{t("new_routing_status")}</h2>
<div className="flex flex-col space-y-2">
{reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED && reroutingPreview()}
{(reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED || !isReroutingInNewTab) &&
reroutingCTAs()}
{isReroutingInNewTab && newTabReroutingStatus()}
</div>
</div>
);
function rescheduleToSameTimeslotOfEvent({
eventType: { id: eventTypeId, slug: eventTypeSlug, team: eventTypeTeam, length: eventTypeLength },
}: {
eventType: Pick<TeamEventTypeBookingToReroute["eventType"], "id" | "slug" | "length"> & {
team: {
slug: string | null;
} | null;
};
}) {
if (!chosenRoute) {
console.error("Chosen route must be there for rerouting");
throw new Error(t("something_went_wrong"));
}
if (!eventTypeTeam?.slug) {
console.error("Event type team slug must be there for rerouting");
throw new Error(t("something_went_wrong"));
}
const teamSlug = eventTypeTeam.slug;
if (isBookingTimeslotInPast(booking)) {
showToast("You cannot reschedule to a past timeslot", "error");
return;
}
const booker = booking.attendees[0];
const getFieldsThatRemainSame = () => {
return {
responses: booking.responses,
timeZone: booker.timeZone,
language: booker.locale || "en",
metadata: booking.metadata || {},
...getBookingSeatFields(),
...getHashedLinkFields(),
};
function getBookingSeatFields() {
// During rerouting, do all the seats need to be re-routed?
// Let's not support re-routing for booking with seats for now.
return {
// bookingUid: booking.uid,
// seatReferenceUid: booking.eventType,
};
}
function getHashedLinkFields() {
// After booking hashedLink is deleted, so I don't believe we need to return anything here.
// Also, Rerouting is for a booking that was routed and routing doesn't involve hashed link even if it is enabled for the event.
return {};
}
};
const getFieldFromEventTypeThatRemainSame = () => {
return {
user: teamSlug,
eventTypeId: eventTypeId,
eventTypeSlug: eventTypeSlug,
};
};
const getTimeslotFields = () => {
return {
// It is possible that EvenType changed its duration, so we would consider that as well.
start: dayjs(booking.startTime).format(),
end: dayjs(booking.startTime)
// Defaults to the default event length in case no custom duration is set.
.add(eventTypeLength, "minute")
.format(),
};
};
const getRoutingFormRelatedFields = () => {
return {
routedTeamMemberIds: teamMemberIdsMatchingAttributeLogic,
routingFormResponseId: routedFromRoutingFormReponseId,
skipContactOwner: chosenRoute.attributeRoutingConfig?.skipContactOwner,
};
};
const getSalesforceContactOwnerFields = () => {
// Email field won't need to be corrected, so we don't need to support it.
return {};
};
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
createBookingMutation.mutate({
rescheduleUid: booking.uid,
// rescheduleReason,
reroutingFormResponses: reroutingFormResponses,
...getTimeslotFields(),
...getFieldsThatRemainSame(),
...getFieldFromEventTypeThatRemainSame(),
...getRoutingFormRelatedFields(),
...getSalesforceContactOwnerFields(),
});
reroutingState.setValue({
newBooking: null,
type: "same_timeslot",
reschedulerWindow: null,
});
}
function rescheduleToSameTimeslotOfSameEvent() {
return rescheduleToSameTimeslotOfEvent({
eventType: booking.eventType,
});
}
function rescheduleToSameTimeslotOfChosenEvent() {
if (!chosenEventTypeId && isReroutingToDifferentEvent) {
showToast(
"We don't have ID for the new event. Please go to the Routing Form and save it again.",
"error"
);
return;
}
if (!chosenEventType) {
// There are disabled/loading state on button to ensure that this function is not called if chosenEventType is not there
console.error("Chosen event type must be there for rerouting");
throw new Error(t("something_went_wrong"));
}
return rescheduleToSameTimeslotOfEvent({
eventType: chosenEventType,
});
}
function rescheduleSameEventInNewTab() {
if (!chosenEventUrls?.eventBookingAbsoluteUrl) {
console.error("URL must be there for opening new tab");
throw new Error(t("something_went_wrong"));
}
const reschedulerWindow = rescheduleInNewTab({
url: chosenEventUrls.eventBookingAbsoluteUrl,
});
reroutingState.setValue({
type: "reschedule_to_same_event_new_tab",
reschedulerWindow,
newBooking: null,
});
}
function rescheduleDifferentEventInNewTab() {
if (!chosenEventUrls?.eventBookingAbsoluteUrl) {
console.error("URL must be there for opening new tab");
throw new Error(t("something_went_wrong"));
}
const reschedulerWindow = rescheduleInNewTab({
url: chosenEventUrls.eventBookingAbsoluteUrl,
});
console.log("SETTING NEW VALUE", {
type: "reschedule_to_different_event_new_tab",
reschedulerWindow,
newBooking: null,
});
reroutingState.setValue({
type: "reschedule_to_different_event_new_tab",
reschedulerWindow,
newBooking: null,
});
console.log("VALUE NOW", JSON.stringify(reroutingState));
}
function reroutingPreview() {
if (chosenRoute.action.type === RouteActionType.CustomPageMessage) {
return <span className="text-attention">{t("reroute_preview_custom_message")}</span>;
}
if (chosenRoute.action.type === RouteActionType.ExternalRedirectUrl) {
return (
<span className="text-attention">
{t("reroute_preview_external_redirect", { externalUrl: chosenRoute.action.value })}
</span>
);
}
if (chosenRoute.action.type === RouteActionType.EventTypeRedirectUrl) {
// Computed value for eventTypeSlugToRedirect
const eventTypeSlugToRedirect =
chosenRoute.action.type === RouteActionType.EventTypeRedirectUrl ? chosenRoute.action.value : null;
if (!eventTypeSlugToRedirect) {
console.error("Event type slug to redirect must be there if action is EventTypeRedirectUrl");
throw new Error(t("something_went_wrong"));
}
return (
<div className="flex flex-col space-y-2">
<span className="text-default" data-testid="reroute-preview-event-type">
<span className="font-semibold">{t("event_type")}:</span>{" "}
<a
className="underline"
target="_blank"
href={`${bookerUrl}/${eventTypeSlugToRedirect}`}
rel="noreferrer"
data-testid="test-routing-result">
{eventTypeSlugToRedirect}
</a>
</span>
<span className="text-default" data-testid="reroute-preview-hosts">
<Tooltip content="Includes fixed hosts always and assumes that all the team members mentioned here are assigned to the event">
<span className="font-semibold">
{isRoundRobinScheduling ? t("reroute_preview_possible_host") : t("hosts")}:
</span>
</Tooltip>{" "}
{bookingHosts()}
</span>
</div>
);
}
function bookingHosts() {
if (teamMembersMatchingAttributeLogic.isPending) return t("loading");
const hostEmails = teamMembersMatchingAttributeLogic.data?.map((member) => member.email).join(", ");
return hostEmails || t("no_matching_members");
}
return null;
}
function reroutingCTAs() {
if (chosenRoute.action.type !== RouteActionType.EventTypeRedirectUrl) {
// There are no actions to perform if the new chosen route isn't an event type redirect
return null;
}
const shouldDisableCTAs = teamMembersMatchingAttributeLogic.isPending || createBookingMutation.isPending;
return (
<div className="flex flex-col gap-2">
{!isReroutingToDifferentEvent ? (
<Button onClick={rescheduleSameEventInNewTab} disabled={shouldDisableCTAs}>
{t("reschedule_with_different_timeslot")}
</Button>
) : (
<Button onClick={rescheduleDifferentEventInNewTab} disabled={shouldDisableCTAs}>
{t("reschedule_to_the_new_event_with_different_timeslot")}
</Button>
)}
{!isReroutingToDifferentEvent ? (
<Button
disabled={shouldDisableCTAs}
loading={createBookingMutation.isPending}
onClick={rescheduleToSameTimeslotOfSameEvent}>
{t("reschedule_with_same_timeslot")}
</Button>
) : (
<Button
disabled={shouldDisableCTAs}
loading={createBookingMutation.isPending || isChosenEventTypeRequestInProgress}
onClick={rescheduleToSameTimeslotOfChosenEvent}>
{t("reschedule_with_same_timeslot_of_new_event")}
</Button>
)}
</div>
);
}
function newTabReroutingStatus() {
if (reroutingState.status !== ReroutingStatusEnum.REROUTING_IN_PROGRESS) {
return null;
}
return (
<div>
<span className="text-attention">
<span>Continue with rerouting in the new</span>{" "}
<a
href="javascript:void(0)"
className="text-attention underline"
onClick={() => reroutingState.value?.reschedulerWindow?.focus()}>
tab
</a>
</span>
</div>
);
}
};
const CurrentRoutingStatus = ({
booking,
}: {
booking: Pick<TeamEventTypeBookingToReroute, "eventType" | "user" | "attendees">;
}) => {
const fullSlug = getFullSlugForEvent(booking.eventType);
const { t } = useLocale();
const bookerUrl = useBookerUrl();
if (!fullSlug) return null;
return (
<div className="bg-muted flex flex-col space-y-3 rounded-md p-4 text-sm">
<h2 className="text-emphasis font-medium">{t("current_routing_status")}</h2>
<div className="flex flex-col space-y-2">
<span className="text-default" data-testid="current-routing-status-event-type">
<span className="font-semibold">{t("event_type")}:</span>{" "}
<a
className="underline"
target="_blank"
href={`${bookerUrl}/${fullSlug}`}
rel="noreferrer"
data-testid="test-routing-result">
{fullSlug}
</a>
</span>
<span className="text-default" data-testid="current-routing-status-organizer">
<span className="font-semibold">{t("organizer")}:</span> <span>{booking.user.email}</span>
</span>
<span className="text-default" data-testid="current-routing-status-attendees">
<span className="font-semibold">{t("attendees")}:</span>{" "}
<span>{booking.attendees.map((attendee) => attendee.email).join(", ")}</span>
</span>
</div>
</div>
);
};
const RerouteDialogContentAndFooter = ({
booking,
isOpenDialog,
setIsOpenDialog,
}: Pick<RerouteDialogProps, "isOpenDialog" | "setIsOpenDialog"> & {
booking: TeamEventTypeBookingToReroute;
}) => {
const {
data: responseWithForm,
isPending: isRoutingFormLoading,
error: formResponseFetchError,
} = trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({
formResponseId: booking.routedFromRoutingFormReponse.id,
});
const { t } = useLocale();
if (isRoutingFormLoading)
return (
<>
<FormInputFieldsSkeleton />
<DialogFooter>
<Button color="secondary" onClick={() => setIsOpenDialog(false)}>
{t("cancel")}
</Button>
<Button disabled data-testid="verify-new-route-button">
{t("verify_new_route")}
</Button>
</DialogFooter>
</>
);
if (formResponseFetchError) {
return <div className="mb-8">{formResponseFetchError.message}</div>;
}
if (!responseWithForm) return <div className="mb-8">{t("something_went_wrong")}</div>;
return (
<RerouteDialogContentAndFooterWithFormResponse
isOpenDialog={isOpenDialog}
setIsOpenDialog={setIsOpenDialog}
responseWithForm={responseWithForm}
booking={booking}
/>
);
};
const RerouteDialogContentAndFooterWithFormResponse = ({
responseWithForm,
booking,
isOpenDialog,
setIsOpenDialog,
}: Pick<RerouteDialogProps, "isOpenDialog" | "setIsOpenDialog"> & {
responseWithForm: ResponseWithForm;
booking: TeamEventTypeBookingToReroute;
}) => {
const form = responseWithForm.form;
const { t } = useLocale();
const router = useRouter();
const [responseFromOrganizer, setResponseFromOrganizer] = useState<FormResponse>({});
const isResponseFromOrganizerUnpopulated = Object.keys(responseFromOrganizer).length === 0;
const currentResponse = isResponseFromOrganizerUnpopulated
? responseWithForm.response
: responseFromOrganizer;
const [chosenRoute, setChosenRoute] = useState<LocalRoute | null>(null);
const reroutingState = useReroutingState({
isOpenDialog,
});
const [teamMembersMatchingAttributeLogic, setTeamMembersMatchingAttributeLogic] = useState<
| {
id: number;
name: string | null;
email: string;
}[]
| null
>([]);
const findTeamMembersMatchingAttributeLogicMutation =
trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({
onSuccess(data) {
setTeamMembersMatchingAttributeLogic(data.result);
},
});
function verifyRoute() {
// Reset all states
reroutingState.setValue(null);
setTeamMembersMatchingAttributeLogic(null);
const route = findMatchingRoute({
form,
response: currentResponse,
});
setChosenRoute(route || null);
if (!route) return;
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response: currentResponse,
route,
});
}
return (
<div>
<div>
<Link
className="text-emphasis text-semibold font-cal mb-4 flex underline"
href={`/apps/routing-forms/form-edit/${form.id}`}
target="_blank">
{form.name}
</Link>
<FormInputFields
response={currentResponse}
disabledFields={fieldsThatCannotBeCorrected}
form={form}
setResponse={setResponseFromOrganizer}
/>
<div className="flex flex-col gap-2">
<CurrentRoutingStatus booking={booking} />
{chosenRoute && (
<NewRoutingManager
chosenRoute={chosenRoute}
booking={booking}
teamMembersMatchingAttributeLogic={{
isPending: findTeamMembersMatchingAttributeLogicMutation.isPending,
data: teamMembersMatchingAttributeLogic,
}}
reroutingState={reroutingState}
form={form}
currentResponse={currentResponse}
/>
)}
</div>
</div>
{renderFooter()}
</div>
);
function renderFooter() {
if (reroutingState.status !== ReroutingStatusEnum.REROUTING_COMPLETE) {
return (
<DialogFooter>
<Button color="secondary" onClick={() => setIsOpenDialog(false)}>
{t("cancel")}
</Button>
<Button
onClick={verifyRoute}
data-testid="verify-new-route-button"
disabled={reroutingState.status === ReroutingStatusEnum.REROUTING_IN_PROGRESS}>
{t("verify_new_route")}
</Button>
</DialogFooter>
);
}
if (reroutingState.status === ReroutingStatusEnum.REROUTING_COMPLETE) {
return (
<DialogFooter>
<Button
onClick={() => {
setIsOpenDialog(false);
}}>
{t("close")}
</Button>
</DialogFooter>
);
}
return null;
}
};
export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: RerouteDialogProps) => {
const { t } = useLocale();
const bookingEventType = booking.eventType;
if (!bookingEventType.team) {
console.error("Only team event can be re-routed");
throw new Error(t("something_went_wrong"));
}
if (!bookingEventType.team.slug) {
console.error("Team slug must be there for rerouting");
throw new Error(t("something_went_wrong"));
}
if (!booking.user) {
// Not a hard requirement, but better to be strict with data.
console.error("Booking must have a user");
throw new Error(t("something_went_wrong"));
}
const teamEventTypeBooking = {
...booking,
user: booking.user,
eventType: {
...bookingEventType,
team: {
...bookingEventType.team,
slug: bookingEventType.team.slug,
},
},
};
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow preventCloseOnOutsideClick>
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
<RerouteDialogContentAndFooter
booking={teamEventTypeBooking}
isOpenDialog={isOpenDialog}
setIsOpenDialog={setIsOpenDialog}
/>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,496 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
import { RerouteDialog } from "../RerouteDialog";
const mockRouter = {
push: vi.fn((path: string) => {
return;
}),
};
vi.mock("next/navigation", async (importOriginal) => {
const actual = await importOriginal<typeof import("next/navigation")>();
return {
...actual,
useRouter: vi.fn(() => mockRouter),
};
});
vi.mock("@calcom/app-store/routing-forms/lib/processRoute", () => ({
findMatchingRoute: vi.fn(({ form, response }) => {
return form.routes.find((route: any) => route.__testMatching);
}),
}));
const fakeNewTabWindow = {
close: vi.fn(),
};
const mockOpen = vi.fn((_url: string) => {
return fakeNewTabWindow;
});
vi.stubGlobal("open", mockOpen);
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
return (
<div data-testid="mock-form-input-fields">
{form.fields?.map((field: any) => (
<div key={field.id}>
<label data-testid={`mock-form-field-${field.id}-label`}>{field.label}</label>
<div data-testid={`mock-form-field-${field.id}-value`}>{response[field.id]?.value}</div>
</div>
))}
<div data-testid="mock-form-field-disabled-fields-identifiers">{disabledFields.join(", ")}</div>
<button
data-testid="mock-form-update-response-button"
onClick={() =>
setResponse({
"company-size": {
label: "Company Size",
value: "small",
},
country: {
label: "Country",
value: "usa",
},
})
}>
Test Update Response
</button>
</div>
);
}),
FormInputFieldsSkeleton: vi.fn(() => <div data-testid="mock-form-input-fields-skeleton" />),
}));
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: vi.fn(() => ({ t: (key: string) => key })),
}));
vi.mock("@calcom/web/lib/hooks/useRouterQuery", () => ({
default: vi.fn(() => {
return {
setQuery: vi.fn(),
};
}),
}));
vi.mock("@calcom/ui", async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const actual = await importOriginal<any>("@calcom/ui");
return {
...actual,
Tooltip: vi.fn(({ children }) => <div data-testid="mock-tooltip">{children}</div>),
};
});
vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
appRoutingForms: {
getResponseWithFormFields: {
useQuery: vi.fn(() => ({
data: {
form: {
id: "form-id",
name: "Test Form",
fields: [
{
id: "company-size",
label: "Company Size",
type: "select",
options: [
{ label: "Small", value: "small" },
{ label: "Medium", value: "medium" },
{ label: "Large", value: "large" },
],
},
{
id: "country",
label: "Country",
type: "select",
options: [
{ label: "USA", value: "usa" },
{ label: "Canada", value: "canada" },
{ label: "UK", value: "uk" },
],
},
{
id: "email",
label: "Email",
type: "email",
required: true,
},
],
routes: [
{
id: "mock-route-id",
action: {
eventTypeId: 123,
type: RouteActionType.EventTypeRedirectUrl,
value: "team/test-team/new-test-event",
},
__testMatching: true,
},
{
id: "fallback-route",
isFallback: true,
action: {
type: RouteActionType.CustomPageMessage,
value: "456",
},
__testMatching: false,
},
],
},
response: {
"company-size": {
label: "Company Size",
value: "small",
},
country: {
label: "Country",
value: "usa",
},
},
},
isPending: false,
})),
},
findTeamMembersMatchingAttributeLogic: {
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn(() => {
onSuccess({
result: [
{
id: 1,
name: "Matching User 1",
email: "matching-user-1@example.com",
},
{
id: 2,
name: "Matching User 2",
email: "matching-user-2@example.com",
},
],
});
}),
};
}),
},
},
eventTypes: {
get: {
useQuery: vi.fn(() => ({
data: {
eventType: {
id: "123",
title: "Mocked Event Type",
slug: "mocked-event-type",
length: 30,
schedulingType: "ROUND_ROBIN",
team: { slug: "mocked-team" },
},
},
isPending: false,
})),
},
},
},
},
}));
const mockMutateFn = vi.fn(({ __testOnSuccess }) => {
__testOnSuccess({
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
});
});
vi.mock("@tanstack/react-query", () => ({
useMutation: vi.fn(({ onSuccess }) => {
return {
mutate: vi.fn((payload) => {
mockMutateFn({
...payload,
__testOnSuccess: onSuccess,
});
}),
isLoading: false,
isError: false,
error: null,
onSuccess: onSuccess,
};
}),
}));
async function mockMessageFromOpenedTab({ type, data }: { type: string; data: any }) {
const messageReceivedPromise = new Promise<boolean>((resolve) => {
window.addEventListener("message", () => {
resolve(true);
});
});
window.postMessage(
{
type,
data,
},
"*"
);
return messageReceivedPromise;
}
async function expectEventTypeInfoInCurrentRouting({
eventTypeText,
eventTypeHref,
}: {
eventTypeText: string;
eventTypeHref: string;
}) {
const eventTypeEl = screen.getByTestId("current-routing-status-event-type");
expect(eventTypeEl).toHaveTextContent(eventTypeText);
await expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
}
async function expectEventTypeInfoInReroutePreview({
eventTypeText,
eventTypeHref,
}: {
eventTypeText: string;
eventTypeHref: string;
}) {
const eventTypeEl = screen.getByTestId("reroute-preview-event-type");
expect(eventTypeEl).toHaveTextContent(eventTypeText);
await expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
}
function expectOrganizerInfoInCurrentRouting({ organizerText }: { organizerText: string }) {
const organizerEl = screen.getByTestId("current-routing-status-organizer");
expect(organizerEl).toHaveTextContent(organizerText);
}
function expectAttendeesInfoInCurrentRouting({ attendeesText }: { attendeesText: string }) {
const attendeesEl = screen.getByTestId("current-routing-status-attendees");
expect(attendeesEl).toHaveTextContent(attendeesText);
}
const userWhoBooked = { id: 1, name: "Test User", email: "user@example.com" };
const mockBooking = {
id: 1,
uid: "original-booking-uid",
title: "Test Booking",
startTime: new Date(new Date().setDate(new Date().getDate() + 1)).toISOString(),
metadata: {},
responses: {},
attendees: [{ email: "attendee@example.com", name: "Attendee", timeZone: "UTC", locale: "en" }],
eventType: {
id: 1,
slug: "test-event",
team: { slug: "test-team" },
length: 60,
schedulingType: SchedulingType.ROUND_ROBIN,
title: "Test Event",
},
user: userWhoBooked,
routedFromRoutingFormReponse: { id: 1 },
status: BookingStatus.ACCEPTED, // Add this line
};
const buildBooking = () => {
return {
...mockBooking,
};
};
describe("RerouteDialog", () => {
const mockSetIsOpenDialog = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
test("renders the dialog when open", () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("reroute_booking")).toBeInTheDocument();
expect(screen.getByText("reroute_booking_description")).toBeInTheDocument();
});
test("doesn't render the dialog when closed", () => {
render(
<RerouteDialog isOpenDialog={false} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
expect(screen.queryByText("reroute_booking")).not.toBeInTheDocument();
});
test("displays current routing status", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("current_routing_status")).toBeInTheDocument();
expectEventTypeInfoInCurrentRouting({
eventTypeText: "team/test-team/test-event",
eventTypeHref: "https://cal.com/team/test-team/test-event",
});
expectOrganizerInfoInCurrentRouting({
organizerText: "user@example.com",
});
expectAttendeesInfoInCurrentRouting({
attendeesText: "attendee@example.com",
});
// screen.logTestingPlaygroundURL()
});
test("verify_new_route button is enabled even when form fields are not filled", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
await expect(screen.getByText("verify_new_route")).toBeEnabled();
});
test("disabledFields are passed to FormInputFields with value ['email'] - email field is disabled", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByTestId("mock-form-field-disabled-fields-identifiers")).toHaveTextContent(/^email$/);
});
test("Expect form fields and name to be rendered", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("Test Form")).toBeInTheDocument();
expect(screen.getByTestId("mock-form-field-company-size-label")).toHaveTextContent("Company Size");
expect(screen.getByTestId("mock-form-field-company-size-value")).toHaveTextContent("small");
expect(screen.getByTestId("mock-form-field-country-label")).toHaveTextContent("Country");
expect(screen.getByTestId("mock-form-field-country-value")).toHaveTextContent("usa");
});
test("cancel button closes the dialog", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("cancel")).toBeInTheDocument();
fireEvent.click(screen.getByText("cancel"));
expect(mockSetIsOpenDialog).toHaveBeenCalledWith(false);
});
describe("New Routing tests", () => {
test("when verify_new_route is clicked, the form is submitted", async () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
fireEvent.click(screen.getByText("verify_new_route"));
expectEventTypeInfoInReroutePreview({
eventTypeText: "team/test-team/new-test-event",
eventTypeHref: "https://cal.com/team/test-team/new-test-event",
});
await expect(screen.getByText("verify_new_route")).toBeEnabled();
expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("reroute_preview_possible_host");
expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("matching-user-1@example.com");
expect(screen.getByText("reschedule_to_the_new_event_with_different_timeslot")).toBeInTheDocument();
expect(screen.getByText("reschedule_with_same_timeslot_of_new_event")).toBeInTheDocument();
});
describe("New tab rescheduling", () => {
test("new tab is closed when new booking is rerouted", async () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
clickVerifyNewRouteButton();
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
await mockMessageFromOpenedTab({
type: "CAL:rescheduleBookingSuccessfulV2",
data: {
uid: "RESCHEDULED_BOOKING_UID_NEW_TAB",
},
});
const rescheduleTabUrl = mockOpen.mock.calls[0][0] as unknown as string;
const rescheduleTabUrlObject = new URL(rescheduleTabUrl, "http://mockcal.com");
expect(Object.fromEntries(rescheduleTabUrlObject.searchParams.entries())).toEqual(
expect.objectContaining({
rescheduleUid: mockBooking.uid,
"cal.rerouting": "true",
// Shouldn't include the user who booked the booking
"cal.routedTeamMemberIds": "2",
"cal.reroutingFormResponses": JSON.stringify({
"company-size": {
value: "small",
},
country: {
value: "usa",
},
}),
})
);
expect(fakeNewTabWindow.close).toHaveBeenCalled();
expectToBeNavigatedToBookingPage({
booking: {
uid: "RESCHEDULED_BOOKING_UID_NEW_TAB",
},
});
});
test("Rescheduling with same timeslot works", async () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton();
expect(mockMutateFn).toHaveBeenCalledWith(
expect.objectContaining({
rescheduleUid: mockBooking.uid,
// Shouldn't include the user who booked the booking
routedTeamMemberIds: [2],
reroutingFormResponses: {
"company-size": {
value: "small",
},
country: {
value: "usa",
},
},
})
);
expectToBeNavigatedToBookingPage({
booking: {
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
},
});
});
});
});
});
function clickVerifyNewRouteButton() {
act(() => {
fireEvent.click(screen.getByText("verify_new_route"));
});
}
function clickRescheduleToTheNewEventWithDifferentTimeslotButton() {
act(() => {
fireEvent.click(screen.getByText("reschedule_to_the_new_event_with_different_timeslot"));
});
}
function clickRescheduleWithSameTimeslotOfChosenEventButton() {
act(() => {
fireEvent.click(screen.getByText("reschedule_with_same_timeslot_of_new_event"));
});
}
function expectToBeNavigatedToBookingPage({
booking,
}: {
booking: {
uid: string;
};
}) {
expect(mockRouter.push).toHaveBeenCalledWith(`/booking/${booking.uid}?cal.rerouting=true`);
}
@@ -45,7 +45,7 @@ const CustomEmailTextField = ({
return (
<>
<div
className={`border-default mt-2 flex items-center rounded-md border ${
className={`border-default mt-2 flex w-full items-center rounded-md border ${
inputFocus ? "ring-brand-default border-neutral-300 ring-2" : ""
}`}>
<TextField
@@ -57,6 +57,7 @@ const SecondaryEmailModal = ({
type="creation"
data-testid="secondary-email-add-dialog">
<Form form={formMethods} handleSubmit={handleAddEmail}>
<div className="text-subtle mb-4 text-sm">{t("change_email_hint")}</div>
<TextField
label={t("email_address")}
data-testid="secondary-email-input"
@@ -159,14 +159,14 @@ export const OAuthClientCard = ({
</div>
<div className="flex items-start gap-4">
<Button
className="bg-subtle hover:bg-emphasis text-white"
color="primary"
loading={isLoading}
disabled={isLoading}
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit/webhooks`)}>
Webhooks
</Button>
<Button
className="bg-subtle hover:bg-emphasis text-white"
color="secondary"
loading={isLoading}
disabled={isLoading}
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
@@ -212,7 +212,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
autoCorrect="none"
disabled={disabled}
className={classNames(
"border-l-1 mb-0 mt-0 rounded-md rounded-l-none font-sans text-sm leading-4 focus:!ring-0",
"border-l-1 my-0 rounded-md rounded-l-none font-sans text-sm leading-4 focus:!ring-0",
isInputUsernamePremium
? "border border-orange-400 focus:border focus:border-orange-400"
: "border focus:border",
@@ -262,7 +262,7 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
)}
</div>
{paymentMsg}
{markAsError && <p className="mt-1 text-xs text-red-500">Username is already taken</p>}
{markAsError && <p className="mt-1 text-xs text-red-500">{t("username_already_taken")}</p>}
<Dialog open={openDialogSaveUsername}>
<DialogContent
+2 -1
View File
@@ -168,7 +168,8 @@ export const handleSeatsEventTypeOnBooking = async (
if (seatAttendee) {
const attendee = bookingInfo?.attendees?.find((a) => {
return (
a.email === seatAttendee?.attendee?.email || a.phoneNumber === seatAttendee?.attendee?.phoneNumber
a.email === seatAttendee?.attendee?.email ||
(a.phoneNumber && a.phoneNumber === seatAttendee?.attendee?.phoneNumber)
);
});
bookingInfo["attendees"] = attendee ? [attendee] : [];
@@ -56,6 +56,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
return {
props: {
entity: eventData.entity,
eventTypeId: eventData.id,
duration: getMultipleDurationValue(
eventData.metadata?.multipleDuration,
queryDuration,
@@ -1,3 +1,4 @@
import type { Prisma } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
@@ -10,7 +11,7 @@ import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import type { RouterOutputs } from "@calcom/trpc";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
@@ -29,7 +30,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const session = await getServerSession({ req });
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
const ssr = await ssrInit(context);
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
const isOrgContext = currentOrgDomain && isValidOrgDomain;
@@ -54,43 +54,54 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
select: {
id: true,
hideBranding: true,
parent: true,
name: true,
slug: true,
eventTypes: {
where: {
slug: meetingSlug,
},
select: {
id: true,
isInstantEvent: true,
schedulingType: true,
metadata: true,
length: true,
},
},
},
});
if (!team) {
if (!team || !team.eventTypes?.[0]) {
return {
notFound: true,
} as const;
}
const eventData = team.eventTypes[0];
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
}
const org = isValidOrgDomain ? currentOrgDomain : null;
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({
username: teamSlug,
eventSlug: meetingSlug,
isTeamEvent: true,
org,
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
if (!eventData) {
return {
notFound: true,
} as const;
}
const ssr = await ssrInit(context);
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
return {
props: {
eventData: {
entity: eventData.entity,
eventTypeId: eventData.id,
entity: {
fromRedirectOfNonOrgLink,
considerUnpublished: isUnpublished && !fromRedirectOfNonOrgLink,
orgSlug: isValidOrgDomain ? currentOrgDomain : null,
teamSlug: team.slug ?? null,
name: team.parent?.name ?? team.name ?? null,
},
length: eventData.length,
metadata: eventData.metadata,
metadata: EventTypeMetaDataSchema.parse(eventData.metadata),
},
booking,
user: teamSlug,
@@ -98,20 +109,27 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
slug: meetingSlug,
trpcState: ssr.dehydrate(),
isBrandingHidden: team?.hideBranding,
isInstantMeeting: eventData.isInstantEvent && queryIsInstantMeeting ? true : false,
isInstantMeeting: eventData && queryIsInstantMeeting ? true : false,
themeBasis: null,
orgBannerUrl: eventData?.team?.parent?.bannerUrl ?? "",
orgBannerUrl: team.parent?.bannerUrl ?? "",
teamMemberEmail: await getTeamMemberEmail(eventData, email as string),
},
};
};
type EventData = RouterOutputs["viewer"]["public"]["event"];
async function getTeamMemberEmail(eventData: EventData, email?: string): Promise<string | null> {
async function getTeamMemberEmail(
eventData: {
id: number;
isInstantEvent: boolean;
schedulingType: SchedulingType | null;
metadata: Prisma.JsonValue | null;
length: number;
},
email?: string
): Promise<string | null> {
// Pre-requisites
if (!eventData || !email || eventData.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.id);
const crmContactOwnerEmail = await getCRMContactOwnerForRRLeadSkip(email, eventData.metadata);
if (!crmContactOwnerEmail) return null;
// Determine if the contactOwner is a part of the event type
const contactOwnerQuery = await prisma.user.findFirst({
-1
View File
@@ -167,7 +167,6 @@ export const config = {
"/event-types",
"/future/event-types/",
"/settings/admin/:path*",
"/future/settings/admin/:path*",
"/apps/installed/:category/",
"/future/apps/installed/:category/",
"/apps/:slug/",
-15
View File
@@ -3,7 +3,6 @@
import type { ChangeEventHandler } from "react";
import { useState } from "react";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
@@ -96,17 +95,3 @@ export default function Apps({ categories, appStore, userAdminTeams }: PageProps
</AppsLayout>
);
}
export const LayoutWrapper = (page: React.ReactElement) => {
const { t } = useLocale();
return (
<Shell
title={t("app_store")}
description={t("app_store_description")}
withoutMain={true}
hideHeadingOnMobile>
{page}
</Shell>
);
};
@@ -201,7 +201,7 @@ export default function AvailabilityPage({ currentOrg }: PageProps) {
(data && (data.user.role === MembershipRole.OWNER || data.user.role === MembershipRole.ADMIN)) ?? false;
const isOrgAndPrivate = data?.isOrganization && data.isPrivate;
const canViewTeamAvailability = isOrg && (isOrgAdminOrOwner || !isOrgAndPrivate);
const canViewTeamAvailability = isOrgAdminOrOwner || !isOrgAndPrivate;
const toggleGroupOptions = [{ value: "mine", label: t("my_availability") }];
@@ -233,7 +233,7 @@ export default function AvailabilityPage({ currentOrg }: PageProps) {
</div>
}>
{searchParams?.get("type") === "team" && canViewTeamAvailability ? (
<AvailabilitySliderTable userTimeFormat={me?.data?.timeFormat ?? null} />
<AvailabilitySliderTable userTimeFormat={me?.data?.timeFormat ?? null} isOrg={isOrg} />
) : (
<AvailabilityListWithQuery />
)}
@@ -11,6 +11,7 @@ import { FiltersContainer } from "@calcom/features/bookings/components/FiltersCo
import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQuery";
import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery";
import Shell from "@calcom/features/shell/Shell";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -18,7 +19,6 @@ import { trpc } from "@calcom/trpc/react";
import type { HorizontalTabItemProps, VerticalTabItemProps } from "@calcom/ui";
import { Alert, Button, EmptyScreen, HorizontalTabs } from "@calcom/ui";
import { useInViewObserver } from "@lib/hooks/useInViewObserver";
import useMeQuery from "@lib/hooks/useMeQuery";
import BookingListItem from "@components/booking/BookingListItem";
@@ -78,7 +78,6 @@ export default function Bookings() {
const { t } = useLocale();
const user = useMeQuery().data;
const [isFiltersVisible, setIsFiltersVisible] = useState<boolean>(false);
const query = trpc.viewer.bookings.get.useInfiniteQuery(
{
limit: 10,
@@ -88,7 +87,6 @@ export default function Bookings() {
},
},
{
// first render has status `undefined`
enabled: true,
getNextPageParam: (lastPage) => lastPage.nextCursor,
}

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