Compare commits

..
80 Commits
Author SHA1 Message Date
Keith Williams d72f7f8a42 v4.1.9 (#15321)
release.yml / release (push) Canceled after 0s
2024-06-04 16:32:54 +00:00
3a4ebadbf3 feat: track no-shows in bookings (#15307)
* added dropdown for no-shows in past bookings

* added layout

* nit

* todo comment: figure out how to close dropdown

* more todo comments

* Adds TRPC route among other WIP

* functional update

* fixes type err

* fix type err

* fix: styles

* strikethrough in email

* NITs

* removed stale comment

* add tests

* err comment

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-04 15:18:11 +00:00
Hariom BalharaandJoe Au-Yeung 765d601ff9 fix: Booking Error - cannot be booked at this time (#15288)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-06-04 15:09:57 +00:00
3a22fdb428 feat: API v2 subversioning (#15135)
* feat: API v2 subversioning

* Update app.ts

* Added multiple versions to existing controllers

* Updated the slots controller

* chore: version platform library package with npm package alias

* chore: use consts to version

* chore: use consts to version

* chore: version api for cal provider

* chore: remove timezone controller subversion poc

* fixup! Merge branch 'main' into feat/api-v2-subversioning

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-06-04 15:05:39 +00:00
Rajiv SahalandRajiv Sahal 93580ac4bc fix: typo in Gcal atom prop and outlook service (#15319)
* fix typo in onCheckError prop

* pass in correct access token

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
2024-06-04 11:17:33 +00:00
Somay Chauhanandsean-brydon b15ad6f233 fix: inviting a team member dosent work when he login in via Google (#11312)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-04 07:54:26 +00:00
56e59ab3e1 fix: no rating submitted text fixed (#15007)
* no rating submitted text fixed

* Update packages/ui/components/empty-screen/EmptyScreen.tsx

* Update packages/ui/components/empty-screen/EmptyScreen.tsx

---------

Co-authored-by: Peer Richelsen <peer@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-06-03 15:19:38 +00:00
Abhishek Gurav ee796933ee Updated CONTRIBUTING.md (#15102)
For AES256 CALENDSO_ENCRYPTION_KEY
2024-06-03 14:57:36 +00:00
Joe Au-Yeung d2f76edfd6 fix: Double encoding in addAppMutation (#15303)
* Remove double encoding

* Add test for OAuth page

* Add tests

* Remove test from integrations.e2e
2024-06-03 14:53:03 +00:00
Peer RichelsenandJoe Au-Yeung 822f0204a6 added admin API docs (#15297)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-06-03 14:41:13 +00:00
9770aff153 feat: outlook 365 calendar endpoints (#15077)
* init endpoints for office 365 syncing

* get web app url for oauth

* update typings

* nethods for office 365 calendar

* update office 365 connect endpoint

* update packages

* add calendar type and if office 365 calendar

* add SelectedCalendarsRepository and TokensModule

* add functions for getting oauth credentials, getting default calendars and redirect url for office 365 calendar

* update controllers

* e2e tests for office 365 calendar endpoints

* fixup! Merge branch 'main' into rajiv/cal-3544-outlookoffice365-integration-connect-atom

* fixup! fixup! Merge branch 'main' into rajiv/cal-3544-outlookoffice365-integration-connect-atom

* fix merge conflict

* update typings

* abstract microsoft outlook into its own module

* add one common interface for calendar apps

* make calendars dynamic

* abstract every calendar app into its own service

* cleanup

* make all calendar endpoints dynamic

* calendar app constants

* cleanup

* remove unused repositories

* rename microsoft calendar type

* fix typings

* add google and outlook calendar service to calendars module

* fixup

* some more e2e tests including  google calendar

* chore: small type change and apple calendar not implemented yet

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
2024-06-03 11:47:57 +00:00
Hichem Fantar 723d0ddec2 fix: Kbar component styles and improve transitions (#15283)
* fix: Kbar component styles

Fix Kbar styles on active state

* refactor: Add transition to event type list item

The commit refactors the event type list item component by adding a transition effect to improve the user experience.
2024-06-01 21:34:25 +00:00
Amit SharmaandSomay Chauhan 60b61eb625 feat: ability to assign people to round robin from org members (#14199)
* feat: ability to assign people to round robin from org members

* fix: include schedulingtype in filter

* fix: type check

* rename to EventTypesList

* rename to teams, change icon to link, early return if no events or teamId

* filter scheduling type array, change filter name

* rename to bulkAddMembersToTeams and bulkAddMembersToEventTypes

* filter refactor

* remove bulk

* filter remove null return

* Revert "filter remove null return"

This reverts commit 8ae6296dac24e5081b5896bb9dfe0c000c0f7faf.

* show checked if user already host in event type

* check boxes if user is part of the team

* remove hosts on unchecking boxes

* fix isSelected condition

* remove selected members from teams

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-06-01 21:30:52 +00:00
Omar López 5d2d99006b v4.1.8 (#15287)
release.yml / release (push) Canceled after 0s
2024-06-01 00:32:45 +00:00
Keith WilliamsandOmar López 5d01eb2f38 fix: 404 collisions (#15249)
* fix: 404 collisions

* Added E2E test

* Removed await

* Attempting to fix E2E

* fingers crossed

Signed-off-by: zomars <zomars@me.com>

* fix: e2e tests

* fix: missing navigation to 404 page

* fix: don't interrupt post login navigation

* fix: Expect owner as possible host for RR events

---------

Signed-off-by: zomars <zomars@me.com>
Co-authored-by: Omar López <zomars@me.com>
2024-05-31 22:28:43 +00:00
Joe Au-Yeung ac3fb0a1cd fix: Typo in deleteCredential handler (#15281)
* Fix typo

* Added optional chaining
2024-05-31 16:57:20 +00:00
Hariom Balhara 1730ef7d5d feat: Add framework to send all events fired for embed to Analytics Apps (#15173)
* Send all events to analytics apps

* feat: Start sending all events fired for embed to Analytics Apps(GTM Support added)

* Add tests
2024-05-31 16:07:09 +00:00
Somay Chauhan 9e371ded90 add redirectUrl to /eventTypes page as well (#15274)
* During signup, if the account already exists, we redirect the user to /event-types instead of onboarding. Adding this redirection logic to /event-types page as well to ensure the user is redirected to the correct redirectUrl.
2024-05-31 15:06:11 +00:00
Keith Williams 15098bdd1e chore: v4.1.7 (#15277)
release.yml / release (push) Canceled after 0s
2024-05-31 14:30:41 +00:00
625a7ec180 refactor: Introduce crmManager (#14272)
* fix timezone display on booking page to reflect event availability timezone

* migrate fetching event owner's schedule to server side

* migrate fetching event owner's schedule to server side

* fix e2e test errors

* Add WEBAPP_URL_FOR_OAUTH to salesforce auth

* In event manager constructor include "_crm" credentials as calendar creds

* Change crm apps to type to end with `_crm`

* Move sendgrid out of CRM

* Add zoho bigin to CRM apps

* When getting apps, use slug

* Add `crm` variants

* Hubspot Oauth use `WEBAPP_URL_FOR_OAUTH`

* Refactor creating credentials

* Fix empty CRM page

* Use credentials with `_crm`

* Abstract getAppCategoryTitle

* Add integration.handler changes

* Init crmManager

* Change salesforce to CrmService

* Create crmManager

* Create contact on new event

* Create event

* Create new CRM reference

* - Fix create new contact for salesforce
- Add reschedule to crmManager

* Create deleteAllCRMEvents

* When searching for credential, look for current credentials in class

* On cancel, delete 3rd party events

* Add delete method

* Type fix

* Type fix

* Convert Close.com to CrmService

* Convert Close.com to CrmService

* Move hubspot to CrmService

* Convert Pipedrive to CrmService

* Rename classes to CrmService

* Move ZohoCrm to CrmService

* Move Bigin to CrmService

* Type return for CrmServices

* Fix type errors

* Close.com create leads and contacts

* Fix tests

* Type fix

* Zoho bug fixes

* Clean up

* Type fixes

* Remove apiDeletes

* Type fixes

* Specific typing

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* feat: Enable CRM apps on a per event type basis (#14450)

* Add Salesforce to be an event type app

* Handle new booking, only get enabled CRM credentials

* Abstract generating search params

* Add close.com to event type

* Clean up

* Move hubspot to event type

* Add pipedrive to event type

* Add zoho bigin to event type

* Add zoho crm to event type

* Remove console.log

* Add deleting CRM apps from event type

* Delete event type apps

* Fix deleting credentials

* Add CRM app data to event type metadata

* Backwards compatibility: add CRM credential if doesn't exist on event type

* Don't include user CRM credentials for backwards comp

* Backwards compatibility show CRM app is enabled and dirty field

* Clean up

* Type fixes

* Type fixes

* Type fix

* Type fix

* Remove console.log

* Test fix

* Upgrade embed-react vite version - dev

* Change build can't find error message

* Add back omni install prop

* Clean up

* Refactor `writeAppDataToEventType`

* Use eventType repository in writeAppDataToEventType

* Clean up old comments

* Add error logging

* createCRMEvents pass event uid as created event uid

* Use `getUid`

* Clean up props in create crm event

* Small changes to `crmManager`

* Fix zoho CRM

* refactor crmManager

* Undo vite config change

* Fix teamId query

* Fix bigin error

* Remove need for `writeAppDataToEventType`

* Add `getAllCredentials` test

* Add crmManager tests

* Type fixes

* Fix type errors

* Fix getAllCredentials test

* Fix tests

* Skip CRM manager tests for now

* feat: Skip RR Assignment if Contact Exists In Salesforce (#14556)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

* Update yarn.lock

* @zomars feedback - use new URL for state params

* fix: update hook to not produce enabled === undefined

* fix: update app card interfaces to use the new enabled from useIsAppEnabled

* fix: feedback for crm RR skip (#15160)

* code clean up

* fix type any

* test setup for RR lead skip

* code clean up

* simplify code

* type error

* finshed first test for RR lead skip

* add seconds test

* add test for handleNewBooking

* test if teamMember is set

* fix missing enabled key

* fix tests

* fix type error

* use setSystemTime instead of getDate

* remove nested if

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

* fix type error

* fix: remove app metadata from all eventTypes on deleting the app

* fix: update hook to not produce enabled === undefined (default to false)

---------

Co-authored-by: Shaik-Sirajuddin <sirajuddinshaik30gmail.com>
Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Shaik-Sirajuddin <89742297+Shaik-Sirajuddin@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-05-31 13:54:52 +00:00
Omar López 5602f7bd95 chore: v4.1.6 (#15266)
release.yml / release (push) Canceled after 0s
2024-05-31 13:32:30 +00:00
Udit TakkarandKeith Williams 3e2caa9e1e fix: add blacklist email logic in backend (#15270)
* fix: add blacklist email logic in backedn

* chore: type err

* Update packages/features/bookings/lib/handleNewBooking.ts

* Update packages/features/bookings/lib/handleNewBooking.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-31 12:47:36 +00:00
Lauris Skraucis b905de435d refactor: format v2 API managed user timeZone (#15273) 2024-05-31 12:06:29 +00:00
Amit Sharma 23bb06cc36 fix: Avatars not showing when using bookings filter (#15271) 2024-05-31 08:25:10 +00:00
Afrin NaharandKeith Williams dd24100035 fixes top right sections overflow on event page (#15109)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-31 04:23:19 +00:00
Vinicius VieiraandKeith Williams 7434d91d48 Adjust on getAggregateWorkingHours (#15137)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-31 04:01:11 +00:00
Keith WilliamsandUdit Takkar 3b1de34451 chore: Add guest email blacklist (#15255)
* chore: Add guest email blacklist

* types

* Added check to only log when we've removed one

* feat: add verification logic

* chore: use base exftract email

* Added toLowerCase for guest email checks

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-05-30 17:13:34 +00:00
4cf89d273e feat(i18n): complete Simplified Chinese translations (#14474)
* feat(i18n): complete Simplified Chinese translation

* chore: check for missing translation items

* fix: put the prevent merge conflict line at the end

* feat: put new translations at the beginning

* Rename translate-locales.ts to check-missing-translations.ts

* Update package.json to use check-missing-translations

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2024-05-30 15:06:58 +00:00
Dilpreet SinghandSio e58ce4af71 adds 45 min to dynamic events (#15248)
Co-authored-by: Sio <sio@Sios-MacBook-Air.local>
2024-05-30 14:01:45 +00:00
KaleemandAlex van Andel 15122dd5e9 fix: remove ts-ignore by converting to string[] (#14622)
* fix: remove ts-ignore by converting to string[]

* chore: remove extra space

* chore: make word lower case

* Possible fix for endpoints.ts

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-05-30 13:35:17 +00:00
sean-brydon 793c0c60e3 test: get upgradeable test (#15261)
* Add test for defaeult handler

* tests: add tests for getUsersTopbanner-getUpgradeable

* Import router to use userId
2024-05-30 12:28:40 +00:00
MorganandAlex van Andel 344454e574 chore: version platform libraries npm package (#15257)
* wip

* fix: watch mode and eslint slow

* update yarn lock

* Regenerated yarn.lock file for this branch

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-05-30 12:11:24 +00:00
sean-brydon 86342d103a Add test for defaeult handler (#15258) 2024-05-30 11:57:54 +00:00
Carina WollendorferandCarinaWolli d4f3726875 fix: missing date overrides in negative tz (#15251)
* fix check for past date overrides

* add test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-05-29 23:35:43 +00:00
Hariom Balhara 817a3decb7 fix: Do subscription update even if team.members are less than 30 in case orgSeats allow it (#15209)
* Send all events to analytics apps

* fix: Handle subscription update when orgSeats are less than 30

* Add more tests
2024-05-29 21:19:32 +00:00
Keith Williams 5f082e11f8 fix: API booking issues (#15247)
* fix: API booking issues

* Put back the optional zod for user

* fixed the imports

* Using nullish

* typing errors

* missed one
2024-05-29 16:16:49 +00:00
Amit Sharma 5fb5081faa fix: removing the default app should remove the default badge from the appCard (#15243) 2024-05-29 15:10:45 +00:00
17f328fc5e chore: added help cards to platform dashboard (#15218)
* added cards to platform dashboard

* nit

* move help cards into its own component

* help cards component

* utils for platform

* fix icon type prop

* fix icon

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2024-05-29 13:58:18 +00:00
Udit Takkar d19034c2d5 fix: double icon on user event type (#15239)
* fix: double icon

* test: add test for user event type
2024-05-29 13:23:58 +00:00
Morgan 4cdcc85620 fix: useGetBookingForReschedule wait for calProvider init (#15236) 2024-05-29 13:02:44 +00:00
Somay Chauhan 3f51ae626c openIntercom="true" open intercom (#15235) 2024-05-29 12:22:44 +00:00
Somay ChauhanandPeer Richelsen 8499aebe88 fix: intercom has organisation (#15224)
* fix: intercom has organisation

* Apply suggestions from code review

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-05-29 12:22:25 +00:00
Udit Takkar 07f613050e fix: not able to click start recording button (#15244) 2024-05-29 12:18:51 +00:00
Keith Williams 2c8c8368a0 chore: Reducing E2E test sharding (#15192)
* chore: Removing E2E test sharding

* Added back 4 shards for main E2E

* Bump build machines to use 8vCPUs

* Downgrading builds to 2 vCPUs to see time difference

* Reduced shards to 2 to see if flakes are reduced

* Increased E2E vCPUs

* Put builds back to 4 vCPUs since they need it

* Updated E2E suite to use 16 vCPUs and no sharding

* Not running in parallel just to test

* Put back the parallelism

* Get rid of themeBasis warning in test suite

* Skipping another flaky test

* 2 shards

* Update e2e.yml
2024-05-28 20:50:39 +00:00
Alex van Andel 74d3a84e27 v4.1.5 (#15230)
release.yml / release (push) Canceled after 0s
2024-05-28 20:15:53 +00:00
Somay Chauhan 33c1d49c52 feat: jelly seamless signup (#15220)
* feat: jelly seamless signup

* fix: wrong import

* add credentialId id to defaultLocation

* add CredentialId and correct appType

* fix: type error
2024-05-28 17:54:59 +00:00
889ff177c1 feat: Add {first name} variable to Organizer & Scheduler custom event… (#14156)
* feat: Add {first name} variable to Organizer & Scheduler custom event name

* fix: use split name booking field

* remove unwanted translation changes

* Update packages/core/event.ts

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

* chore: improve code

* add scheduler last name

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-05-28 14:32:12 +00:00
Morgan d8a6c375d0 chore: Platform Booker defaultFormValues for prefilling (#15219)
* chore: Platform Booker defaultFormValues for prefilling

* fixup! chore: Platform Booker defaultFormValues for prefilling
2024-05-28 11:36:54 +00:00
Lauris Skraucis 655127d155 fix: exclude atoms web wrappers from build (#15216)
* fix: exclude atoms web wrappers from build

* refactor: tsconfig web wrapper regex

* refactor: wrappers follow schema Component(Web|Platform)Wrapper.tsx

* fix: tsconfig regex for web wrapper
2024-05-28 10:09:03 +00:00
Morgan 17c4a10e33 fix: platform plan name is always uppercase (#15215) 2024-05-28 08:17:42 +00:00
Udit Takkar 30f9f19a9f fix: avatar on booking page (#15190)
* fix: avatar on booking page

* chore

* fix: type err

* fix: unpublished org
2024-05-27 19:31:08 +00:00
Anik Dhabal BabuandPeer Richelsen be7d45afe2 feat: autosave availability after making changes (#14849)
* feat: availability after making changes

* update

* update

* Update Schedule.tsx

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-05-27 14:25:29 +00:00
Amit SharmaandUdit Takkar d2bec46d89 fix: event duration missing translation (#14862)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-27 14:12:00 +00:00
Sujit Ale MagarandUdit Takkar b90500778e fix: Event Type Toggle Shift (#15066)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-27 14:10:53 +00:00
Rajiv SahalandMorgan Vernay bddc235c3c fix: platform booker icons and address property for type inPerson (#15207)
* fix: platform booker icons

* fix label not being shown for address

* fix type check

* PR feedback

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-05-27 11:02:58 +00:00
360b729d08 chore: added event-types link to settings (#14274)
* added event-types link

* fix: add item in settings layout

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-05-27 09:35:52 +00:00
Alex van Andel 5d325495a9 fix: Profile page crash due to invalid topLevelElement (#15198) 2024-05-24 16:12:15 +00:00
Amit Sharma dead2b843a fix: attendeeSeatId is not being returned on webhook response for events with seats (#15184) 2024-05-24 15:47:35 +00:00
Olga Stogova 1174cb0d8e fix(atoms): fix max-height bug for the Booker component (#15195) 2024-05-24 13:15:04 +00:00
Olga Stogova 58e6f18594 fix: styles for the schedule component, make it mobile-friendly (#15178)
* fix styles for the schedule component, make it mobile-friendly

* CR changes

* no need that condition
2024-05-24 11:06:40 +00:00
Udit Takkar ffe6a46e66 chore: return empty array if no transcription found (#15185) 2024-05-24 02:20:50 +00:00
Keith Williams 37c02572de chore: v4.1.4 (#15186)
release.yml / release (push) Canceled after 0s
2024-05-23 20:06:37 +00:00
b82b0a33a4 feat: events apps new install flow (#14616)
* feat(appStore): add isOAuth config

* refactor(appStore): EventTypeAppSettngsInterface

* refactor(qr_code): split EventTypeAppCardInterface and EventTypeAppSettingsInterface

* feat(appStore): redirect to onboarding for stripe and basecam

* fix(ui/ScrollableArea): overflow indicator not working

* feat(apps): new install app flow

* fix(configureStep): get disabled props for settings

* fix: getAppInstallsBySlug now use teamIds

* chore: Alby to AppSettingsInterface

* chore: Giphy to appsettings interface

* chore: GTM migration

* chore: GT4 - migration

* chore: fathom migration

* chore: paypal

* feat: basecamp migration

* feat: metapixel migration

* feat:plausable

* feat:stripe

* fix merge issues

* fix merge issues in new app install flow steps

* wip: callback to onboarding

* fix: imports

* fix: more imports

* feat: use redirect to onboarding on install flow

* fix: redirect to event-type after oauth flow

* When clicking on install new app button, only redirect to the new app install flow if it is an oAuth app or an app that extends the EventType feature in it's config

* code refactor, skip event-type step

* removed extra consoles

* so that no one can change the eventTypeId in url

* theme support enabled

* only pass eventTypeId instead of whole evetType obj to OmniInstallAppButton

* fix: clicking on event-type gives error and redirects to /apps

* saved appOnboardingRedirectUrl to OAuth State

* fix: a user having no team breaks the onboarding flow

* add optional chaining to appMetadata.extendsFeature and appMetadata.isOAuth;

* encode appOnboardingRedirectUrl

* fix: dark theme ui issues

* skip configure step if no-event types

if user/team does not have event-type then skip configure step and goto apps/installed/[category]

* exclude calendars from apps- onboarding flow

* Update getAppOnboardingRedirectUrl.ts

* fix: On configure step, inputs aren't showing errors

* change route from `apps/onboarding` to `apps/installation`

* fix: flow not correctly working for teams

* should only allow team ADMIN or OWER to install an app to team

* Prevent two payment apps to be enabled

* removed loading state

* Fix type error

* undo new-app-flow from OmniInstallAppButton

* remove OAuth Step, handle step numbering

* fix: redirect url

* getAppOnboardingRedirectUrl refactor

* for debugging

* Revert "for debugging"

This reverts commit b9362c1a8f47f2280e684c213d7ec08d04a8ac4c.

* fix: added toaster

* feat: added support to select multiple events (almost)

* hide button

* Move inner form outside outer form using portal

Due to limitations with nested forms in React Hook Form, moved the inner form outside the outer form using a portal to ensure proper form handling.

* submit buttons refs madness

* removed configure step

we had to use query params to manage selected event-types, between routes which was causing 2 issues:
* each time we selected or unselected an event the whole page was reloaded including getserversideprops being called again and again
* the form was reloaded causing more issues

* removed extra unwanted code

* small fix

* fix: type error

* fix: types

* implemented suggested changes

* removed step footer
* fixed error.message
* removed commented styles

* removed step footer

* fix: Display team event types instead of user's on event types step when team selected

* fix: typo

* removed shouldLockDisableProps

* refactor setUpdatedEventTypesStatus, fix type

* removed extra code

* can't delete while saving

* added translations

* fix: loading in select account step, fix handle onSelect params type

* Fix typo

* feat: add skip button to event-types step

* use set-up-later button in tests

* added skip button to configure step as well

* feat: add test for analytics apps using the new app install flow

* feat: add test for stripe 1

* feat: add test for stripe 2

* Fix: Redirect only those apps that extend 'EventType' to the new app install flow (#14527)

* fix: type errors

* fix: failing tests

* fix: tests

* fix test flakiness

* better naming and code refactor for analytics apps tests

* run tests parallelly

* removed network idle, giving error

* another shot at test flakiness

* Fix:  can't install apps without team selector dropdown on main app store page.

* fix: test flakiness

* fix: account select loading issue

* removed getPaymentCredential (unused)

* fix: only redirect after the app has been added to all the event-types

* remove overflowIndicatorStyles

* fixed failing test

* fix: exclude org team events

* undo some random merge changes

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2024-05-23 19:53:28 +00:00
Udit TakkarandKeith Williams b1bbbcee90 fix: signing_up_terms i18n (#15156)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-23 19:06:30 +00:00
Hariom BalharaandKeith Williams 1a381695e5 fix: Org Seeder - to better represent production scenario and fix admin org delete cleanup (#15157)
* Fix seeder and update message to inform well about org deletion implication

* Cleanup tempOrgRedirect as well

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-23 18:01:25 +00:00
Keith WilliamsandOmar López 8831ff13c7 chore: Skip flaky tests that have been failing often (#15161)
* chore: Skip flaky tests that have been failing often

* Update basic.e2e.ts

* Skipped another test

* Only run on legacy routes as a test

* Removing future routes from build

* Updated the remove script

* Fixing command

* Bring back the future routes

* Skipped another

---------

Co-authored-by: Omar López <zomars@me.com>
2024-05-23 14:00:56 -04:00
Keith Williams 64845c670d fix: Required check for CI (#15181) 2024-05-23 13:26:17 -04:00
Keith Williams ba06c6c26a chore: Bring back merge queues (#15177)
* chore: Bring back merge queues

* Removed lint as pre-req for integration test

* Cleaned up warnings
2024-05-23 12:49:29 -04:00
Keith Williams 49df99cf92 chore: v4.1.3 (#15176)
release.yml / release (push) Canceled after 0s
2024-05-23 12:34:21 -04:00
Udit Takkar 0cb2e60d9b fix: broken url on booking page (#15028) 2024-05-23 16:17:44 +00:00
Amit SharmaandUdit Takkar b3485b07bf fix: mobile avatars not loading on event-types (#15167)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-23 16:17:09 +00:00
Lauris Skraucis 01933e5c15 feat: v2 API enable updating event-type bookingFields (#15172) 2024-05-23 13:19:52 +00:00
Hariom Balhara 5f5cf92456 fix: Use origin as provided in HeadSeo. origin would be different for Domain cases (#14935)
* fix: Use origin as provided in HeadSeo. origin would be different for Domain cases

* Add a test

* add more test

* Add tests

* Cleanup tests

* Add an e2e as well

* fix-test

* Reset mocks
2024-05-23 11:47:46 +01:00
Amit SharmaandSomay Chauhan 29ccc58806 fix: if we go to event-types/[id]?tabName=apps after installing an app to an organisation it shows 2 apps (#15166)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-05-23 08:37:10 +00:00
Omar López 26bd02bd94 chore: Moved legacy rewrites from afterFiles to beforeFiles (#15164) 2024-05-22 12:14:08 -07:00
Syed Ali Shahbaz 04bf23ed69 Make pagination optional (#15163) 2024-05-22 17:51:36 +00:00
Alex van Andel a7b5fd593e chore: Release version 4.1.2 (#15159)
release.yml / release (push) Canceled after 0s
2024-05-22 15:46:08 +00:00
Udit Takkar 6dd4933337 feat: add get recording and transcripts API endpoint (#15131)
* feat: add get recording API endpoint

* feat: add transcript endpoint

* chore: add missing await

* chore: add error handling

* fix: type err and add swagger comment

* test: add test

* chore: add afterEach

* fix: test

* test: add transcript test
2024-05-22 14:04:08 +01:00
Hariom Balhara 4217efc5a8 fix: Failing futureLimit handleNewBooking test (#15155) 2024-05-22 14:44:56 +05:30
aca2b29aa4 feat: better logging for failed webhooks (#14825)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2024-05-22 08:51:14 +00:00
439 changed files with 12412 additions and 2263 deletions
+3
View File
@@ -359,3 +359,6 @@ UNKEY_ROOT_KEY=
# Used for Cal.ai Enterprise Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
# Used to disallow emails as being added as guests on bookings
BLACKLISTED_GUEST_EMAILS=
+2
View File
@@ -1,4 +1,6 @@
node_modules
apps/api/v2/dist
packages/platform/**/dist/*
**/**/node_modules
**/**/.next
**/**/public
+85
View File
@@ -0,0 +1,85 @@
name: All checks
on:
merge_group:
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
type-check:
name: Type check
uses: ./.github/workflows/check-types.yml
secrets: inherit
lint:
name: Linters
uses: ./.github/workflows/lint.yml
secrets: inherit
unit-test:
name: Tests
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
build-api-v1:
name: Production builds
uses: ./.github/workflows/api-v1-production-build.yml
secrets: inherit
build-api-v2:
name: Production builds
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build:
name: Production builds
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
integration-test:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
e2e:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: Tests
needs: [lint, build, build-api-v1, build-api-v2]
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
analyze:
name: Analyze Build
needs: [build]
uses: ./.github/workflows/nextjs-bundle-analysis.yml
secrets: inherit
required:
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
run: exit 1
+3 -6
View File
@@ -41,7 +41,7 @@ env:
jobs:
e2e-app-store:
timeout-minutes: 20
name: E2E App Store (${{ matrix.shard }}/${{ strategy.job-total }})
name: E2E App Store
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
@@ -70,9 +70,6 @@ jobs:
- 1025:1025
strategy:
fail-fast: false
matrix:
## There aren't many tests for AppStore. So, just start with 2 shards. Increase if needed.
shard: [1, 2]
steps:
- uses: docker/login-action@v3
@@ -93,10 +90,10 @@ jobs:
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
- uses: ./.github/actions/cache-build
- name: Run Tests
run: yarn e2e:app-store --shard=${{ matrix.shard }}/${{ strategy.job-total }}
run: yarn e2e:app-store
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: app-store-results-${{ matrix.shard }}_${{ strategy.job-total }}
name: app-store-results
path: test-results
+3 -6
View File
@@ -41,7 +41,7 @@ env:
jobs:
e2e-embed:
timeout-minutes: 20
name: E2E Embed React (${{ matrix.shard }}/${{ strategy.job-total }})
name: E2E Embed React
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
@@ -62,9 +62,6 @@ jobs:
- 5432:5432
strategy:
fail-fast: false
matrix:
## There aren't many tests for embed-react. So, just start with 2 shards. Increase if needed.
shard: [1, 2]
steps:
- uses: docker/login-action@v3
@@ -79,11 +76,11 @@ jobs:
- uses: ./.github/actions/cache-build
- name: Run Tests
run: |
yarn e2e:embed-react --shard=${{ matrix.shard }}/${{ strategy.job-total }}
yarn e2e:embed-react
yarn workspace @calcom/embed-react packaged:tests
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: embed-react-results-${{ matrix.shard }}_${{ strategy.job-total }}
name: embed-react-results
path: test-results
+3 -6
View File
@@ -41,7 +41,7 @@ env:
jobs:
e2e-embed:
timeout-minutes: 20
name: E2E Embed Core (${{ matrix.shard }}/${{ strategy.job-total }})
name: E2E Embed Core
runs-on: buildjet-4vcpu-ubuntu-2204
services:
postgres:
@@ -70,9 +70,6 @@ jobs:
- 1025:1025
strategy:
fail-fast: false
matrix:
## There aren't many tests for embed-core. So, just start with 2 shards. Increase if needed.
shard: [1, 2]
steps:
- uses: docker/login-action@v3
@@ -86,10 +83,10 @@ jobs:
- uses: ./.github/actions/cache-db
- uses: ./.github/actions/cache-build
- name: Run Tests
run: yarn e2e:embed --shard=${{ matrix.shard }}/${{ strategy.job-total }}
run: yarn e2e:embed
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: embed-core-results-${{ matrix.shard }}_${{ strategy.job-total }}
name: embed-core-results
path: test-results
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
e2e:
timeout-minutes: 20
name: E2E (${{ matrix.shard }}/${{ strategy.job-total }})
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: buildjet-8vcpu-ubuntu-2204
services:
postgres:
image: postgres:13
@@ -71,7 +71,7 @@ jobs:
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4, 5, 6, 7]
shard: [1, 2, 3, 4]
steps:
- uses: docker/login-action@v3
with:
+8 -7
View File
@@ -75,15 +75,16 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-db
- name: Run Tests
run: yarn test -- --integrationTestsOnly
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results
# TODO: Generate test results so we can upload them
# - name: Upload Test Results
# if: ${{ always() }}
# uses: actions/upload-artifact@v4
# with:
# name: test-results
# path: test-results
+5 -62
View File
@@ -4,7 +4,6 @@ on:
pull_request_target:
branches:
- main
merge_group:
workflow_dispatch:
concurrency:
@@ -35,13 +34,6 @@ jobs:
uses: ./.github/workflows/check-types.yml
secrets: inherit
unit-test:
name: Tests
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
lint:
name: Linters
needs: [changes]
@@ -49,71 +41,22 @@ jobs:
uses: ./.github/workflows/lint.yml
secrets: inherit
build-api-v1:
name: Production builds
unit-test:
name: Tests
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v1-production-build.yml
secrets: inherit
build-api-v2:
name: Production builds
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build:
name: Production builds
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/production-build-without-database.yml
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
integration-test:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
e2e:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: Tests
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
analyze:
name: Analyze Build
needs: [changes, build]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/nextjs-bundle-analysis.yml
secrets: inherit
required:
needs: [changes, lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
needs: [changes, lint, type-check, unit-test, integration-test]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
+1 -1
View File
@@ -99,7 +99,7 @@ To develop locally:
- Duplicate `.env.example` to `.env`.
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
- Use `openssl rand -base64 24` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
- Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
6. Setup Node
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
+1 -1
View File
@@ -55,7 +55,7 @@ export const schemaBookingReadPublic = Booking.extend({
timeZone: true,
locale: true,
})
.optional(),
.nullish(),
payment: z
.array(
_PaymentModel.pick({
@@ -0,0 +1,99 @@
import type { NextApiRequest } from "next";
import { getRecordingsOfCalVideoByRoomName } from "@calcom/core/videoClient";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import type { RecordingItemSchema } from "@calcom/prisma/zod-utils";
import type { PartialReference } from "@calcom/types/EventManager";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
/**
* @swagger
* /bookings/{id}/recordings:
* get:
* summary: Find all Cal video recordings of that booking
* operationId: getRecordingsByBookingId
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the booking for which recordings need to be fetched. Recording download link is only valid for 12 hours and you would have to fetch the recordings again to get new download link
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* tags:
* - bookings
* responses:
* 200:
* description: OK
* content:
* application/json:
* schema:
* $ref: "#/components/schemas/ArrayOfRecordings"
* examples:
* recordings:
* value:
* - id: "ad90a2e7-154f-49ff-a815-5da1db7bf899"
* room_name: "0n22w24AQ5ZFOtEKX2gX"
* start_ts: 1716215386
* status: "finished"
* max_participants: 1
* duration: 11
* share_token: "x94YK-69Gnh7"
* download_link: "https://daily-meeting-recordings..."
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: Booking was not found
*/
export async function getHandler(req: NextApiRequest) {
const { query } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const booking = await prisma.booking.findUnique({
where: { id },
include: { references: true },
});
if (!booking)
throw new HttpError({
statusCode: 404,
message: `No Booking found with booking id ${id}`,
});
const roomName =
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
undefined;
if (!roomName)
throw new HttpError({
statusCode: 404,
message: `No Cal Video reference found with booking id ${booking.id}`,
});
const recordings = await getRecordingsOfCalVideoByRoomName(roomName);
if (!recordings || !("data" in recordings)) return [];
const recordingWithDownloadLink = recordings.data.map((recording: RecordingItemSchema) => {
return getDownloadLinkOfCalVideoByRecordingId(recording.id)
.then((res) => ({
...recording,
download_link: res?.download_link,
}))
.catch((err) => ({ ...recording, download_link: null, error: err.message }));
});
const res = await Promise.all(recordingWithDownloadLink);
return res;
}
export default defaultResponder(getHandler);
@@ -0,0 +1,16 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import authMiddleware from "../_auth-middleware";
export default withMiddleware()(
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
await authMiddleware(req);
return defaultHandler({
GET: import("./_get"),
})(req, res);
})
);
@@ -0,0 +1,73 @@
import type { NextApiRequest } from "next";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import prisma from "@calcom/prisma";
import type { PartialReference } from "@calcom/types/EventManager";
import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransformParseInt";
/**
* @swagger
* /bookings/{id}/transcripts:
* get:
* summary: Find all Cal video transcripts of that booking
* operationId: getTranscriptsByBookingId
* parameters:
* - in: path
* name: id
* schema:
* type: integer
* required: true
* description: ID of the booking for which recordings need to be fetched.
* - in: query
* name: apiKey
* required: true
* schema:
* type: string
* description: Your API key
* tags:
* - bookings
* responses:
* 200:
* description: OK
* content:
* application/json:
* 401:
* description: Authorization information is missing or invalid.
* 404:
* description: Booking was not found
*/
export async function getHandler(req: NextApiRequest) {
const { query } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const booking = await prisma.booking.findUnique({
where: { id },
include: { references: true },
});
if (!booking)
throw new HttpError({
statusCode: 404,
message: `No Booking found with booking id ${id}`,
});
const roomName =
booking?.references?.find((reference: PartialReference) => reference.type === "daily_video")?.meetingId ??
undefined;
if (!roomName)
throw new HttpError({
statusCode: 404,
message: `No Cal Video reference found with booking id ${booking.id}`,
});
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(roomName);
return transcripts;
}
export default defaultResponder(getHandler);
@@ -0,0 +1,16 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import authMiddleware from "../_auth-middleware";
export default withMiddleware()(
defaultResponder(async (req: NextApiRequest, res: NextApiResponse) => {
await authMiddleware(req);
return defaultHandler({
GET: import("./_get"),
})(req, res);
})
);
+4 -2
View File
@@ -180,8 +180,10 @@ export async function handler(req: NextApiRequest) {
const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query);
const args: Prisma.BookingFindManyArgs = {};
args.take = take;
args.skip = skip;
if (req.query.take && req.query.page) {
args.take = take;
args.skip = skip;
}
args.include = {
attendees: true,
user: true,
+12 -1
View File
@@ -2,6 +2,8 @@ import type { NextApiRequest } from "next";
import getBookingDataSchemaForApi from "@calcom/features/bookings/lib/getBookingDataSchemaForApi";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
@@ -218,7 +220,16 @@ async function handler(req: NextApiRequest) {
req.userId = requestedUserId || userId;
}
return await handleNewBooking(req, getBookingDataSchemaForApi);
try {
return await handleNewBooking(req, getBookingDataSchemaForApi);
} catch (error: unknown) {
const knownError = error as Error;
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
throw new HttpError({ statusCode: 400, message: knownError.message });
}
throw error;
}
}
export default defaultResponder(handler);
+31
View File
@@ -27,6 +27,37 @@ const swaggerHandler = withSwagger({
$ref: "#/components/schemas/Booking",
},
},
ArrayOfRecordings: {
type: "array",
items: {
$ref: "#/components/schemas/Recording",
},
},
Recording: {
properties: {
id: {
type: "string",
},
room_name: {
type: "string",
},
start_ts: {
type: "number",
},
status: {
type: "string",
},
max_participants: {
type: "number",
},
duration: {
type: "number",
},
download_link: {
type: "string",
},
},
},
Booking: {
properties: {
id: {
@@ -0,0 +1,136 @@
import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi, afterEach } from "vitest";
import {
getRecordingsOfCalVideoByRoomName,
getDownloadLinkOfCalVideoByRecordingId,
} from "@calcom/core/videoClient";
import { buildBooking } from "@calcom/lib/test/builder";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware";
import handler from "../../../../../pages/api/bookings/[id]/recordings/_get";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
const adminUserId = 1;
const memberUserId = 10;
vi.mock("@calcom/core/videoClient", () => {
return {
getRecordingsOfCalVideoByRoomName: vi.fn(),
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
};
});
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
return {
getAccessibleUsers: vi.fn(),
};
});
afterEach(() => {
vi.resetAllMocks();
});
const mockGetRecordingsAndDownloadLink = () => {
const download_link = "https://URL";
const recordingItem = {
id: "TEST_ID",
room_name: "0n22w24AQ5ZFOtEKX2gX",
start_ts: 1716215386,
status: "finished",
max_participants: 1,
duration: 11,
share_token: "TEST_TOKEN",
};
vi.mocked(getRecordingsOfCalVideoByRoomName).mockResolvedValue({ data: [recordingItem], total_count: 1 });
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
download_link,
});
return [{ ...recordingItem, download_link }];
};
describe("GET /api/bookings/[id]/recordings", () => {
test("Returns recordings if user is system-wide admin", async () => {
const userId = 2;
const bookingId = 1111;
prismaMock.booking.findUnique.mockResolvedValue(
buildBooking({
id: bookingId,
userId,
references: [
{
id: 1,
type: "daily_video",
uid: "17OHkCH53pBa03FhxMbw",
meetingId: "17OHkCH53pBa03FhxMbw",
meetingPassword: "password",
meetingUrl: "https://URL",
},
],
})
);
const mockedRecordings = mockGetRecordingsAndDownloadLink();
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: bookingId,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUserId;
await authMiddleware(req);
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData())).toEqual(mockedRecordings);
});
test("Allows GET recordings when user is org-wide admin", async () => {
const bookingId = 3333;
prismaMock.booking.findUnique.mockResolvedValue(
buildBooking({
id: bookingId,
userId: memberUserId,
references: [
{ id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
],
})
);
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: bookingId,
},
});
req.userId = adminUserId;
req.isOrganizationOwnerOrAdmin = true;
const mockedRecordings = mockGetRecordingsAndDownloadLink();
vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
await authMiddleware(req);
await handler(req, res);
expect(res.statusCode).toBe(200);
});
});
@@ -0,0 +1,120 @@
import prismaMock from "../../../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test, vi, afterEach } from "vitest";
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
import { buildBooking } from "@calcom/lib/test/builder";
import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
import authMiddleware from "../../../../../pages/api/bookings/[id]/_auth-middleware";
import handler from "../../../../../pages/api/bookings/[id]/transcripts/_get";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
vi.mock("@calcom/core/videoClient", () => {
return {
getAllTranscriptsAccessLinkFromRoomName: vi.fn(),
};
});
vi.mock("~/lib/utils/retrieveScopedAccessibleUsers", () => {
return {
getAccessibleUsers: vi.fn(),
};
});
afterEach(() => {
vi.resetAllMocks();
});
const mockGetTranscripts = () => {
const downloadLinks = ["https://URL1", "https://URL2"];
vi.mocked(getAllTranscriptsAccessLinkFromRoomName).mockResolvedValue(downloadLinks);
return downloadLinks;
};
describe("GET /api/bookings/[id]/transcripts", () => {
test("Returns transcripts if user is system-wide admin", async () => {
const adminUserId = 1;
const userId = 2;
const bookingId = 1111;
prismaMock.booking.findUnique.mockResolvedValue(
buildBooking({
id: bookingId,
userId,
references: [
{
id: 1,
type: "daily_video",
uid: "17OHkCH53pBa03FhxMbw",
meetingId: "17OHkCH53pBa03FhxMbw",
meetingPassword: "password",
meetingUrl: "https://URL",
},
],
})
);
const mockedTranscripts = mockGetTranscripts();
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: bookingId,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUserId;
await authMiddleware(req);
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData())).toEqual(mockedTranscripts);
});
test("Allows GET transcripts when user is org-wide admin", async () => {
const adminUserId = 1;
const memberUserId = 10;
const bookingId = 3333;
prismaMock.booking.findUnique.mockResolvedValue(
buildBooking({
id: bookingId,
userId: memberUserId,
references: [
{ id: 1, type: "daily_video", uid: "17OHkCH53pBa03FhxMbw", meetingId: "17OHkCH53pBa03FhxMbw" },
],
})
);
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
body: {},
query: {
id: bookingId,
},
});
req.userId = adminUserId;
req.isOrganizationOwnerOrAdmin = true;
mockGetTranscripts();
vi.mocked(getAccessibleUsers).mockResolvedValue([memberUserId]);
await authMiddleware(req);
await handler(req, res);
expect(res.statusCode).toBe(200);
});
});
+5 -3
View File
@@ -9,8 +9,8 @@
"build": "yarn dev:build && nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch & yarn workspace @calcom/platform-libraries build:watch",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
"dev:build:watch": "yarn workspace @calcom/platform-constants build:watch & yarn workspace @calcom/platform-utils build:watch & yarn workspace @calcom/platform-types build:watch",
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node ./dist/apps/api/v2/src/main.js",
@@ -25,11 +25,12 @@
},
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries": "*",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
"@golevelup/ts-jest": "^0.4.0",
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
"@nestjs/core": "^10.0.0",
@@ -55,6 +56,7 @@
"next-auth": "^4.22.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"querystring": "^0.2.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1",
"stripe": "^15.3.0",
-2
View File
@@ -50,8 +50,6 @@ import { AppController } from "./app.controller";
EndpointsModule,
AuthModule,
JwtModule,
//register prefix for all routes in EndpointsModule
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
],
controllers: [AppController],
providers: [
+28 -5
View File
@@ -9,18 +9,33 @@ import { HttpAdapterHost } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import * as Sentry from "@sentry/node";
import * as cookieParser from "cookie-parser";
import { Request } from "express";
import helmet from "helmet";
import { X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import {
API_VERSIONS,
VERSION_2024_04_15,
API_VERSIONS_ENUM,
CAL_API_VERSION_HEADER,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import { TRPCExceptionFilter } from "./filters/trpc-exception.filter";
export const bootstrap = (app: NestExpressApplication): NestExpressApplication => {
app.enableShutdownHooks();
app.enableVersioning({
type: VersioningType.URI,
prefix: "v",
defaultVersion: "1",
type: VersioningType.CUSTOM,
extractor: (request: unknown) => {
const headerVersion = (request as Request)?.headers[CAL_API_VERSION_HEADER] as string | undefined;
if (headerVersion && API_VERSIONS.includes(headerVersion as API_VERSIONS_ENUM)) {
return headerVersion;
}
return VERSION_2024_04_15;
},
defaultVersion: VERSION_2024_04_15,
});
app.use(helmet());
@@ -28,7 +43,15 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
app.enableCors({
origin: "*",
methods: ["GET", "PATCH", "DELETE", "HEAD", "POST", "PUT", "OPTIONS"],
allowedHeaders: [X_CAL_CLIENT_ID, X_CAL_SECRET_KEY, "Accept", "Authorization", "Content-Type", "Origin"],
allowedHeaders: [
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
CAL_API_VERSION_HEADER,
"Accept",
"Authorization",
"Content-Type",
"Origin",
],
maxAge: 86_400,
});
@@ -20,7 +20,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.2";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -2,6 +2,7 @@ import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
@@ -37,14 +38,14 @@ import {
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries";
} from "@calcom/platform-libraries-0.0.2";
import {
handleNewBooking,
BookingResponse,
HttpError,
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries";
} from "@calcom/platform-libraries-0.0.2";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -72,8 +73,8 @@ const DEFAULT_PLATFORM_PARAMS = {
};
@Controller({
path: "/bookings",
version: "2",
path: "/v2/bookings",
version: API_VERSIONS_VALUES,
})
@UseGuards(PermissionsGuard)
@DocsTags("Bookings")
@@ -1,7 +1,7 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.2";
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@@ -0,0 +1,211 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
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 { PlatformOAuthClient, Team, User, Credential } from "@prisma/client";
import * as request from "supertest";
import { CredentialsRepositoryFixture } from "test/fixtures/repository/credentials.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import {
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
GOOGLE_CALENDAR_TYPE,
GOOGLE_CALENDAR_ID,
} from "@calcom/platform-constants";
import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Calendars Endpoints", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let tokensRepositoryFixture: TokensRepositoryFixture;
let credentialsRepositoryFixture: CredentialsRepositoryFixture;
let user: User;
let office365Credentials: Credential;
let googleCalendarCredentials: Credential;
let accessTokenSecret: string;
let refreshTokenSecret: string;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, TokensModule],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
credentialsRepositoryFixture = new CredentialsRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.createOAuthManagedUser("office365-connect@gmail.com", oAuthClient.id);
const tokens = await tokensRepositoryFixture.createTokens(user.id, oAuthClient.id);
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: [CLIENT_REDIRECT_URI],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(oauthClientRepositoryFixture).toBeDefined();
expect(userRepositoryFixture).toBeDefined();
expect(oAuthClient).toBeDefined();
expect(accessTokenSecret).toBeDefined();
expect(refreshTokenSecret).toBeDefined();
expect(user).toBeDefined();
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
const data = response.body.data;
expect(data.authUrl).toBeDefined();
});
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
const data = response.body.data;
expect(data.authUrl).toBeDefined();
});
it(`/GET/v2/calendars/random-calendar/connect: it should respond 400 with a message saying the calendar type is invalid`, async () => {
await request(app.getHttpServer())
.get(`/v2/calendars/random-calendar/connect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(400);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
)
.expect(400);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/v2/calendars/${OFFICE_365_CALENDAR}/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=User.Read%20Calendars.Read%20Calendars.ReadWrite%20offline_access`
)
.expect(400);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check without access token`, async () => {
await request(app.getHttpServer()).get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`).expect(401);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with no credentials`, async () => {
await request(app.getHttpServer())
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(400);
});
it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/check with access token, origin and office365 credentials`, async () => {
office365Credentials = await credentialsRepositoryFixture.create(
OFFICE_365_CALENDAR_TYPE,
{},
user.id,
OFFICE_365_CALENDAR_ID
);
await request(app.getHttpServer())
.get(`/v2/calendars/${OFFICE_365_CALENDAR}/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
});
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/check with access token, origin and google calendar credentials`, async () => {
googleCalendarCredentials = await credentialsRepositoryFixture.create(
GOOGLE_CALENDAR_TYPE,
{},
user.id,
GOOGLE_CALENDAR_ID
);
await request(app.getHttpServer())
.get(`/v2/calendars/${GOOGLE_CALENDAR}/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await credentialsRepositoryFixture.delete(office365Credentials.id);
await credentialsRepositoryFixture.delete(googleCalendarCredentials.id);
await userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
});
@@ -0,0 +1,12 @@
import { Request } from "express";
import { ApiResponse } from "@calcom/platform-types";
export interface CalendarApp {
save(state: string, code: string, origin: string): Promise<{ url: string }>;
check(userId: number): Promise<ApiResponse>;
}
export interface OAuthCalendarApp extends CalendarApp {
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
}
@@ -1,13 +1,25 @@
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, UsersModule],
providers: [CredentialsRepository, CalendarsService],
imports: [PrismaModule, UsersModule, TokensModule],
providers: [
CredentialsRepository,
CalendarsService,
OutlookService,
GoogleCalendarService,
SelectedCalendarsRepository,
AppsRepository,
],
controllers: [CalendarsController],
exports: [CalendarsService],
})
@@ -1,24 +1,48 @@
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, Get, UseGuards, Query } from "@nestjs/common";
import {
Controller,
Get,
UseGuards,
Query,
HttpStatus,
HttpCode,
Req,
Param,
Headers,
Redirect,
BadRequestException,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Request } from "express";
import { z } from "zod";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CalendarBusyTimesInput } from "@calcom/platform-types";
import { APPS_READ } from "@calcom/platform-constants";
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
@Controller({
path: "/calendars",
version: "2",
path: "/v2/calendars",
version: API_VERSIONS_VALUES,
})
@UseGuards(AccessTokenGuard)
@DocsTags("Calendars")
export class CalendarsController {
constructor(private readonly calendarsService: CalendarsService) {}
constructor(
private readonly calendarsService: CalendarsService,
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService
) {}
@UseGuards(AccessTokenGuard)
@Get("/busy-times")
async getBusyTimes(
@Query() queryParams: CalendarBusyTimesInput,
@@ -55,4 +79,70 @@ export class CalendarsController {
data: calendars,
};
}
@UseGuards(AccessTokenGuard)
@Get("/:calendar/connect")
@HttpCode(HttpStatus.OK)
async redirect(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@Param("calendar") calendar: string
): Promise<ApiResponse<{ authUrl: string }>> {
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.connect(authorization, req);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.connect(authorization, req);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
@Get("/:calendar/save")
@HttpCode(HttpStatus.OK)
@Redirect(undefined, 301)
async save(
@Query("state") state: string,
@Query("code") code: string,
@Param("calendar") calendar: string
): Promise<{ url: string }> {
// state params contains our user access token
const stateParams = new URLSearchParams(state);
const { accessToken, origin } = z
.object({ accessToken: z.string(), origin: z.string() })
.parse({ accessToken: stateParams.get("accessToken"), origin: stateParams.get("origin") });
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.save(code, accessToken, origin);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.save(code, accessToken, origin);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
@Get("/:calendar/check")
@HttpCode(HttpStatus.OK)
@UseGuards(AccessTokenGuard, PermissionsGuard)
@Permissions([APPS_READ])
async check(@GetUser("id") userId: number, @Param("calendar") calendar: string): Promise<ApiResponse> {
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.check(userId);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.check(userId);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
}
@@ -1,3 +1,4 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import {
CredentialsRepository,
CredentialsWithUserEmail,
@@ -11,21 +12,27 @@ import {
UnauthorizedException,
NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { User } from "@prisma/client";
import { DateTime } from "luxon";
import { z } from "zod";
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries";
import { getBusyCalendarTimes } from "@calcom/platform-libraries";
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries-0.0.2";
import { getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.2";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class CalendarsService {
private oAuthCalendarResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
constructor(
private readonly usersRepository: UsersRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly appsRepository: AppsRepository,
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService
private readonly dbWrite: PrismaWriteService,
private readonly config: ConfigService
) {}
async getCalendars(userId: number) {
@@ -110,4 +117,24 @@ export class CalendarsService {
});
return composedSelectedCalendars;
}
async getAppKeys(appName: string) {
const app = await this.appsRepository.getAppBySlug(appName);
if (!app) {
throw new NotFoundException();
}
const { client_id, client_secret } = this.oAuthCalendarResponseSchema.parse(app.keys);
if (!client_id) {
throw new NotFoundException();
}
if (!client_secret) {
throw new NotFoundException();
}
return { client_id, client_secret };
}
}
@@ -0,0 +1,156 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
const CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.readonly",
"https://www.googleapis.com/auth/calendar.events",
];
@Injectable()
export class GoogleCalendarService implements OAuthCalendarApp {
private redirectUri = `${this.config.get("api.url")}/gcal/oauth/save`;
private gcalResponseSchema = z.object({ client_id: z.string(), client_secret: z.string() });
private logger = new Logger("GcalService");
constructor(
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly calendarsService: CalendarsService,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
) {}
async connect(
authorization: string,
req: Request
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "");
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string) {
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: CALENDAR_SCOPES,
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}`,
});
return authUrl;
}
async getOAuthClient(redirectUri: string) {
this.logger.log("Getting Google Calendar OAuth Client");
const app = await this.appsRepository.getAppBySlug("google-calendar");
if (!app) {
throw new NotFoundException();
}
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
return oAuth2Client;
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
if (!gcalCredentials) {
throw new BadRequestException("Credentials for google_calendar not found.");
}
if (gcalCredentials.invalid) {
throw new BadRequestException("Invalid google oauth credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const googleCalendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === GOOGLE_CALENDAR_TYPE
);
if (!googleCalendar) {
throw new UnauthorizedException("Google Calendar not connected.");
}
if (googleCalendar.error?.message) {
throw new UnauthorizedException(googleCalendar.error?.message);
}
return { status: SUCCESS_STATUS };
}
async saveCalendarCredentialsAndRedirect(code: string, accessToken: string, origin: string) {
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
if (!code) {
return { url: origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (!ownerId) {
throw new UnauthorizedException("Invalid Access token.");
}
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const token = await oAuth2Client.getToken(parsedCode);
// Google oAuth Credentials are stored in token.tokens
const key = token.tokens;
const credential = await this.credentialRepository.createAppCredential(
GOOGLE_CALENDAR_TYPE,
key as Prisma.InputJsonValue,
ownerId
);
oAuth2Client.setCredentials(key);
const calendar = google.calendar({
version: "v3",
auth: oAuth2Client,
});
const cals = await calendar.calendarList.list({ fields: "items(id,summary,primary,accessRole)" });
const primaryCal = cals.data.items?.find((cal) => cal.primary);
if (primaryCal?.id) {
await this.selectedCalendarsRepository.createSelectedCalendar(
primaryCal.id,
credential.id,
ownerId,
GOOGLE_CALENDAR_TYPE
);
}
return { url: origin };
}
}
@@ -0,0 +1,180 @@
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import type { Calendar as OfficeCalendar } from "@microsoft/microsoft-graph-types-beta";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Request } from "express";
import { stringify } from "querystring";
import { z } from "zod";
import { OFFICE_365_CALENDAR_TYPE } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { OFFICE_365_CALENDAR_ID } from "@calcom/platform-constants";
@Injectable()
export class OutlookService implements OAuthCalendarApp {
private redirectUri = `${this.config.get("api.url")}/calendars/office365/save`;
constructor(
private readonly config: ConfigService,
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
) {}
async connect(
authorization: string,
req: Request
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "");
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string) {
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
const params = {
response_type: "code",
scope: scopes.join(" "),
client_id,
prompt: "select_account",
redirect_uri: this.redirectUri,
state: `accessToken=${accessToken}&origin=${origin}`,
};
const query = stringify(params);
const url = `https://login.microsoftonline.com/common/oauth2/v2.0/authorize?${query}`;
return url;
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
"office365_calendar",
userId
);
if (!office365CalendarCredentials) {
throw new BadRequestException("Credentials for office_365_calendar not found.");
}
if (office365CalendarCredentials.invalid) {
throw new BadRequestException("Invalid office 365 calendar credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const office365Calendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === OFFICE_365_CALENDAR_TYPE
);
if (!office365Calendar) {
throw new UnauthorizedException("Office 365 calendar not connected.");
}
if (office365Calendar.error?.message) {
throw new UnauthorizedException(office365Calendar.error?.message);
}
return {
status: SUCCESS_STATUS,
};
}
async getOAuthCredentials(code: string) {
const scopes = ["offline_access", "Calendars.Read", "Calendars.ReadWrite"];
const { client_id, client_secret } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const toUrlEncoded = (payload: Record<string, string>) =>
Object.keys(payload)
.map((key) => `${key}=${encodeURIComponent(payload[key])}`)
.join("&");
const body = toUrlEncoded({
client_id,
grant_type: "authorization_code",
code,
scope: scopes.join(" "),
redirect_uri: this.redirectUri,
client_secret,
});
const response = await fetch("https://login.microsoftonline.com/common/oauth2/v2.0/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
},
body,
});
const responseBody = await response.json();
return responseBody;
}
async getDefaultCalendar(accessToken: string): Promise<OfficeCalendar> {
const response = await fetch("https://graph.microsoft.com/v1.0/me/calendar", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
const responseBody = await response.json();
return responseBody as OfficeCalendar;
}
async saveCalendarCredentialsAndRedirect(code: string, accessToken: string, origin: string) {
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
if (!code) {
return { url: origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
if (!ownerId) {
throw new UnauthorizedException("Invalid Access token.");
}
const office365OAuthCredentials = await this.getOAuthCredentials(parsedCode);
const defaultCalendar = await this.getDefaultCalendar(office365OAuthCredentials.access_token);
if (defaultCalendar?.id) {
const credential = await this.credentialRepository.createAppCredential(
OFFICE_365_CALENDAR_TYPE,
office365OAuthCredentials,
ownerId
);
await this.selectedCalendarsRepository.createSelectedCalendar(
defaultCalendar.id,
credential.id,
ownerId,
OFFICE_365_CALENDAR_TYPE
);
}
return {
url: origin,
};
}
}
@@ -2,6 +2,8 @@ import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { EventTypesModule } from "@/ee/event-types/event-types.module";
import { CreateEventTypeInput } from "@/ee/event-types/inputs/create-event-type.input";
import { Editable } from "@/ee/event-types/inputs/enums/editable";
import { BaseField } from "@/ee/event-types/inputs/enums/field-type";
import { UpdateEventTypeInput } from "@/ee/event-types/inputs/update-event-type.input";
import { GetEventTypePublicOutput } from "@/ee/event-types/outputs/get-event-type-public.output";
import { GetEventTypeOutput } from "@/ee/event-types/outputs/get-event-type.output";
@@ -23,7 +25,12 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withAccessTokenAuth } from "test/utils/withAccessTokenAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { EventTypesByViewer, EventTypesPublic } from "@calcom/platform-libraries";
import {
EventTypesByViewer,
EventTypesPublic,
eventTypeBookingFields,
eventTypeLocations,
} from "@calcom/platform-libraries-0.0.2";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("Event types Endpoints", () => {
@@ -197,6 +204,77 @@ describe("Event types Endpoints", () => {
});
});
it("should update event type locations", async () => {
const locations = [{ type: "inPerson", address: "123 Main St" }];
const body: UpdateEventTypeInput = {
locations,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
const responseLocations = eventTypeLocations.parse(responseBody.data.locations);
expect(responseLocations).toBeDefined();
expect(responseLocations.length).toEqual(locations.length);
expect(responseLocations).toEqual(locations);
eventType.locations = responseLocations;
});
});
it("should update event type bookingFields", async () => {
const bookingFieldName = "location-name";
const bookingFields = [
{
name: bookingFieldName,
type: BaseField.radio,
label: "Location",
options: [
{
label: "Via Bari 10, Roma, 90119, Italy",
value: "Via Bari 10, Roma, 90119, Italy",
},
{
label: "Via Reale 28, Roma, 9001, Italy",
value: "Via Reale 28, Roma, 9001, Italy",
},
],
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: Editable.user,
required: true,
placeholder: "",
},
];
const body: UpdateEventTypeInput = {
bookingFields,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
const responseBookingFields = eventTypeBookingFields.parse(responseBody.data.bookingFields);
expect(responseBookingFields).toBeDefined();
// note(Lauris): response bookingFields are already existing default bookingFields + the new one
const responseBookingField = responseBookingFields.find((field) => field.name === bookingFieldName);
expect(responseBookingField).toEqual(bookingFields[0]);
eventType.bookingFields = responseBookingFields;
});
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
@@ -9,6 +9,7 @@ import { GetEventTypesPublicOutput } from "@/ee/event-types/outputs/get-event-ty
import { GetEventTypesData, GetEventTypesOutput } from "@/ee/event-types/outputs/get-event-types.output";
import { UpdateEventTypeOutput } from "@/ee/event-types/outputs/update-event-type.output";
import { EventTypesService } from "@/ee/event-types/services/event-types.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
@@ -33,13 +34,13 @@ import {
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent } from "@calcom/platform-libraries";
import { getEventTypesByViewer } from "@calcom/platform-libraries";
import { getPublicEvent } from "@calcom/platform-libraries-0.0.2";
import { getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import { PrismaClient } from "@calcom/prisma";
@Controller({
path: "event-types",
version: "2",
path: "/v2/event-types",
version: API_VERSIONS_VALUES,
})
@UseGuards(PermissionsGuard)
@DocsTags("Event types")
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.2";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
@@ -275,11 +275,11 @@ export class UpdateEventTypeInput {
// @IsOptional()
// parentId?: number;
// @IsOptional()
// @IsArray()
// @ValidateNested({ each: true })
// @Type(() => BookingField)
// bookingFields?: BookingField[];
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => BookingField)
bookingFields?: BookingField[];
// @IsString()
// @IsOptional()
@@ -9,8 +9,8 @@ import { SelectedCalendarsRepository } from "@/modules/selected-calendars/select
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.2";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.2";
import { EventType } from "@calcom/prisma/client";
@Injectable()
+3 -2
View File
@@ -2,6 +2,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GcalAuthUrlOutput } from "@/ee/gcal/outputs/auth-url.output";
import { GcalCheckOutput } from "@/ee/gcal/outputs/check.output";
import { GcalSaveRedirectOutput } from "@/ee/gcal/outputs/save-redirect.output";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GCalService } from "@/modules/apps/services/gcal.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
@@ -40,8 +41,8 @@ const CALENDAR_SCOPES = [
// Controller for the GCalConnect Atom
@Controller({
path: "/gcal",
version: "2",
path: "/v2/gcal",
version: API_VERSIONS_VALUES,
})
@DocsTags("Google Calendar")
export class GcalController {
+3 -2
View File
@@ -1,6 +1,7 @@
import { GetMeOutput } from "@/ee/me/outputs/get-me.output";
import { UpdateMeOutput } from "@/ee/me/outputs/update-me.output";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
@@ -14,8 +15,8 @@ import { PROFILE_READ, PROFILE_WRITE, SUCCESS_STATUS } from "@calcom/platform-co
import { userSchemaResponse } from "@calcom/platform-types";
@Controller({
path: "/me",
version: "2",
path: "/v2/me",
version: API_VERSIONS_VALUES,
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@DocsTags("Me")
@@ -1,5 +1,6 @@
import { ProviderVerifyAccessTokenOutput } from "@/ee/provider/outputs/verify-access-token.output";
import { ProviderVerifyClientOutput } from "@/ee/provider/outputs/verify-client.output";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
@@ -20,8 +21,8 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/provider",
version: "2",
path: "/v2/provider",
version: API_VERSIONS_VALUES,
})
@DocsTags("Cal provider")
export class CalProviderController {
@@ -5,6 +5,7 @@ import { GetScheduleOutput } from "@/ee/schedules/outputs/get-schedule.output";
import { GetSchedulesOutput } from "@/ee/schedules/outputs/get-schedules.output";
import { UpdateScheduleOutput } from "@/ee/schedules/outputs/update-schedule.output";
import { SchedulesService } from "@/ee/schedules/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
@@ -31,8 +32,8 @@ import { UpdateScheduleInput } from "@calcom/platform-types";
import { CreateScheduleInput } from "../inputs/create-schedule.input";
@Controller({
path: "schedules",
version: "2",
path: "/v2/schedules",
version: API_VERSIONS_VALUES,
})
@UseGuards(AccessTokenGuard, PermissionsGuard)
@DocsTags("Schedules")
@@ -7,13 +7,13 @@ import { BadRequestException, ForbiddenException, Injectable, NotFoundException
import { Schedule } from "@prisma/client";
import { User } from "@prisma/client";
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries";
import { updateScheduleHandler } from "@calcom/platform-libraries";
import type { ScheduleWithAvailabilities } from "@calcom/platform-libraries-0.0.2";
import { updateScheduleHandler } from "@calcom/platform-libraries-0.0.2";
import {
transformWorkingHoursForClient,
transformAvailabilityForClient,
transformDateOverridesForClient,
} from "@calcom/platform-libraries";
} from "@calcom/platform-libraries-0.0.2";
import { UpdateScheduleInput } from "@calcom/platform-types";
@Injectable()
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries";
import { TRPCError } from "@calcom/platform-libraries-0.0.2";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
+7
View File
@@ -0,0 +1,7 @@
import { VersionValue } from "@nestjs/common/interfaces";
import { API_VERSIONS, VERSION_2024_04_15, VERSION_2024_05_21 } from "@calcom/platform-constants";
export const API_VERSIONS_VALUES: VersionValue = API_VERSIONS as unknown as VersionValue;
export const VERSION_2024_05_21_VALUE: VersionValue = VERSION_2024_05_21 as unknown as VersionValue;
export const VERSION_2024_04_15_VALUE: VersionValue = VERSION_2024_04_15 as unknown as VersionValue;
@@ -0,0 +1,61 @@
import { plainToClass } from "class-transformer";
import { IsOptional, IsString } from "class-validator";
import { CapitalizeTimeZone } from "./capitalize-timezone";
class TestDto {
@IsOptional()
@IsString()
@CapitalizeTimeZone()
timeZone?: string;
}
describe("CapitalizeTimeZone", () => {
it("should capitalize single part time zone correctly", () => {
const input = { timeZone: "egypt" };
const output = plainToClass(TestDto, input);
expect(output.timeZone).toBe("Egypt");
});
it("should capitalize one-part time zone correctly", () => {
const input = { timeZone: "europe/rome" };
const output = plainToClass(TestDto, input);
expect(output.timeZone).toBe("Europe/Rome");
});
it("should capitalize multi-part time zone correctly", () => {
const input = { timeZone: "america/new_york" };
const output = plainToClass(TestDto, input);
expect(output.timeZone).toBe("America/New_York");
});
it("should capitalize complex time zone correctly", () => {
const input = { timeZone: "europe/isle_of_man" };
const output = plainToClass(TestDto, input);
expect(output.timeZone).toBe("Europe/Isle_Of_Man");
});
it("should handle already capitalized time zones correctly", () => {
const input = { timeZone: "Asia/Tokyo" };
const output = plainToClass(TestDto, input);
expect(output.timeZone).toBe("Asia/Tokyo");
});
it("should handle missing time zone correctly", () => {
const input = {};
const output = plainToClass(TestDto, input);
expect(output.timeZone).toBeUndefined();
});
it("should capitalize EST at the end of the string", () => {
const input = { email: "test@example.com", timeZone: "utc/est" };
const output = plainToClass(TestDto, input);
expect(output.timeZone).toBe("UTC/EST");
});
it("should capitalize UTC when surrounded by non-alphabetical characters", () => {
const input = { email: "test@example.com", timeZone: "utc/gmt+3_est" };
const output = plainToClass(TestDto, input);
expect(output.timeZone).toBe("UTC/GMT+3_EST");
});
});
@@ -0,0 +1,28 @@
import { Transform } from "class-transformer";
export function CapitalizeTimeZone(): PropertyDecorator {
return Transform(({ value }) => {
if (typeof value === "string") {
const parts = value.split("/");
const normalizedParts = parts.map((part) =>
part
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("_")
);
let normalizedTimeZone = normalizedParts.join("/");
// note(Lauris): regex matching GMT, EST, UTC at the start, end, or surrounded by non-letters and capitalizing them
const specialCases = ["GMT", "EST", "UTC"];
specialCases.forEach((specialCase) => {
const regex = new RegExp(`(^|[^a-zA-Z])(${specialCase})([^a-zA-Z]|$)`, "gi");
normalizedTimeZone = normalizedTimeZone.replace(regex, (match, p1, p2, p3) => {
return `${p1}${specialCase}${p3}`;
});
});
return normalizedTimeZone;
}
return value;
});
}
@@ -1,4 +1,5 @@
import { AppConfig } from "@/config/type";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
@@ -29,8 +30,8 @@ import { Stripe } from "stripe";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/billing",
version: "2",
path: "/v2/billing",
version: API_VERSIONS_VALUES,
})
@ApiExcludeController(true)
export class BillingController {
@@ -122,7 +123,7 @@ export class BillingController {
await this.billingService.setSubscriptionForTeam(
teamId,
subscription,
PlatformPlan[plan as keyof typeof PlatformPlan]
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
return {
@@ -11,7 +11,7 @@ export class BillingConfigService {
const planKeys = Object.keys(PlatformPlan).filter((key) => isNaN(Number(key)));
for (const key of planKeys) {
this.config.set(
PlatformPlan[key as keyof typeof PlatformPlan],
PlatformPlan[key.toUpperCase() as keyof typeof PlatformPlan],
process.env[`STRIPE_PRICE_ID_${key}`] ?? ""
);
}
@@ -1,3 +1,4 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -33,8 +34,8 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { Pagination } from "@calcom/platform-types";
@Controller({
path: "oauth-clients/:clientId/users",
version: "2",
path: "/v2/oauth-clients/:clientId/users",
version: API_VERSIONS_VALUES,
})
@UseGuards(OAuthClientCredentialsGuard)
@DocsTags("Managed users")
@@ -1,4 +1,5 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
@@ -46,8 +47,8 @@ const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-
Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`;
@Controller({
path: "oauth-clients",
version: "2",
path: "/v2/oauth-clients",
version: API_VERSIONS_VALUES,
})
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@DocsExcludeController(getEnv("NODE_ENV") === "production")
@@ -1,4 +1,5 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
@@ -33,8 +34,8 @@ import { Response as ExpressResponse } from "express";
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@Controller({
path: "oauth/:clientId",
version: "2",
path: "/v2/oauth/:clientId",
version: API_VERSIONS_VALUES,
})
@DocsExcludeController(getEnv("NODE_ENV") === "production")
@DocsTags("OAuth - development only")
@@ -7,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.2";
@Injectable()
export class OAuthClientUsersService {
@@ -1,3 +1,4 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { AccessTokenGuard } from "@/modules/auth/guards/access-token/access-token.guard";
import { SlotsService } from "@/modules/slots/services/slots.service";
import { Query, Body, Controller, Get, Delete, Post, Req, Res, UseGuards } from "@nestjs/common";
@@ -5,14 +6,14 @@ import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { getAvailableSlots } from "@calcom/platform-libraries";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import { getAvailableSlots } from "@calcom/platform-libraries-0.0.2";
import type { AvailableSlotsType } from "@calcom/platform-libraries-0.0.2";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
import { ApiResponse, GetAvailableSlotsInput } from "@calcom/platform-types";
@Controller({
path: "slots",
version: "2",
path: "/v2/slots",
version: API_VERSIONS_VALUES,
})
@DocsTags("Slots")
export class SlotsController {
@@ -3,7 +3,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { DateTime } from "luxon";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries";
import { MINUTES_TO_BOOK } from "@calcom/platform-libraries-0.0.2";
import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
@@ -1,14 +1,15 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
import { Controller, Get } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { CityTimezones } from "@calcom/platform-libraries";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.2";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "timezones",
version: "2",
path: "/v2/timezones",
version: API_VERSIONS_VALUES,
})
@DocsTags("Timezones")
export class TimezonesController {
@@ -1,8 +1,8 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { cityTimezonesHandler } from "@calcom/platform-libraries";
import type { CityTimezones } from "@calcom/platform-libraries";
import { cityTimezonesHandler } from "@calcom/platform-libraries-0.0.2";
import type { CityTimezones } from "@calcom/platform-libraries-0.0.2";
@Injectable()
export class TimezonesService {
@@ -1,3 +1,4 @@
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsTimeZone, IsString } from "class-validator";
@@ -26,6 +27,7 @@ export class CreateManagedUserInput {
@IsTimeZone()
@IsOptional()
@CapitalizeTimeZone()
@ApiProperty({ example: "America/New_York" })
timeZone?: string;
}
+125 -6
View File
@@ -1214,6 +1214,113 @@
]
}
},
"/v2/calendars/{calendar}/connect": {
"get": {
"operationId": "CalendarsController_redirect",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Calendars"
]
}
},
"/v2/calendars/{calendar}/save": {
"get": {
"operationId": "CalendarsController_save",
"parameters": [
{
"name": "state",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "code",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Calendars"
]
}
},
"/v2/calendars/{calendar}/check": {
"get": {
"operationId": "CalendarsController_check",
"parameters": [
{
"name": "calendar",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
}
}
},
"tags": [
"Calendars"
]
}
},
"/v2/bookings": {
"get": {
"operationId": "BookingsController_getBookings",
@@ -2104,17 +2211,20 @@
"type": "boolean"
},
"slotInterval": {
"type": "number"
"type": "number",
"minimum": 0
},
"minimumBookingNotice": {
"type": "number",
"minimum": 0
},
"beforeEventBuffer": {
"type": "number"
"type": "number",
"minimum": 0
},
"afterEventBuffer": {
"type": "number"
"type": "number",
"minimum": 0
}
},
"required": [
@@ -2798,6 +2908,12 @@
"$ref": "#/components/schemas/EventTypeLocation"
}
},
"bookingFields": {
"type": "array",
"items": {
"$ref": "#/components/schemas/BookingField"
}
},
"disableGuests": {
"type": "boolean"
},
@@ -2806,13 +2922,16 @@
"minimum": 0
},
"beforeEventBuffer": {
"type": "number"
"type": "number",
"minimum": 0
},
"afterEventBuffer": {
"type": "number"
"type": "number",
"minimum": 0
},
"slotInterval": {
"type": "number"
"type": "number",
"minimum": 0
}
}
},
+8 -13
View File
@@ -15,7 +15,6 @@
"paths": {
"@/*": ["./src/*"],
"@prisma/client/*": ["@calcom/prisma/client/*"],
"@calcom/platform-libraries": ["../../../packages/platform/libraries/index.ts"],
"@calcom/platform-constants": ["../../../packages/platform/constants/index.ts"],
"@calcom/platform-types": ["../../../packages/platform/types/index.ts"],
"@calcom/platform-utils": ["../../../packages/platform/utils/index.ts"]
@@ -27,17 +26,13 @@
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
},
"watchOptions": {
"watchFile": "fixedPollingInterval",
"watchDirectory": "useFsEvents",
"fallbackPolling": "dynamicPriority",
"synchronousWatchDirectory": true,
"excludeDirectories": ["**/node_modules", "dist"]
},
"exclude": ["./dist", "./node_modules", "next-i18next.config.js"],
"include": [
"./**/*.ts",
"../../../packages/types/*.d.ts",
"../../../packages/platform/libraries/**/*.ts",
"../../../packages/platform/libraries/**/*.d.ts",
"../../../packages/platform/constants/**/*.ts",
"../../../packages/platform/constants/**/*.d.ts",
"../../../packages/platform/types/**/*.ts",
"../../../packages/platform/types/**/*.d.ts",
"../../../packages/platform/utils/**/*.ts",
"../../../packages/platform/utils/**/*.d.ts"
]
"include": ["./**/*.ts", "../../../packages/types/*.d.ts"]
}
@@ -1,6 +1,5 @@
"use client";
import type { GetStaticPropsContext } from "next";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
@@ -11,14 +10,11 @@ import {
} from "@calcom/features/ee/organizations/lib/orgDomains";
import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HeadSeo } from "@calcom/ui";
import { Icon } from "@calcom/ui";
import { HeadSeo, Icon } from "@calcom/ui";
import { Discord } from "@calcom/ui/components/icon/Discord";
import PageWrapper from "@components/PageWrapper";
import { getTranslations } from "@server/lib/getTranslations";
enum pageType {
ORG = "org",
TEAM = "team",
@@ -271,13 +267,3 @@ export default function Custom404() {
}
Custom404.PageWrapper = PageWrapper;
export const getStaticProps = async (context: GetStaticPropsContext) => {
const i18n = await getTranslations(context);
return {
props: {
i18n,
},
};
};
-64
View File
@@ -1,64 +0,0 @@
"use client";
/**
* Typescript class based component for custom-error
* @link https://nextjs.org/docs/advanced-features/custom-error-page
*/
import type { NextPage } from "next";
import type { ErrorProps } from "next/error";
import React from "react";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { redactError } from "@calcom/lib/redactError";
import { ErrorPage } from "@components/error/error-page";
type NextError = Error & { digest?: string };
// Ref: https://nextjs.org/docs/app/api-reference/file-conventions/error#props
export type DefaultErrorProps = {
error: NextError;
reset: () => void; // A function to reset the error boundary
};
type AugmentedError = NextError | HttpError | null;
type CustomErrorProps = {
err?: AugmentedError;
statusCode?: number;
message?: string;
} & Omit<ErrorProps, "err" | "statusCode">;
const log = logger.getSubLogger({ prefix: ["[error]"] });
const CustomError: NextPage<DefaultErrorProps> = (props) => {
const { error } = props;
let errorObject: CustomErrorProps = {
message: error.message,
err: error,
};
if (error instanceof HttpError) {
const redactedError = redactError(error);
errorObject = {
statusCode: error.statusCode,
title: redactedError.name,
message: redactedError.message,
err: {
...redactedError,
...error,
},
};
}
// `error.digest` property contains an automatically generated hash of the error that can be used to match the corresponding error in server-side logs
log.debug(`${error?.toString() ?? JSON.stringify(error)}`);
log.info("errorObject: ", errorObject);
return (
<ErrorPage statusCode={errorObject.statusCode} error={errorObject.err} message={errorObject.message} />
);
};
export default CustomError;
-17
View File
@@ -1,17 +0,0 @@
"use client";
import { type NextPage } from "next";
import CustomError, { type DefaultErrorProps } from "./error";
export const GlobalError: NextPage<DefaultErrorProps> = (props) => {
return (
<html>
<body>
<CustomError {...props} />
</body>
</html>
);
};
export default GlobalError;
+3 -2
View File
@@ -1,9 +1,10 @@
import NotFoundPage from "@pages/404";
import { WithLayout } from "app/layoutHOC";
import type { GetStaticPropsContext } from "next";
import { getTranslations } from "@server/lib/getTranslations";
import NotFoundPage from "./404/page";
import { WithLayout } from "./layoutHOC";
const getData = async (context: GetStaticPropsContext) => {
const i18n = await getTranslations(context);
+12
View File
@@ -7,6 +7,7 @@ import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/comp
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import type { App as AppType } from "@calcom/types/App";
@@ -21,6 +22,7 @@ export type AppPageProps = {
isGlobal?: AppType["isGlobal"];
logo: string;
slug: string;
dirName: string | undefined;
variant: string;
body: React.ReactNode;
categories: string[];
@@ -68,8 +70,11 @@ export const AppPage = ({
dependencies,
concurrentMeetings,
paid,
dirName,
}: AppPageProps) => {
const { t, i18n } = useLocale();
const searchParams = useCompatSearchParams();
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
const mutation = useAddAppMutation(null, {
@@ -118,6 +123,11 @@ export const AppPage = ({
// variant not other allows, an app to be shown in calendar category without requiring an actual calendar connection e.g. vimcal
// Such apps, can only be installed once.
const allowedMultipleInstalls = categories.indexOf("calendar") > -1 && variant !== "other";
useEffect(() => {
if (searchParams?.get("defaultInstall") === "true") {
mutation.mutate({ type, variant, slug, defaultInstall: true });
}
}, []);
return (
<div className="relative flex-1 flex-col items-start justify-start px-4 md:flex md:px-8 lg:flex-row lg:px-0">
@@ -223,6 +233,7 @@ export const AppPage = ({
multiInstall
concurrentMeetings={concurrentMeetings}
paid={paid}
dirName={dirName}
{...props}
/>
);
@@ -262,6 +273,7 @@ export const AppPage = ({
credentials={appDbQuery.data?.credentials}
concurrentMeetings={concurrentMeetings}
paid={paid}
dirName={dirName}
{...props}
/>
);
@@ -1,7 +1,14 @@
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { doesAppSupportTeamInstall } from "@calcom/app-store/utils";
import { Spinner } from "@calcom/features/calendars/weeklyview/components/spinner/Spinner";
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -26,7 +33,9 @@ export const InstallAppButtonChild = ({
multiInstall,
credentials,
concurrentMeetings,
dirName,
paid,
onClick,
...props
}: {
userAdminTeams?: UserAdminTeams;
@@ -36,8 +45,10 @@ export const InstallAppButtonChild = ({
credentials?: RouterOutputs["viewer"]["appCredentialsByType"]["credentials"];
concurrentMeetings?: boolean;
paid?: AppFrontendPayload["paid"];
dirName: string | undefined;
} & ButtonProps) => {
const { t } = useLocale();
const router = useRouter();
const mutation = useAddAppMutation(null, {
onSuccess: (data) => {
@@ -49,6 +60,18 @@ export const InstallAppButtonChild = ({
},
});
const shouldDisableInstallation = !multiInstall ? !!(credentials && credentials.length) : false;
const appMetadata = appStoreMetadata[dirName as keyof typeof appStoreMetadata];
const redirectToAppOnboarding = useMemo(() => shouldRedirectToAppOnboarding(appMetadata), [appMetadata]);
const _onClick = (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
if (redirectToAppOnboarding) {
router.push(
getAppOnboardingUrl({ slug: addAppMutationInput.slug, step: AppOnboardingSteps.ACCOUNTS_STEP })
);
} else if (onClick) {
onClick(e);
}
};
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
@@ -56,6 +79,7 @@ export const InstallAppButtonChild = ({
return (
<Button
data-testid="install-app-button"
onClick={_onClick}
{...props}
disabled={shouldDisableInstallation}
color="primary"
@@ -72,6 +96,7 @@ export const InstallAppButtonChild = ({
return (
<Button
data-testid="install-app-button"
onClick={_onClick}
{...props}
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
@@ -83,6 +108,19 @@ export const InstallAppButtonChild = ({
);
}
if (redirectToAppOnboarding) {
return (
<Button
data-testid="install-app-button"
disabled={shouldDisableInstallation}
onClick={_onClick}
color="primary"
size="base"
{...props}>
{multiInstall ? t("install_another") : t("install_app")}
</Button>
);
}
return (
<Dropdown>
<DropdownMenuTrigger asChild>
@@ -0,0 +1,97 @@
import type { FC } from "react";
import React, { useState } from "react";
import { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Team, User } from "@calcom/prisma/client";
import { Avatar, StepCard } from "@calcom/ui";
type AccountSelectorProps = {
avatar?: string;
name: string;
alreadyInstalled: boolean;
onClick: () => void;
loading: boolean;
testId: string;
};
const AccountSelector: FC<AccountSelectorProps> = ({
avatar,
alreadyInstalled,
name,
onClick,
loading,
testId,
}) => {
const { t } = useLocale();
const [selected, setSelected] = useState(false);
return (
<div
className={classNames(
"hover:bg-muted flex cursor-pointer flex-row items-center gap-2 p-1",
(alreadyInstalled || loading) && "cursor-not-allowed",
selected && "bg-muted animate-pulse"
)}
data-testid={testId}
onClick={() => {
if (!alreadyInstalled && !loading) {
setSelected(true);
onClick();
}
}}>
<Avatar
alt={avatar || ""}
imageSrc={avatar || `${CAL_URL}/${avatar}`} // if no image, use default avatar
size="sm"
/>
<div className="text-md pt-0.5 font-medium text-gray-500">
{name}
{alreadyInstalled ? <span className="ml-1 text-sm text-gray-400">{t("already_installed")}</span> : ""}
</div>
</div>
);
};
export type PersonalAccountProps = Pick<User, "id" | "avatar" | "name"> & { alreadyInstalled: boolean };
export type TeamsProp = (Pick<Team, "id" | "name" | "logo"> & {
alreadyInstalled: boolean;
})[];
type AccountStepCardProps = {
teams: TeamsProp;
personalAccount: PersonalAccountProps;
onSelect: (id?: number) => void;
loading: boolean;
};
export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAccount, onSelect, loading }) => {
const { t } = useLocale();
return (
<StepCard>
<div className="text-sm font-medium text-gray-400">{t("install_app_on")}</div>
<div className={classNames("mt-2 flex flex-col gap-2 ")}>
<AccountSelector
testId="install-app-button-personal"
avatar={personalAccount.avatar ?? ""}
name={personalAccount.name ?? ""}
alreadyInstalled={personalAccount.alreadyInstalled}
onClick={() => onSelect()}
loading={loading}
/>
{teams.map((team) => (
<AccountSelector
key={team.id}
testId={`install-app-button-team${team.id}`}
alreadyInstalled={team.alreadyInstalled}
avatar={team.logo ?? ""}
name={team.name}
onClick={() => onSelect(team.id)}
loading={loading}
/>
))}
</div>
</StepCard>
);
};
@@ -0,0 +1,223 @@
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
import { X } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
import type { FC } from "react";
import React, { forwardRef, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useForm } from "react-hook-form";
import type { z } from "zod";
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { Button, Form } from "@calcom/ui";
import useAppsData from "@lib/hooks/useAppsData";
type TFormType = {
metadata: z.infer<typeof EventTypeMetaDataSchema>;
};
type ConfigureStepCardProps = {
slug: string;
userName: string;
categories: AppCategories[];
credentialId?: number;
loading?: boolean;
formPortalRef: React.RefObject<HTMLDivElement>;
eventTypes: TEventType[] | undefined;
setConfigureStep: Dispatch<SetStateAction<boolean>>;
handleSetUpLater: () => void;
};
type EventTypeAppSettingsFormProps = Pick<
ConfigureStepCardProps,
"slug" | "userName" | "categories" | "credentialId" | "loading"
> & {
eventType: TEventType;
handleDelete: () => void;
onSubmit: (values: z.infer<typeof EventTypeMetaDataSchema>) => void;
};
type EventTypeAppSettingsWrapperProps = Pick<
ConfigureStepCardProps,
"slug" | "userName" | "categories" | "credentialId"
> & {
eventType: TEventType;
};
const EventTypeAppSettingsWrapper: FC<EventTypeAppSettingsWrapperProps> = ({
slug,
eventType,
categories,
credentialId,
}) => {
const { getAppDataGetter, getAppDataSetter } = useAppsData();
useEffect(() => {
const appDataSetter = getAppDataSetter(slug as EventTypeAppsList, categories, credentialId);
appDataSetter("enabled", true);
}, []);
return (
<EventTypeAppSettings
slug={slug}
eventType={eventType}
getAppData={getAppDataGetter(slug as EventTypeAppsList)}
setAppData={getAppDataSetter(slug as EventTypeAppsList, categories, credentialId)}
/>
);
};
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
function EventTypeAppSettingsForm(props, ref) {
const { handleDelete, onSubmit, eventType, loading } = props;
const formMethods = useForm<TFormType>({
defaultValues: {
metadata: eventType?.metadata,
},
});
return (
<Form
form={formMethods}
id={`eventtype-${eventType.id}`}
handleSubmit={() => {
const data = formMethods.getValues("metadata");
onSubmit(data);
}}>
<div>
<div className="sm:border-subtle bg-default relative border p-4 dark:bg-black sm:rounded-md">
<div>
<span className="text-default font-semibold ltr:mr-1 rtl:ml-1">{eventType.title}</span>{" "}
<small className="text-subtle hidden font-normal sm:inline">
/{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug}
</small>
</div>
<EventTypeAppSettingsWrapper {...props} />
<X
data-testid={`remove-event-type-${eventType.id}`}
className="absolute right-4 top-4 h-4 w-4 cursor-pointer"
onClick={() => !loading && handleDelete()}
/>
<button type="submit" className="hidden" ref={ref}>
Save
</button>
</div>
</div>
</Form>
);
}
);
export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
loading,
formPortalRef,
eventTypes,
setConfigureStep,
handleSetUpLater,
...props
}) => {
const { t } = useLocale();
const { control, getValues } = useFormContext<TEventTypesForm>();
const { fields, update } = useFieldArray({
control,
name: "eventTypes",
keyName: "fieldId",
});
const submitRefs = useRef<Array<React.RefObject<HTMLButtonElement>>>([]);
submitRefs.current = fields.map(
(_ref, index) => (submitRefs.current[index] = React.createRef<HTMLButtonElement>())
);
const mainForSubmitRef = useRef<HTMLButtonElement>(null);
const [updatedEventTypesStatus, setUpdatedEventTypesStatus] = useState(
fields.filter((field) => field.selected).map((field) => ({ id: field.id, updated: false }))
);
const [submit, setSubmit] = useState(false);
const allUpdated = updatedEventTypesStatus.every((item) => item.updated);
useEffect(() => {
setUpdatedEventTypesStatus((prev) =>
prev.filter((state) => fields.some((field) => field.id === state.id && field.selected))
);
if (!fields.some((field) => field.selected)) {
setConfigureStep(false);
}
}, [fields]);
useEffect(() => {
if (submit && allUpdated && mainForSubmitRef.current) {
mainForSubmitRef.current?.click();
setSubmit(false);
}
}, [submit, allUpdated, getValues, mainForSubmitRef]);
return (
formPortalRef?.current &&
createPortal(
<div className="mt-8">
<div className="flex flex-col space-y-6">
{fields.map((field, index) => {
return (
field.selected && (
<EventTypeAppSettingsForm
key={field.fieldId}
eventType={field}
loading={loading}
handleDelete={() => {
const eventMetadataDb = eventTypes?.find(
(eventType) => eventType.id == field.id
)?.metadata;
update(index, { ...field, selected: false, metadata: eventMetadataDb });
}}
onSubmit={(data) => {
update(index, { ...field, metadata: data });
setUpdatedEventTypesStatus((prev) =>
prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item))
);
}}
ref={submitRefs.current[index]}
{...props}
/>
)
);
})}
</div>
<button form="outer-event-type-form" type="submit" className="hidden" ref={mainForSubmitRef}>
Save
</button>
<Button
className="text-md mt-6 w-full justify-center"
type="button"
data-testid="configure-step-save"
onClick={() => {
submitRefs.current.reverse().map((ref) => ref.current?.click());
setSubmit(true);
}}
loading={loading}>
{t("save")}
</Button>
<div className="flex w-full flex-row justify-center">
<Button
color="minimal"
data-testid="set-up-later"
onClick={(event) => {
event.preventDefault();
handleSetUpLater();
}}
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
{t("set_up_later")}
</Button>
</div>
</div>,
formPortalRef?.current
)
);
};
@@ -0,0 +1,130 @@
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
import type { Dispatch, SetStateAction } from "react";
import type { FC } from "react";
import React from "react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { ScrollableArea, Badge, Button } from "@calcom/ui";
type EventTypesCardProps = {
userName: string;
setConfigureStep: Dispatch<SetStateAction<boolean>>;
handleSetUpLater: () => void;
};
export const EventTypesStepCard: FC<EventTypesCardProps> = ({
setConfigureStep,
userName,
handleSetUpLater,
}) => {
const { t } = useLocale();
const { control } = useFormContext<TEventTypesForm>();
const { fields, update } = useFieldArray({
control,
name: "eventTypes",
keyName: "fieldId",
});
return (
<div>
<div className="sm:border-subtle bg-default mt-10 border dark:bg-black sm:rounded-md">
<ScrollableArea className="rounded-md">
<ul className="border-subtle max-h-97 !static w-full divide-y">
{fields.map((field, index) => (
<EventTypeCard
handleSelect={() => update(index, { ...field, selected: !field.selected })}
userName={userName}
key={field.fieldId}
{...field}
/>
))}
</ul>
</ScrollableArea>
</div>
<Button
className="text-md mt-6 w-full justify-center"
data-testid="save-event-types"
onClick={() => {
setConfigureStep(true);
}}
disabled={!fields.some((field) => field.selected === true)}>
{t("save")}
</Button>
<div className="flex w-full flex-row justify-center">
<Button
color="minimal"
data-testid="set-up-later"
onClick={(event) => {
event.preventDefault();
handleSetUpLater();
}}
className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium">
{t("set_up_later")}
</Button>
</div>
</div>
);
};
type EventTypeCardProps = TEventType & { userName: string; handleSelect: () => void };
const EventTypeCard: FC<EventTypeCardProps> = ({
title,
description,
id,
metadata,
length,
selected,
slug,
handleSelect,
team,
userName,
}) => {
const parsedMetaData = EventTypeMetaDataSchema.safeParse(metadata);
const durations =
parsedMetaData.success &&
parsedMetaData.data?.multipleDuration &&
Boolean(parsedMetaData.data?.multipleDuration.length)
? [length, ...parsedMetaData.data?.multipleDuration?.filter((duration) => duration !== length)].sort()
: [length];
return (
<div
data-testid={`select-event-type-${id}`}
className="hover:bg-muted min-h-20 box-border flex w-full cursor-pointer select-none items-center space-x-4 px-4 py-3"
onClick={() => handleSelect()}>
<input
id={`${id}`}
checked={selected}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
/>
<label htmlFor={`${id}`} className="cursor-pointer text-sm">
<li>
<div>
<span className="text-default font-semibold ltr:mr-1 rtl:ml-1">{title}</span>{" "}
<small className="text-subtle hidden font-normal sm:inline">
/{team ? team.slug : userName}/{slug}
</small>
</div>
{Boolean(description) && (
<div className="text-subtle line-clamp-4 break-words text-sm sm:max-w-[650px] [&>*:not(:first-child)]:hidden [&_a]:text-blue-500 [&_a]:underline [&_a]:hover:text-blue-600">
{description}
</div>
)}
<div className="mt-2 flex flex-row flex-wrap gap-2">
{Boolean(durations.length) &&
durations.map((duration) => (
<Badge key={`event-type-${id}-duration-${duration}`} variant="gray" startIcon="clock">
{duration}m
</Badge>
))}
</div>
</li>
</label>
</div>
);
};
@@ -0,0 +1,19 @@
import type { FC, ReactNode } from "react";
type StepHeaderProps = {
children?: ReactNode;
title: string;
subtitle: string;
};
export const StepHeader: FC<StepHeaderProps> = ({ children, title, subtitle }) => {
return (
<div>
<header>
<p className="font-cal mb-3 text-[28px] font-medium capitalize leading-7">{title}</p>
<p className="text-subtle font-sans text-sm font-normal">{subtitle}</p>
</header>
{children}
</div>
);
};
+243 -16
View File
@@ -1,5 +1,6 @@
import Link from "next/link";
import { useState } from "react";
import { useForm, Controller, useFieldArray } from "react-hook-form";
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
import {
@@ -15,6 +16,7 @@ import classNames from "@calcom/lib/classNames";
import { formatTime } from "@calcom/lib/date-fns";
import getPaymentAppData from "@calcom/lib/getPaymentAppData";
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
import { useCopy } from "@calcom/lib/hooks/useCopy";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getEveryFreqFor } from "@calcom/lib/recurringStrings";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -35,6 +37,14 @@ import {
TableActions,
TextAreaField,
Tooltip,
Dropdown,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
} from "@calcom/ui";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
@@ -87,7 +97,7 @@ function BookingListItem(booking: BookingItemProps) {
});
const isUpcoming = new Date(booking.endTime) >= new Date();
const isPast = new Date(booking.endTime) < new Date();
const isBookingInPast = new Date(booking.endTime) < new Date();
const isCancelled = booking.status === BookingStatus.CANCELLED;
const isConfirmed = booking.status === BookingStatus.ACCEPTED;
const isRejected = booking.status === BookingStatus.REJECTED;
@@ -220,7 +230,7 @@ function BookingListItem(booking: BookingItemProps) {
bookedActions = bookedActions.filter((action) => action.id !== "edit_booking");
}
if (isPast && isPending && !isConfirmed) {
if (isBookingInPast && isPending && !isConfirmed) {
bookedActions = bookedActions.filter((action) => action.id !== "cancel");
}
@@ -278,9 +288,9 @@ function BookingListItem(booking: BookingItemProps) {
const title = booking.title;
const showViewRecordingsButton = !!(booking.isRecorded && isPast && isConfirmed);
const showViewRecordingsButton = !!(booking.isRecorded && isBookingInPast && isConfirmed);
const showCheckRecordingButton =
isPast &&
isBookingInPast &&
isConfirmed &&
!booking.isRecorded &&
(!booking.location || booking.location === "integrations:daily" || booking?.location?.trim() === "");
@@ -298,7 +308,14 @@ function BookingListItem(booking: BookingItemProps) {
];
const showPendingPayment = paymentAppData.enabled && booking.payment.length && !booking.paid;
const attendeeList = booking.attendees.map((attendee) => {
return {
name: attendee.name,
email: attendee.email,
id: attendee.id,
noShow: attendee.noShow || false,
};
});
return (
<>
<RescheduleDialog
@@ -436,7 +453,7 @@ function BookingListItem(booking: BookingItemProps) {
</div>
</Link>
</td>
<td className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<td data-testid="title-and-attendees" className={`w-full px-4${isRejected ? " line-through" : ""}`}>
<Link href={bookingLink}>
{/* Time and Badges for mobile */}
<div className="w-full pb-2 pt-4 sm:hidden">
@@ -507,9 +524,11 @@ function BookingListItem(booking: BookingItemProps) {
)}
{booking.attendees.length !== 0 && (
<DisplayAttendees
attendees={booking.attendees}
attendees={attendeeList}
user={booking.user}
currentEmail={userEmail}
bookingUid={booking.uid}
isBookingInPast={isBookingInPast}
/>
)}
{isCancelled && booking.rescheduled && (
@@ -528,7 +547,7 @@ function BookingListItem(booking: BookingItemProps) {
{isRejected && <div className="text-subtle text-sm">{t("rejected")}</div>}
</>
) : null}
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
{isBookingInPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
{(showViewRecordingsButton || showCheckRecordingButton) && (
<TableActions actions={showRecordingActions} />
)}
@@ -654,13 +673,215 @@ const FirstAttendee = ({
type AttendeeProps = {
name?: string;
email: string;
id: number;
noShow: boolean;
};
const Attendee = ({ email, name }: AttendeeProps) => {
type NoShowProps = {
bookingUid: string;
isBookingInPast: boolean;
};
const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
const { email, name, bookingUid, isBookingInPast, noShow: noShowAttendee } = attendeeProps;
const { t } = useLocale();
const [noShow, setNoShow] = useState(noShowAttendee);
const [openDropdown, setOpenDropdown] = useState(false);
const { copyToClipboard, isCopied } = useCopy();
const noShowMutation = trpc.viewer.public.noShow.useMutation({
onSuccess: async () => {
showToast(
t(noShow ? "x_marked_as_no_show" : "x_unmarked_as_no_show", {
x: name || email,
}),
"success"
);
},
onError: (err) => {
showToast(err.message, "error");
},
});
function toggleNoShow({
attendee,
bookingUid,
}: {
attendee: { email: string; noShow: boolean };
bookingUid: string;
}) {
noShowMutation.mutate({ bookingUid, attendees: [attendee] });
setNoShow(!noShow);
}
return (
<a className="hover:text-blue-500" href={`mailto:${email}`} onClick={(e) => e.stopPropagation()}>
{name || email}
</a>
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
<button
data-testid="guest"
onClick={(e) => e.stopPropagation()}
className="radix-state-open:text-blue-500 hover:text-blue-500">
{noShow ? (
<s>
{name || email} <Icon name="eye-off" className="inline h-4" />
</s>
) : (
<>{name || email}</>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon="mail"
href={`mailto:${email}`}
onClick={(e) => {
setOpenDropdown(false);
e.stopPropagation();
}}>
<a href={`mailto:${email}`}>{t("email")}</a>
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem className="focus:outline-none">
<DropdownItem
StartIcon={isCopied ? "clipboard-check" : "clipboard"}
onClick={(e) => {
e.preventDefault();
copyToClipboard(email);
setOpenDropdown(false);
showToast(t("email_copied"), "success");
}}>
{!isCopied ? t("copy") : t("copied")}
</DropdownItem>
</DropdownMenuItem>
{isBookingInPast && (
<DropdownMenuItem className="focus:outline-none">
{noShow ? (
<DropdownItem
data-testid="unmark-no-show"
onClick={(e) => {
setOpenDropdown(false);
toggleNoShow({ attendee: { noShow: false, email }, bookingUid });
e.preventDefault();
}}
StartIcon="eye">
{t("unmark_as_no_show")}
</DropdownItem>
) : (
<DropdownItem
data-testid="mark-no-show"
onClick={(e) => {
setOpenDropdown(false);
toggleNoShow({ attendee: { noShow: true, email }, bookingUid });
e.preventDefault();
}}
StartIcon="eye-off">
{t("mark_as_no_show")}
</DropdownItem>
)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</Dropdown>
);
};
type GroupedAttendeeProps = {
attendees: AttendeeProps[];
bookingUid: string;
};
const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
const { bookingUid } = groupedAttendeeProps;
const attendees = groupedAttendeeProps.attendees.map((attendee) => {
return {
id: attendee.id,
email: attendee.email,
name: attendee.name,
noShow: attendee.noShow || false,
};
});
const { t } = useLocale();
const noShowMutation = trpc.viewer.public.noShow.useMutation({
onSuccess: async () => {
showToast(t("no_show_updated"), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
const { control, handleSubmit } = useForm<{
attendees: AttendeeProps[];
}>({
defaultValues: {
attendees,
},
mode: "onBlur",
});
const { fields } = useFieldArray({
control,
name: "attendees",
});
const onSubmit = (data: { attendees: AttendeeProps[] }) => {
const filteredData = data.attendees.slice(1);
noShowMutation.mutate({ bookingUid, attendees: filteredData });
setOpenDropdown(false);
};
const [openDropdown, setOpenDropdown] = useState(false);
return (
<Dropdown open={openDropdown} onOpenChange={setOpenDropdown}>
<DropdownMenuTrigger asChild>
<button
data-testid="more-guests"
onClick={(e) => e.stopPropagation()}
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
{t("plus_more", { count: attendees.length - 1 })}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel className="text-xs font-medium uppercase">
{t("mark_as_no_show_title")}
</DropdownMenuLabel>
<form onSubmit={handleSubmit(onSubmit)}>
{fields.slice(1).map((field, index) => (
<Controller
key={field.id}
name={`attendees.${index + 1}.noShow`}
control={control}
render={({ field: { onChange, value } }) => (
<DropdownMenuCheckboxItem
checked={value || false}
onCheckedChange={onChange}
className="pr-8 focus:outline-none"
onClick={(e) => {
e.preventDefault();
onChange(!value);
}}>
<span className={value ? "line-through" : ""}>{field.email}</span>
</DropdownMenuCheckboxItem>
)}
/>
))}
<DropdownMenuSeparator />
<div className=" flex justify-end p-2">
<Button
data-testid="update-no-show"
color="secondary"
onClick={(e) => {
e.preventDefault();
handleSubmit(onSubmit)();
}}>
{t("mark_as_no_show_title")}
</Button>
</div>
</form>
</DropdownMenuContent>
</Dropdown>
);
};
@@ -668,17 +889,23 @@ const DisplayAttendees = ({
attendees,
user,
currentEmail,
bookingUid,
isBookingInPast,
}: {
attendees: AttendeeProps[];
user: UserProps | null;
currentEmail?: string | null;
bookingUid: string;
isBookingInPast: boolean;
}) => {
const { t } = useLocale();
attendees.sort((a, b) => a.id - b.id);
return (
<div className="text-emphasis text-sm">
{user && <FirstAttendee user={user} currentEmail={currentEmail} />}
{attendees.length > 1 ? <span>,&nbsp;</span> : <span>&nbsp;{t("and")}&nbsp;</span>}
<Attendee {...attendees[0]} />
<Attendee {...attendees[0]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
{attendees.length > 1 && (
<>
<div className="text-emphasis inline-block text-sm">&nbsp;{t("and")}&nbsp;</div>
@@ -686,13 +913,13 @@ const DisplayAttendees = ({
<Tooltip
content={attendees.slice(1).map((attendee) => (
<p key={attendee.email}>
<Attendee {...attendee} />
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
</p>
))}>
<div className="inline-block">{t("plus_more", { count: attendees.length - 1 })}</div>
{isBookingInPast && <GroupedAttendees attendees={attendees} bookingUid={bookingUid} />}
</Tooltip>
) : (
<Attendee {...attendees[1]} />
<Attendee {...attendees[1]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
)}
</>
)}
@@ -19,11 +19,12 @@ interface CustomEventTypeModalFormProps {
setValue: (value: string) => void;
event: EventNameObjectType;
defaultValue: string;
isNameFieldSplit: boolean;
}
const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
const { t } = useLocale();
const { placeHolder, close, setValue, event } = props;
const { placeHolder, close, setValue, event, isNameFieldSplit } = props;
const { register, handleSubmit, watch, getValues } = useFormContext<FormValues>();
const onSubmit: SubmitHandler<FormValues> = (data) => {
setValue(data.customEventName);
@@ -68,10 +69,26 @@ const CustomEventTypeModalForm: FC<CustomEventTypeModalFormProps> = (props) => {
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser}`}</p>
<p className="text-emphasis">{t("your_full_name")}</p>
</div>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Organiser first name}`}</p>
<p className="text-emphasis">{t("organizer_first_name")}</p>
</div>
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler}`}</p>
<p className="text-emphasis">{t("scheduler_full_name")}</p>
</div>
{isNameFieldSplit && (
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler first name}`}</p>
<p className="text-emphasis">{t("scheduler_first_name")}</p>
</div>
)}
{isNameFieldSplit && (
<div className="mb-2.5 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Scheduler last name}`}</p>
<p className="text-emphasis">{t("scheduler_last_name")}</p>
</div>
)}
<div className="mb-1 flex font-normal">
<p className="text-subtle ml-1 mr-5 w-28">{`{Location}`}</p>
<p className="text-emphasis">{t("location_info")}</p>
@@ -101,12 +118,13 @@ interface CustomEventTypeModalProps {
close: () => void;
setValue: (value: string) => void;
event: EventNameObjectType;
isNameFieldSplit: boolean;
}
const CustomEventTypeModal: FC<CustomEventTypeModalProps> = (props) => {
const { t } = useLocale();
const { defaultValue, placeHolder, close, setValue, event } = props;
const { defaultValue, placeHolder, close, setValue, event, isNameFieldSplit } = props;
const methods = useForm<FormValues>({
defaultValues: {
@@ -128,6 +146,7 @@ const CustomEventTypeModal: FC<CustomEventTypeModalProps> = (props) => {
setValue={setValue}
placeHolder={placeHolder}
defaultValue={defaultValue}
isNameFieldSplit={isNameFieldSplit}
/>
</FormProvider>
<DialogFooter>
@@ -65,6 +65,9 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
bookingFields[name] = `${name} input`;
});
const nameBookingField = formMethods.getValues().bookingFields.find((field) => field.name === "name");
const isSplit = (nameBookingField && nameBookingField.variant === "firstAndLastName") ?? false;
const eventNameObject: EventNameObjectType = {
attendeeName: t("scheduler"),
eventType: formMethods.getValues("title"),
@@ -564,6 +567,7 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
setValue={(val: string) => formMethods.setValue("eventName", val, { shouldDirty: true })}
defaultValue={formMethods.getValues("eventName")}
placeHolder={eventNamePlaceholder}
isNameFieldSplit={isSplit}
event={eventNameObject}
/>
)}
+3 -45
View File
@@ -3,7 +3,6 @@ import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useFormContext } from "react-hook-form";
import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import { EventTypeAppCard } from "@calcom/app-store/_components/EventTypeAppCardInterface";
import type { EventTypeAppCardComponentProps } from "@calcom/app-store/types";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
@@ -13,6 +12,8 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen } from "@calcom/ui";
import useAppsData from "@lib/hooks/useAppsData";
export type EventType = Pick<EventTypeSetupProps, "eventType">["eventType"] &
EventTypeAppCardComponentProps["eventType"];
@@ -28,51 +29,8 @@ export const EventAppsTab = ({ eventType }: { eventType: EventType }) => {
eventTypeApps?.items.filter((app) => app.userCredentialIds.length || app.teams.length) || [];
const notInstalledApps =
eventTypeApps?.items.filter((app) => !app.userCredentialIds.length && !app.teams.length) || [];
const allAppsData = formMethods.watch("metadata")?.apps || {};
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
formMethods.setValue(
"metadata",
{
...formMethods.getValues("metadata"),
apps: _allAppsData,
},
{ shouldDirty: true }
);
};
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
return function (key) {
const appData = allAppsData[appId as keyof typeof allAppsData] || {};
if (key) {
return appData[key as keyof typeof appData];
}
return appData;
};
};
const eventTypeFormMetadata = formMethods.getValues("metadata");
const getAppDataSetter = (
appId: EventTypeAppsList,
appCategories: string[],
credentialId?: number
): SetAppData => {
return function (key, value) {
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
const allAppsDataFromForm = formMethods.getValues("metadata")?.apps || {};
const appData = allAppsDataFromForm[appId];
setAllAppsData({
...allAppsDataFromForm,
[appId]: {
...appData,
[key]: value,
credentialId,
appCategories,
},
});
};
};
const { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata } = useAppsData();
const { shouldLockDisableProps, isManagedEventType, isChildrenManagedEventType } = useLockedFieldsManager({
eventType,
@@ -323,7 +323,7 @@ function EventTypeSingleLayout({
<div
className={classNames(
"sm:hover:bg-muted hidden cursor-pointer items-center rounded-md",
formMethods.watch("hidden") ? "px-2" : "",
formMethods.watch("hidden") ? "pl-2" : "",
"lg:flex"
)}>
{formMethods.watch("hidden") && (
@@ -0,0 +1,28 @@
import { Card, Icon } from "@calcom/ui";
import { helpCards } from "@lib/settings/platform/utils";
export const HelpCards = () => {
return (
<>
<div className="grid-col-1 mb-4 grid gap-2 md:grid-cols-3">
{helpCards.map((card) => {
return (
<div key={card.title}>
<Card
icon={<Icon name={card.icon} className="h-5 w-5 text-green-700" />}
variant={card.variant}
title={card.title}
description={card.description}
actionButton={{
href: `${card.actionButton.href}`,
child: `${card.actionButton.child}`,
}}
/>
</div>
);
})}
</div>
</>
);
};
+5 -1
View File
@@ -195,7 +195,11 @@ function getThemeProviderProps(props: {
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
if ((isBookingPageThemeSupportRequired || props.isEmbedMode) && !props.themeBasis) {
if (
!process.env.NEXT_PUBLIC_IS_E2E &&
(isBookingPageThemeSupportRequired || props.isEmbedMode) &&
!props.themeBasis
) {
console.warn(
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
);
+1 -1
View File
@@ -215,7 +215,7 @@ function getThemeProviderProps({
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
const themeBasis = props.themeBasis;
if ((isBookingPageThemeSupportRequired || isEmbedMode) && !themeBasis) {
if (!process.env.NEXT_PUBLIC_IS_E2E && (isBookingPageThemeSupportRequired || isEmbedMode) && !themeBasis) {
console.warn(
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
);
+58
View File
@@ -0,0 +1,58 @@
import type { FormValues } from "pages/event-types/[type]";
import { useFormContext } from "react-hook-form";
import type { GetAppData, SetAppData } from "@calcom/app-store/EventTypeAppContext";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
const useAppsData = () => {
const formMethods = useFormContext<FormValues>();
const allAppsData = formMethods.watch("metadata")?.apps || {};
const setAllAppsData = (_allAppsData: typeof allAppsData) => {
formMethods.setValue(
"metadata",
{
...formMethods.getValues("metadata"),
apps: _allAppsData,
},
{ shouldDirty: true }
);
};
const getAppDataGetter = (appId: EventTypeAppsList): GetAppData => {
return function (key) {
const appData = allAppsData[appId as keyof typeof allAppsData] || {};
if (key) {
return appData[key as keyof typeof appData];
}
return appData;
};
};
const eventTypeFormMetadata = formMethods.getValues("metadata");
const getAppDataSetter = (
appId: EventTypeAppsList,
appCategories: string[],
credentialId?: number
): SetAppData => {
return function (key, value) {
// Always get latest data available in Form because consequent calls to setData would update the Form but not allAppsData(it would update during next render)
const allAppsDataFromForm = formMethods.getValues("metadata")?.apps || {};
const appData = allAppsDataFromForm[appId];
setAllAppsData({
...allAppsDataFromForm,
[appId]: {
...appData,
[key]: value,
credentialId,
appCategories,
},
});
};
};
return { getAppDataGetter, getAppDataSetter, eventTypeFormMetadata };
};
export default useAppsData;
+106
View File
@@ -0,0 +1,106 @@
import type { IconName } from "@calcom/ui";
type IndividualPlatformPlan = {
plan: string;
description: string;
pricing?: number;
includes: string[];
};
type HelpCardInfo = {
icon: IconName;
variant: "basic" | "ProfileCard" | "SidebarCard" | null;
title: string;
description: string;
actionButton: {
href: string;
child: string;
};
};
// if pricing or plans change in future modify this
export const platformPlans: IndividualPlatformPlan[] = [
{
plan: "Starter",
description:
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 99,
includes: [
"Up to 100 bookings a month",
"Community Support",
"Cal Atoms (React Library)",
"Platform APIs",
"Admin APIs",
],
},
{
plan: "Essentials",
description:
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 299,
includes: [
"Up to 500 bookings a month. $0,60 overage beyond",
"Everything in Starter",
"Cal Atoms (React Library)",
"User Management and Analytics",
"Technical Account Manager and Onboarding Support",
],
},
{
plan: "Scale",
description:
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
pricing: 2499,
includes: [
"Up to 5000 bookings a month. $0.50 overage beyond",
"Everything in Essentials",
"Credential import from other platforms",
"Compliance Check SOC2, HIPAA",
"One-on-one developer calls",
"Help with Credentials Verification (Zoom, Google App Store)",
"Expedited features and integrations",
"SLA (99.999% uptime)",
],
},
{
plan: "Enterprise",
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
},
];
export const helpCards: HelpCardInfo[] = [
{
icon: "rocket",
title: "Try our Platform Starter Kit",
description:
"If you are building a marketplace or platform from scratch, our Platform Starter Kit has everything you need.",
variant: "basic",
actionButton: {
href: "https://experts.cal.com",
child: "Try the Demo",
},
},
{
icon: "github",
title: "Get the Source code",
description:
"Our Platform Starter Kit is being used in production by Cal.com itself. You can find the ready-to-rock source code on GitHub.",
variant: "basic",
actionButton: {
href: "https://github.com/calcom/examples",
child: "GitHub",
},
},
{
icon: "calendar-check-2",
title: "Contact us",
description:
"Book our engineering team for a 15 minute onboarding call and debug a problem. Please come prepared with questions.",
variant: "basic",
actionButton: {
href: "https://i.cal.com/platform",
child: "Schedule a call",
},
},
];
@@ -1,6 +1,7 @@
import type { GetServerSidePropsContext } from "next";
import { z } from "zod";
import { orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
import { parseRecurringEvent } from "@calcom/lib";
@@ -159,8 +160,10 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
}
}
const { currentOrgDomain } = orgDomainConfig(context.req);
return {
props: {
orgSlug: currentOrgDomain,
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
hideBranding: eventType.team ? eventType.team.hideBranding : eventType.users[0].hideBranding,
profile,
@@ -0,0 +1,142 @@
import { render } from "@testing-library/react";
import { useSession } from "next-auth/react";
import React from "react";
import { describe, it, expect, vi } from "vitest";
import type { z } from "zod";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { BookingStatus } from "@calcom/prisma/enums";
import { HeadSeo } from "@calcom/ui";
import Success from "./bookings-single-view";
function mockedSuccessComponentProps(props: Partial<React.ComponentProps<typeof Success>>) {
return {
eventType: {
id: 1,
title: "Event Title",
description: "",
locations: null,
length: 15,
userId: null,
eventName: "d",
timeZone: null,
recurringEvent: null,
requiresConfirmation: false,
disableGuests: false,
seatsPerTimeSlot: null,
seatsShowAttendees: null,
seatsShowAvailabilityCount: null,
schedulingType: null,
price: 0,
currency: "usd",
successRedirectUrl: null,
customInputs: [],
team: null,
workflows: [],
hosts: [],
users: [],
owner: null,
isDynamic: false,
periodStartDate: "1",
periodEndDate: "1",
metadata: null,
bookingFields: [] as unknown as [] & z.BRAND<"HAS_SYSTEM_FIELDS">,
},
profile: {
name: "John",
email: null,
theme: null,
brandColor: null,
darkBrandColor: null,
slug: null,
},
bookingInfo: {
uid: "uid",
metadata: null,
customInputs: [],
startTime: new Date(),
endTime: new Date(),
id: 1,
user: null,
eventType: null,
seatsReferences: [],
userPrimaryEmail: null,
eventTypeId: null,
title: "Booking Title",
description: null,
location: null,
recurringEventId: null,
smsReminderNumber: "0",
cancellationReason: null,
rejectionReason: null,
status: BookingStatus.ACCEPTED,
attendees: [],
responses: {
name: "John",
},
},
orgSlug: null,
userTimeFormat: 12,
requiresLoginToUpdate: false,
themeBasis: "dark",
hideBranding: false,
recurringBookings: null,
trpcState: {
queries: [],
mutations: [],
},
dynamicEventName: "Event Title",
paymentStatus: null,
...props,
} satisfies React.ComponentProps<typeof Success>;
}
describe("Success Component", () => {
it("renders HeadSeo correctly", () => {
vi.mocked(getOrgFullOrigin).mockImplementation((text: string | null) => `${text}.cal.local`);
vi.mocked(useRouterQuery).mockReturnValue({
uid: "uid",
});
vi.mocked(useSession).mockReturnValue({
update: vi.fn(),
status: "authenticated",
data: {
hasValidLicense: true,
upId: "1",
expires: "1",
user: {
name: "John",
id: 1,
profile: {
id: null,
upId: "1",
username: null,
organizationId: null,
organization: null,
},
},
},
});
const mockObject = {
props: mockedSuccessComponentProps({
orgSlug: "org1",
}),
};
render(<Success {...mockObject.props} />);
const expectedTitle = `booking_confirmed`;
const expectedDescription = expectedTitle;
expect(HeadSeo).toHaveBeenCalledWith(
{
origin: `${mockObject.props.orgSlug}.cal.local`,
title: expectedTitle,
description: expectedDescription,
},
{}
);
});
});
@@ -19,6 +19,7 @@ import type { nameObjectSchema } from "@calcom/core/event";
import { getEventName } from "@calcom/core/event";
import type { ConfigType } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import {
useEmbedNonStylesConfig,
useIsBackgroundTransparent,
@@ -60,12 +61,10 @@ import {
EmptyScreen,
Icon,
} from "@calcom/ui";
import { timeZone } from "@lib/clock";
import PageWrapper from "@components/PageWrapper";
import CancelBooking from "@components/booking/CancelBooking";
import EventReservationSchema from "@components/schemas/EventReservationSchema";
import PageWrapper from "@calcom/web/components/PageWrapper";
import CancelBooking from "@calcom/web/components/booking/CancelBooking";
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
import { timeZone } from "@calcom/web/lib/clock";
import type { PageProps } from "./bookings-single-view.getServerSideProps";
@@ -109,7 +108,7 @@ export default function Success(props: PageProps) {
const routerQuery = useRouterQuery();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const { eventType, bookingInfo, requiresLoginToUpdate } = props;
const { eventType, bookingInfo, requiresLoginToUpdate, orgSlug } = props;
const {
allRemainingBookings,
@@ -377,7 +376,7 @@ export default function Success(props: PageProps) {
</Link>
</div>
)}
<HeadSeo title={title} description={title} />
<HeadSeo origin={getOrgFullOrigin(orgSlug)} title={title} description={title} />
<BookingPageTagManager eventType={eventType} />
<main className={classNames(shouldAlignCentrally ? "mx-auto" : "", isEmbed ? "" : "max-w-3xl")}>
<div className={classNames("overflow-y-auto", isEmbed ? "" : "z-50 ")}>
@@ -9,6 +9,7 @@ import { memo, useEffect, useState } from "react";
import { z } from "zod";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import useIntercom from "@calcom/features/ee/support/lib/intercom/useIntercom";
import { EventTypeEmbedButton, EventTypeEmbedDialog } from "@calcom/features/embed/EventTypeEmbed";
import { EventTypeDescription } from "@calcom/features/eventtypes/components";
import CreateEventTypeDialog from "@calcom/features/eventtypes/components/CreateEventTypeDialog";
@@ -16,7 +17,7 @@ import { DuplicateDialog } from "@calcom/features/eventtypes/components/Duplicat
import { TeamsFilter } from "@calcom/features/filters/components/TeamsFilter";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import Shell from "@calcom/features/shell/Shell";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { APP_NAME } from "@calcom/lib/constants";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -109,13 +110,10 @@ const querySchema = z.object({
const MobileTeamsTab: FC<MobileTeamsTabProps> = (props) => {
const { eventTypeGroups } = props;
const orgBranding = useOrgBranding();
const tabs = eventTypeGroups.map((item) => ({
name: item.profile.name ?? "",
href: item.teamId ? `/event-types?teamId=${item.teamId}` : "/event-types?noTeam",
avatar: orgBranding
? `${orgBranding.fullDomain}${item.teamId ? "/team" : ""}/${item.profile.slug}/avatar.png`
: item.profile.image ?? `${WEBAPP_URL + (item.teamId && "/team")}/${item.profile.slug}/avatar.png`,
avatar: item.profile.image,
}));
const { data } = useTypedQuery(querySchema);
const events = eventTypeGroups.filter((item) => item.teamId === data.teamId);
@@ -949,12 +947,15 @@ const EventTypesPage: React.FC & {
getLayout?: AppProps["Component"]["getLayout"];
} = () => {
const { t } = useLocale();
const searchParams = useCompatSearchParams();
const { open } = useIntercom();
const { data: user } = useMeQuery();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [showProfileBanner, setShowProfileBanner] = useState(false);
const orgBranding = useOrgBranding();
const routerQuery = useRouterQuery();
const filters = getTeamsFiltersFromQuery(routerQuery);
const router = useRouter();
// TODO: Maybe useSuspenseQuery to focus on success case only? Remember that it would crash the page when there is an error in query. Also, it won't support skeleton
const { data, status, error } = trpc.viewer.eventTypes.getByViewer.useQuery(filters && { filters }, {
@@ -963,6 +964,20 @@ const EventTypesPage: React.FC & {
staleTime: 1 * 60 * 60 * 1000,
});
useEffect(() => {
if (searchParams?.get("openIntercom") === "true") {
open();
}
/**
* During signup, if the account already exists, we redirect the user to /event-types instead of onboarding.
* Adding this redirection logic here as well to ensure the user is redirected to the correct redirectUrl.
*/
const redirectUrl = localStorage.getItem("onBoardingRedirect");
localStorage.removeItem("onBoardingRedirect");
redirectUrl && router.push(redirectUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
setShowProfileBanner(
!!orgBranding && !document.cookie.includes("calcom-profile-banner=1") && !user?.completedOnboarding
+186
View File
@@ -0,0 +1,186 @@
import React from "react";
import { vi, afterEach } from "vitest";
global.React = React;
afterEach(() => {
vi.resetAllMocks();
});
// Mock all modules that are used in multiple tests for modules
// We don't intend to provide the mock implementation here. They should be provided by respective tests.
// But it makes it super easy to start testing any module view without worrying about mocking the dependencies.
vi.mock("next-auth/react", () => ({
useSession: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: vi.fn().mockReturnValue({
replace: vi.fn(),
}),
usePathname: vi.fn(),
}));
vi.mock("@calcom/app-store/BookingPageTagManager", () => ({
default: vi.fn(),
}));
vi.mock("@calcom/app-store/locations", () => ({
DailyLocationType: "daily",
guessEventLocationType: vi.fn(),
getSuccessPageLocationMessage: vi.fn(),
}));
vi.mock("@calcom/app-store/utils", () => ({
getEventTypeAppData: vi.fn(),
}));
vi.mock("@calcom/core/event", () => ({
getEventName: vi.fn(),
}));
vi.mock("@calcom/ee/organizations/lib/orgDomains", () => ({
getOrgFullOrigin: vi.fn(),
}));
vi.mock("@calcom/features/eventtypes/components", () => ({
EventTypeDescriptionLazy: vi.fn(),
}));
vi.mock("@calcom/embed-core/embed-iframe", () => {
return {
useIsBackgroundTransparent: vi.fn(),
useIsEmbed: vi.fn(),
useEmbedNonStylesConfig: vi.fn(),
useEmbedStyles: vi.fn(),
};
});
vi.mock("@calcom/features/bookings/components/event-meta/Price", () => {
return {};
});
vi.mock("@calcom/features/bookings/lib/SystemField", () => {
return {};
});
vi.mock("@calcom/lib/constants", () => {
return {
DEFAULT_LIGHT_BRAND_COLOR: "DEFAULT_LIGHT_BRAND_COLOR",
DEFAULT_DARK_BRAND_COLOR: "DEFAULT_DARK_BRAND_COLOR",
BOOKER_NUMBER_OF_DAYS_TO_LOAD: 1,
};
});
vi.mock("@calcom/lib/date-fns", () => {
return {};
});
vi.mock("@calcom/lib/getBrandColours", () => {
return {
default: vi.fn(),
};
});
vi.mock("@calcom/lib/hooks/useCompatSearchParams", () => {
return {
useCompatSearchParams: vi.fn(),
};
});
vi.mock("@calcom/lib/hooks/useLocale", () => {
return {
useLocale: vi.fn().mockReturnValue({
t: vi.fn().mockImplementation((text: string) => {
return text;
}),
i18n: {
language: "en",
},
}),
};
});
vi.mock("@calcom/lib/hooks/useRouterQuery", () => {
return {
useRouterQuery: vi.fn(),
};
});
vi.mock("@calcom/lib/hooks/useTheme", () => {
return {
default: vi.fn(),
};
});
vi.mock("@calcom/lib/recurringStrings", () => {
return {};
});
vi.mock("@calcom/lib/recurringStrings", () => {
return {};
});
vi.mock("@calcom/prisma/zod-utils", () => ({
BookerLayouts: {
MONTH_VIEW: "month",
},
EventTypeMetaDataSchema: {
parse: vi.fn(),
},
bookingMetadataSchema: {
parse: vi.fn(),
},
}));
vi.mock("@calcom/trpc/react", () => ({
trpc: {
viewer: {
public: {
submitRating: {
useMutation: vi.fn(),
},
noShow: {
useMutation: vi.fn(),
},
},
},
},
}));
vi.mock("@calcom/ui", () => ({
HeadSeo: vi.fn(),
useCalcomTheme: vi.fn(),
Icon: vi.fn(),
UnpublishedEntity: vi.fn(),
UserAvatar: vi.fn(),
}));
vi.mock("@calcom/web/components/PageWrapper", () => ({
default: vi.fn(),
}));
vi.mock("@calcom/web/components/booking/CancelBooking", () => ({}));
vi.mock("@calcom/web/components/schemas/EventReservationSchema", () => ({
default: vi.fn(),
}));
vi.mock("@calcom/web/lib/clock", () => ({
timeZone: vi.fn(),
}));
vi.mock("./bookings-single-view.getServerSideProps", () => ({}));
vi.mock("@calcom/lib/webstorage", () => ({
localStorage: {
getItem: vi.fn(),
setItem: vi.fn(),
},
}));
vi.mock("@calcom/lib/timeFormat", () => ({
detectBrowserTimeFormat: vi.fn(),
isBrowserLocale24h: vi.fn(),
getIs24hClockFromLocalStorage: vi.fn(),
}));
@@ -50,6 +50,7 @@ export type UserPageProps = {
considerUnpublished: boolean;
orgSlug?: string | null;
name?: string | null;
teamSlug?: string | null;
};
eventTypes: ({
descriptionAsSafeHTML: string;
@@ -0,0 +1,102 @@
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains";
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
import { HeadSeo } from "@calcom/ui";
import UserPage from "./users-public-view";
function mockedUserPageComponentProps(props: Partial<React.ComponentProps<typeof UserPage>>) {
return {
trpcState: {
mutations: [],
queries: [],
},
themeBasis: "dark",
safeBio: "My Bio",
profile: {
name: "John Doe",
image: "john-profile-url",
theme: "dark",
brandColor: "red",
darkBrandColor: "black",
organization: { requestedSlug: "slug", slug: "slug", id: 1 },
allowSEOIndexing: true,
username: "john",
},
users: [
{
name: "John Doe",
username: "john",
avatarUrl: "john-user-url",
bio: "",
verified: false,
profile: {
upId: "1",
id: 1,
username: "john",
organizationId: null,
organization: null,
},
},
],
markdownStrippedBio: "My Bio",
entity: {
considerUnpublished: false,
...(props.entity ?? null),
},
eventTypes: [],
} satisfies React.ComponentProps<typeof UserPage>;
}
describe("UserPage Component", () => {
it("should render HeadSeo with correct props", () => {
const mockData = {
props: mockedUserPageComponentProps({
entity: {
considerUnpublished: false,
orgSlug: "org1",
},
}),
};
vi.mocked(getOrgFullOrigin).mockImplementation((orgSlug: string | null) => {
return `${orgSlug}.cal.local`;
});
vi.mocked(useRouterQuery).mockReturnValue({
uid: "uid",
});
render(<UserPage {...mockData.props} />);
const expectedDescription = mockData.props.markdownStrippedBio;
const expectedTitle = expectedDescription;
expect(HeadSeo).toHaveBeenCalledWith(
{
origin: `${mockData.props.entity.orgSlug}.cal.local`,
title: `${mockData.props.profile.name}`,
description: expectedDescription,
meeting: {
profile: {
name: mockData.props.profile.name,
image: mockData.props.users[0].avatarUrl,
},
title: expectedTitle,
users: [
{
name: mockData.props.users[0].name,
username: mockData.props.users[0].username,
},
],
},
nextSeoProps: {
nofollow: !mockData.props.profile.allowSEOIndexing,
noindex: !mockData.props.profile.allowSEOIndexing,
},
},
{}
);
});
});
@@ -11,6 +11,7 @@ import {
useEmbedStyles,
useIsEmbed,
} from "@calcom/embed-core/embed-iframe";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
import EmptyPage from "@calcom/features/eventtypes/components/EmptyPage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -59,10 +60,10 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
const isEventListEmpty = eventTypes.length === 0;
const isOrg = !!user?.profile?.organization;
return (
<>
<HeadSeo
origin={getOrgFullOrigin(entity.orgSlug ?? null)}
title={profile.name}
description={markdownStrippedBio}
meeting={{
@@ -125,7 +126,7 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
<div
key={type.id}
style={{ display: "flex", ...eventTypeListItemEmbedStyles }}
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b first:rounded-t-md last:rounded-b-md last:border-b-0">
className="bg-default border-subtle dark:bg-muted dark:hover:bg-emphasis hover:bg-muted group relative border-b transition first:rounded-t-md last:rounded-b-md last:border-b-0">
<Icon
name="arrow-right"
className="text-emphasis absolute right-4 top-4 h-4 w-4 opacity-0 transition-opacity group-hover:opacity-100"
+6 -6
View File
@@ -2,7 +2,7 @@ import { useTranscription } from "@daily-co/daily-react";
import { useDaily, useDailyEvent } from "@daily-co/daily-react";
import React, { Fragment, useCallback, useRef, useState, useLayoutEffect, useEffect } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { TRANSCRIPTION_STARTED_ICON, TRANSCRIPTION_STOPPED_ICON } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
export const CalAiTranscribe = () => {
@@ -29,8 +29,8 @@ export const CalAiTranscribe = () => {
transcription: {
label: "Stop",
tooltip: "Stop transcription",
iconPath: `${WEBAPP_URL}/sparkles-red.svg`,
iconPathDarkMode: `${WEBAPP_URL}/sparkles-red.svg`,
iconPath: TRANSCRIPTION_STARTED_ICON,
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
});
});
@@ -40,8 +40,8 @@ export const CalAiTranscribe = () => {
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: `${WEBAPP_URL}/sparkles`,
iconPathDarkMode: `${WEBAPP_URL}/sparkles`,
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
});
});
@@ -82,7 +82,7 @@ export const CalAiTranscribe = () => {
return (
<>
<div
id="cal-ai-thing"
id="cal-ai-transcription"
style={{
textShadow: "0 0 20px black, 0 0 20px black, 0 0 20px black",
}}
@@ -8,7 +8,8 @@ import { useState, useEffect, useRef } from "react";
import dayjs from "@calcom/dayjs";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
import { TRANSCRIPTION_STOPPED_ICON } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
@@ -52,8 +53,8 @@ export default function JoinCall(props: PageProps) {
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: `${WEBAPP_URL}/sparkles.svg`,
iconPathDarkMode: `${WEBAPP_URL}/sparkles.svg`,
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
},
}),
@@ -87,7 +88,7 @@ export default function JoinCall(props: PageProps) {
<meta property="twitter:description" content={t("quick_video_meeting")} />
</Head>
<DailyProvider callObject={daily}>
<div className="mx-auto" style={{ zIndex: 2, position: "absolute", bottom: 60, width: "100%" }}>
<div className="mx-auto" style={{ zIndex: 2, position: "absolute", bottom: 100, width: "100%" }}>
<CalAiTranscribe />
</div>
<div style={{ zIndex: 2, position: "relative" }}>
+23 -23
View File
@@ -264,6 +264,29 @@ const nextConfig = {
},
async rewrites() {
const beforeFiles = [
{
source: "/forms/:formQuery*",
destination: "/apps/routing-forms/routing-link/:formQuery*",
},
{
source: "/router",
destination: "/apps/routing-forms/router",
},
{
source: "/success/:path*",
has: [
{
type: "query",
key: "uid",
value: "(?<uid>.*)",
},
],
destination: "/booking/:uid/:path*",
},
{
source: "/cancel/:path*",
destination: "/booking/:path*",
},
{
/**
* Needed due to the introduction of dotted usernames
@@ -327,29 +350,6 @@ const nextConfig = {
source: "/:user/avatar.png",
destination: "/api/user/avatar?username=:user",
},
{
source: "/forms/:formQuery*",
destination: "/apps/routing-forms/routing-link/:formQuery*",
},
{
source: "/router",
destination: "/apps/routing-forms/router",
},
{
source: "/success/:path*",
has: [
{
type: "query",
key: "uid",
value: "(?<uid>.*)",
},
],
destination: "/booking/:uid/:path*",
},
{
source: "/cancel/:path*",
destination: "/booking/:path*",
},
],
/* TODO: have these files being served from another deployment or CDN {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.1.1",
"version": "4.1.9",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -17,7 +17,8 @@
"lint": "eslint . --ignore-path .gitignore",
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
"lint:report": "eslint . --format json --output-file ../../lint-results/web.json",
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts"
"check-changed-files": "ts-node scripts/ts-check-changed-files.ts",
"translate-locales": "ts-node scripts/check-missing-translations.ts"
},
"engines": {
"node": "18",
+4 -1
View File
@@ -80,7 +80,10 @@ const triggerWebhook = async ({
...evt,
downloadLink,
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
log.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
);
})
);
await Promise.all(promises);
+1
View File
@@ -53,6 +53,7 @@ function SingleAppPage(props: inferSSRProps<typeof getStaticProps>) {
slug={data.slug}
variant={data.variant}
type={data.type}
dirName={data.dirName}
logo={data.logo}
categories={data.categories ?? [data.category]}
author={data.publisher}
@@ -0,0 +1,513 @@
import type { GetServerSidePropsContext } from "next";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Head from "next/head";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingRedirectUrl } from "@calcom/lib/apps/getAppOnboardingRedirectUrl";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { CAL_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { AppMeta } from "@calcom/types/App";
import { Form, Steps, showToast } from "@calcom/ui";
import { HttpError } from "@lib/core/http/error";
import PageWrapper from "@components/PageWrapper";
import type { PersonalAccountProps, TeamsProp } from "@components/apps/installation/AccountsStepCard";
import { AccountsStepCard } from "@components/apps/installation/AccountsStepCard";
import { ConfigureStepCard } from "@components/apps/installation/ConfigureStepCard";
import { EventTypesStepCard } from "@components/apps/installation/EventTypesStepCard";
import { StepHeader } from "@components/apps/installation/StepHeader";
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
Pick<EventTypeModel, "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position"> & {
selected: boolean;
};
export type TEventTypesForm = {
eventTypes: TEventType[];
};
const STEPS = [
AppOnboardingSteps.ACCOUNTS_STEP,
AppOnboardingSteps.EVENT_TYPES_STEP,
AppOnboardingSteps.CONFIGURE_STEP,
] as const;
const MAX_NUMBER_OF_STEPS = STEPS.length;
type StepType = (typeof STEPS)[number];
type StepObj = Record<
StepType,
{
getTitle: (appName: string) => string;
getDescription: (appName: string) => string;
stepNumber: number;
}
>;
type OnboardingPageProps = {
appMetadata: AppMeta;
step: StepType;
teams: TeamsProp;
personalAccount: PersonalAccountProps;
eventTypes?: TEventType[];
userName: string;
credentialId?: number;
};
const OnboardingPage = ({
step,
teams,
personalAccount,
appMetadata,
eventTypes,
userName,
credentialId,
}: OnboardingPageProps) => {
const { t } = useLocale();
const pathname = usePathname();
const router = useRouter();
const STEPS_MAP: StepObj = {
[AppOnboardingSteps.ACCOUNTS_STEP]: {
getTitle: () => `${t("select_account_header")}`,
getDescription: (appName) => `${t("select_account_description", { appName })}`,
stepNumber: 1,
},
[AppOnboardingSteps.EVENT_TYPES_STEP]: {
getTitle: () => `${t("select_event_types_header")}`,
getDescription: (appName) => `${t("select_event_types_description", { appName })}`,
stepNumber: 2,
},
[AppOnboardingSteps.CONFIGURE_STEP]: {
getTitle: (appName) => `${t("configure_app_header", { appName })}`,
getDescription: () => `${t("configure_app_description")}`,
stepNumber: 3,
},
} as const;
const [configureStep, setConfigureStep] = useState(false);
const [isSelectingAccount, setIsSelectingAccount] = useState(false);
const currentStep: AppOnboardingSteps = useMemo(() => {
if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) {
return AppOnboardingSteps.CONFIGURE_STEP;
}
return step;
}, [step, configureStep]);
const stepObj = STEPS_MAP[currentStep];
const utils = trpc.useContext();
const formPortalRef = useRef<HTMLDivElement>(null);
const formMethods = useForm<TEventTypesForm>({
defaultValues: {
eventTypes,
},
});
useEffect(() => {
eventTypes && formMethods.setValue("eventTypes", eventTypes);
}, [eventTypes]);
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
onSuccess: async (data) => {
showToast(t("event_type_updated_successfully", { eventTypeTitle: data.eventType?.title }), "success");
},
async onSettled() {
await utils.viewer.eventTypes.get.invalidate();
},
onError: (err) => {
let message = "";
if (err instanceof HttpError) {
const message = `${err.statusCode}: ${err.message}`;
showToast(message, "error");
}
if (err.data?.code === "UNAUTHORIZED") {
message = `${err.data.code}: ${t("error_event_type_unauthorized_update")}`;
}
if (err.data?.code === "PARSE_ERROR" || err.data?.code === "BAD_REQUEST") {
message = `${err.data.code}: ${t(err.message)}`;
}
if (err.data?.code === "INTERNAL_SERVER_ERROR") {
message = t("unexpected_error_try_again");
}
showToast(message ? t(message) : t(err.message), "error");
},
});
const handleSelectAccount = async (teamId?: number) => {
try {
setIsSelectingAccount(true);
if (appMetadata.isOAuth) {
const state = JSON.stringify({
appOnboardingRedirectUrl: getAppOnboardingRedirectUrl(appMetadata.slug, teamId),
teamId,
});
const res = await fetch(
`/api/integrations/${
appMetadata.slug == "stripe" ? "stripepayment" : appMetadata.slug
}/add?state=${state}`,
{
method: "GET",
headers: {
"Content-Type": "application/json",
},
}
);
const oAuthUrl = (await res.json())?.url;
router.push(oAuthUrl);
return;
} else {
await fetch(`/api/integrations/${appMetadata.slug}/add${teamId ? `?teamId=${teamId}` : ""}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
router.push(
getAppOnboardingUrl({
slug: appMetadata.slug,
step: AppOnboardingSteps.EVENT_TYPES_STEP,
teamId,
})
);
}
} catch (error) {
setIsSelectingAccount(false);
router.push(`/apps`);
}
};
const handleSetUpLater = () => {
router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`);
};
return (
<div
key={pathname}
className="dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen px-4"
data-testid="onboarding">
<Head>
<title>
{t("install")} {appMetadata?.name ?? ""}
</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="mx-auto py-6 sm:px-4 md:py-24">
<div className="relative">
<div className="sm:mx-auto sm:w-full sm:max-w-[600px]" ref={formPortalRef}>
<Form
form={formMethods}
id="outer-event-type-form"
handleSubmit={async (values) => {
const mutationPromises = values?.eventTypes
.filter((eventType) => eventType.selected)
.map((value: TEventType) => {
// Prevent two payment apps to be enabled
// Ok to cast type here because this metadata will be updated as the event type metadata
if (
checkForMultiplePaymentApps(value.metadata as z.infer<typeof EventTypeMetaDataSchema>)
)
throw new Error(t("event_setup_multiple_payment_apps_error"));
if (value.metadata?.apps?.stripe?.paymentOption === "HOLD" && value.seatsPerTimeSlot) {
throw new Error(t("seats_and_no_show_fee_error"));
}
return updateMutation.mutateAsync({
id: value.id,
metadata: value.metadata,
});
});
try {
await Promise.all(mutationPromises);
router.push("/event-types");
} catch (err) {
console.error(err);
}
}}>
<StepHeader
title={stepObj.getTitle(appMetadata.name)}
subtitle={stepObj.getDescription(appMetadata.name)}>
<Steps maxSteps={MAX_NUMBER_OF_STEPS} currentStep={stepObj.stepNumber} disableNavigation />
</StepHeader>
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
<AccountsStepCard
teams={teams}
personalAccount={personalAccount}
onSelect={handleSelectAccount}
loading={isSelectingAccount}
/>
)}
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
eventTypes &&
Boolean(eventTypes?.length) && (
<EventTypesStepCard
setConfigureStep={setConfigureStep}
userName={userName}
handleSetUpLater={handleSetUpLater}
/>
)}
{currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && (
<ConfigureStepCard
slug={appMetadata.slug}
categories={appMetadata.categories}
credentialId={credentialId}
userName={userName}
loading={updateMutation.isPending}
formPortalRef={formPortalRef}
setConfigureStep={setConfigureStep}
eventTypes={eventTypes}
handleSetUpLater={handleSetUpLater}
/>
)}
</Form>
</div>
</div>
</div>
<Toaster position="bottom-right" />
</div>
);
};
// Redirect Error map to give context on edge cases, this is for the devs, never shown to users
const ERROR_MESSAGES = {
appNotFound: "App not found",
userNotAuthed: "User is not logged in",
userNotFound: "User from session not found",
appNotExtendsEventType: "App does not extend EventTypes",
userNotInTeam: "User is not in provided team",
} as const;
const getUser = async (userId: number) => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
id: true,
avatar: true,
name: true,
username: true,
teams: {
where: {
accepted: true,
team: {
members: {
some: {
userId,
role: {
in: ["ADMIN", "OWNER"],
},
},
},
},
},
select: {
team: {
select: {
id: true,
name: true,
logo: true,
},
},
},
},
},
});
if (!user) {
throw new Error(ERROR_MESSAGES.userNotFound);
}
return user;
};
const getAppBySlug = async (appSlug: string) => {
const app = await prisma.app.findUnique({
where: { slug: appSlug, enabled: true },
select: { slug: true, keys: true, enabled: true, dirName: true },
});
if (!app) throw new Error(ERROR_MESSAGES.appNotFound);
return app;
};
const getEventTypes = async (userId: number, teamId?: number) => {
const eventTypes = (
await prisma.eventType.findMany({
select: {
id: true,
description: true,
durationLimits: true,
metadata: true,
length: true,
title: true,
position: true,
recurringEvent: true,
requiresConfirmation: true,
team: { select: { slug: true } },
schedulingType: true,
teamId: true,
users: { select: { username: true } },
seatsPerTimeSlot: true,
slug: true,
},
where: teamId ? { teamId } : { userId, teamId: null },
})
).sort((eventTypeA, eventTypeB) => {
return eventTypeB.position - eventTypeA.position;
});
if (eventTypes.length === 0) {
return [];
}
return eventTypes.map((item) => ({
...item,
URL: `${CAL_URL}/${item.team ? `team/${item.team.slug}` : item?.users?.[0]?.username}/${item.slug}`,
selected: false,
}));
};
const getAppInstallsBySlug = async (appSlug: string, userId: number, teamIds?: number[]) => {
const appInstalls = await prisma.credential.findMany({
where: {
OR: [
{
appId: appSlug,
userId: userId,
},
teamIds && Boolean(teamIds.length)
? {
appId: appSlug,
teamId: { in: teamIds },
}
: {},
],
},
});
return appInstalls;
};
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
try {
let eventTypes: TEventType[] | null = null;
const { req, res, query, params } = context;
const stepsEnum = z.enum(STEPS);
const parsedAppSlug = z.coerce.string().parse(query?.slug);
const parsedStepParam = z.coerce.string().parse(params?.step);
const parsedTeamIdParam = z.coerce.number().optional().parse(query?.teamId);
const _ = stepsEnum.parse(parsedStepParam);
const session = await getServerSession({ req, res });
const locale = await getLocale(context.req);
const app = await getAppBySlug(parsedAppSlug);
const appMetadata = appStoreMetadata[app.dirName as keyof typeof appStoreMetadata];
const hasEventTypes = appMetadata?.extendsFeature === "EventType";
if (!session?.user?.id) throw new Error(ERROR_MESSAGES.userNotAuthed);
if (!hasEventTypes) {
throw new Error(ERROR_MESSAGES.appNotExtendsEventType);
}
const user = await getUser(session.user.id);
const userAcceptedTeams = user.teams.map((team) => ({ ...team.team }));
const hasTeams = Boolean(userAcceptedTeams.length);
const appInstalls = await getAppInstallsBySlug(
parsedAppSlug,
user.id,
userAcceptedTeams.map(({ id }) => id)
);
if (parsedTeamIdParam) {
const isUserMemberOfTeam = userAcceptedTeams.some((team) => team.id === parsedTeamIdParam);
if (!isUserMemberOfTeam) {
throw new Error(ERROR_MESSAGES.userNotInTeam);
}
}
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
if (eventTypes.length === 0) {
return {
redirect: {
permanent: false,
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
},
};
}
}
const personalAccount = {
id: user.id,
name: user.name,
avatar: user.avatar,
alreadyInstalled: appInstalls.some((install) => !Boolean(install.teamId) && install.userId === user.id),
};
const teamsWithIsAppInstalled = hasTeams
? userAcceptedTeams.map((team) => ({
...team,
alreadyInstalled: appInstalls.some(
(install) => Boolean(install.teamId) && install.teamId === team.id
),
}))
: [];
let credentialId = null;
if (parsedTeamIdParam) {
credentialId =
appInstalls.find((item) => !!item.teamId && item.teamId == parsedTeamIdParam)?.id ?? null;
} else {
credentialId = appInstalls.find((item) => !!item.userId && item.userId == user.id)?.id ?? null;
}
return {
props: {
...(await serverSideTranslations(locale, ["common"])),
app,
appMetadata,
step: parsedStepParam,
teams: teamsWithIsAppInstalled,
personalAccount,
eventTypes,
teamId: parsedTeamIdParam ?? null,
userName: user.username,
credentialId,
} as OnboardingPageProps,
};
} catch (err) {
if (err instanceof z.ZodError) {
return { redirect: { permanent: false, destination: "/apps" } };
}
if (err instanceof Error) {
switch (err.message) {
case ERROR_MESSAGES.userNotAuthed:
return { redirect: { permanent: false, destination: "/auth/login" } };
case ERROR_MESSAGES.userNotFound:
return { redirect: { permanent: false, destination: "/auth/login" } };
default:
return { redirect: { permanent: false, destination: "/apps" } };
}
}
}
};
OnboardingPage.PageWrapper = PageWrapper;
export default OnboardingPage;
+3 -1
View File
@@ -249,7 +249,9 @@ inferSSRProps<typeof getServerSideProps> & WithNonceProps<{}>) {
CustomStartIcon={<GoogleIcon />}
onClick={async (e) => {
e.preventDefault();
await signIn("google");
await signIn("google", {
callbackUrl,
});
}}>
{t("signin_with_google")}
</Button>
+2 -2
View File
@@ -45,10 +45,10 @@ function VerifyEmailPage() {
className="underline"
loading={mutation.isPending}
onClick={() => {
showToast("Send email", "success");
showToast(t("send_email"), "success");
mutation.mutate();
}}>
Resend Email
{t("resend_email")}
</Button>
}
/>
@@ -11,6 +11,7 @@ import {
import { useDeleteOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { HelpCards } from "@components/settings/platform/dashboard/HelpCards";
import { ManagedUserList } from "@components/settings/platform/dashboard/managed-user-list";
import { OAuthClientsList } from "@components/settings/platform/dashboard/oauth-clients-list";
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
@@ -68,6 +69,7 @@ export default function Platform() {
withoutMain={false}
subtitle="Manage everything related to platform."
isPlatformUser={true}>
<HelpCards />
<OAuthClientsList oauthClients={data} isDeleting={isDeleting} handleDelete={handleDelete} />
<ManagedUserList
oauthClients={data}
+19 -17
View File
@@ -538,23 +538,25 @@ export default function Signup({
</Link>
</div>
<div className="text-subtle ">
<Trans i18nKey="signing_up_terms">
By proceeding, you agree to our{" "}
<Link
className="text-emphasis hover:underline"
href={`${WEBSITE_URL}/terms`}
target="_blank">
Terms
</Link>{" "}
and{" "}
<Link
className="text-emphasis hover:underline"
href={`${WEBSITE_URL}/privacy`}
target="_blank">
Privacy Policy
</Link>
.
</Trans>
<Trans
i18nKey="signing_up_terms"
components={[
<Link
className="text-emphasis hover:underline"
key="terms"
href={`${WEBSITE_URL}/terms`}
target="_blank">
Terms
</Link>,
<Link
className="text-emphasis hover:underline"
key="privacy"
href={`${WEBSITE_URL}/privacy`}
target="_blank">
Privacy Policy.
</Link>,
]}
/>
</div>
</div>
</div>
+2
View File
@@ -12,6 +12,7 @@ import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getOrgOrTeamAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -167,6 +168,7 @@ function TeamPage({
return (
<>
<HeadSeo
origin={getOrgFullOrigin(currentOrgDomain)}
title={teamName}
description={teamName}
meeting={{

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