Compare commits

...
67 Commits
Author SHA1 Message Date
Omar López aa20b9248a chore: v4.2.4 (#15703)
release.yml / release (push) Canceled after 0s
2024-07-09 14:09:04 +00:00
Keith Williams fa40f1ead5 fix: 404s becoming 500s (#15696) 2024-07-09 12:35:34 +02:00
Morgan 86676eb8af fix: delete reserved slot on booker unmount (#15700) 2024-07-09 10:08:06 +00:00
Morgan 3f9d1c1abf chore: add caching apiv2 roles guard (#15694) 2024-07-09 09:39:06 +00:00
5f8d090d3c refactor: handleNewBooking #3 (#15612)
* refactor: handleNewBooking #3

* fix: type error

* fix: type err

* fix: type err

* refactor: create booking factor

* chore: refactor availusers

* chore: type err

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-08 18:18:36 +00:00
4a2a2d165b fix: incorrect booking seats full error on collective seated event (#15602)
* fix: wrong booking seats full error on collective event

* test: verify seat availability calculation based on booked seats, not total attendees

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-08 18:02:02 +00:00
Martin SioneandJoe Au-Yeung 263c0ac7ef fix: Update stripe customer email when a user updates cal.com email (#15299)
* fix: Update stripe customer email when a user updates cal.com email

* Only update email on stripe if stripe is enabled and if there is a stripeAccountId already associated with the user

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-07-08 18:00:51 +00:00
f1b4d7d9e0 feat: app install flow followup (#15193)
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-07-08 19:15:57 +02:00
Anik Dhabal BabuandKeith Williams 7d8cb7d6bb fix (#15465)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-08 14:17:56 +00:00
Hariom BalharaandKeith Williams 5ff7e65d64 fix: Broken navigation after booking creation in some cases (#15664)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-08 13:56:58 +00:00
5bbc025494 fix: 15636 Sort order of team event types incorrect (#15660)
* Fix:15636 event types sort order on team page

* fix type-check

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-07-08 14:31:27 +01:00
sean-brydon 59bffa075f remove (#15687) 2024-07-08 12:31:15 +00:00
c4104af8a2 feat: org-wide workflows (#15083)
* add org to create workflow button

* add save button for testing in dev mode

* add select all to multi select dropdown

* fix select all

* pass isOrg to WorkflowDetailsPage

* add checkbox to apply to all including future

* fix count text on select

* WIP schema

* shows teams in dropdown

* add team option to UI

* [WIP] refactor for update handler

* filter out "all" from activeOn

* fix type error

* create more helper functions

* create reusable function for scheduling all booking reminders

* finish update workflows for orgs (without testing)

* fix isAuthorized check for orgs

* fix getting newActiveOn

* move all helper functions to util file

* more code clean up

* fix deleting reminders for org workflows

* fix adding and editing steps

* update form data when workflow is saved

* code clean up

* fixing creating/deleting reminders when user is member of several teams

* fix setting activeOn for teams in useffect

* add on delete cascade

* make multiSelectSchenbox searchable

* set activeOn correctly when checkbox true

* WIP

* fix type errors in MultiSelectCheckboxes

* implemented scheduling org-wide workfow notifications (not yet tested)

* fix type errors

* type error

* add missing changes from merge conflict

* remote not needed include statement

* fix type errors

* code clean up + some todo comments

* support org workflows for cancelled workflows

* delete reminders from removed members

* remove reminders if isActiveOnAll is turned off

* fix unti test and type error

* code clean up

* create basis for testing to book org team event

* create org workflow with active team

* fix getting active org workflows on team + test setup

* fix creating workflow step for tests

* fix first org test

* add test for user event type with org workflow active

* use deleteAllWorkflowReminders everywhere

* add test for deleteRemindersFromRemovedActiveOn

* fix type errors

* make all tests pass

* fix type error

* fix getSchedule test

* code clean up

* add missing import

* fix type error

* fix tests

* code clean up

* fix imports

* update reminders when trigger was changed

* check for teamId before userId in reminderScheduler

* move getOrgIdFromMemberOrTeamId to different folder

* code clean up

* fix tests

* test setup for scheduleBookingReminders

* fix typo

* add tests for scheduleBookingReminders

* fix prisma default import

* fix workflowStep type

* add scheduleBookingReminders test for sms

* return dummy sid for scheduleSMS testMode

* clean up + fixes

* add lost changes from merge

* get teamId and userId from incoming evt object

* removing not needed select

* add org support for scheduleMandatory email reminder

* add other teams to dropdown

* move getAllWorkflows to seperate file and call it in parent function

* include org wide workflows in createNewSeat

* some fixes + code clean up

* add new team to select text count when including future teams is checked

* fix upsert and remove sms reminder field

* correctly update activeOn if 'including future ...' is enabled

* list active Org workflows in event workflows settings

* fix sms reminder field in all handlers

* add helper function to check if step was edited

* fix active on badge on workflow

* fix type error

* fix double reminders

* add teamId: null for userWorkflow query

* fix activeOnAll with managed event types

* add missing teamId in getAllWorkflows

* add a dafaut to prisma param

* fix managed event types on select all user workflows

* code clean up

* better variable name

* small fixes in update handler

* fix test name to match function name

* add info badge

* fix workflow count in event type settings

* fix getting bookings from children manged event types

* delete reminders when user is not part of any time no more

* implement feedback

* fix disbale workflow in event type settings

* fix remove member

* create new function getAllWorkflowsFromEventType

* add some removed code

* use promise.allSettled when deleting workflow reminders

* create new function deleteWorkflowRemindersOfRemovedMember.ts

* fix userId param

* delete org worklfows when team is disbanded

* don't trigger active on all workflow if not part of any team

* fix active on count badge

* add test for deleteWorkflowRemidnersOfRemovedMember

* trigger workflow also if not member of any subteam

* fix failing test

* remove unused code

* use testId for go back button

* fixes for managed event types & activateEventTypeHandler

* code clean up

* don't activate workflow on locked managed event type

* fix type error

* type error

* more type fixes

* feedback

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-07-08 17:44:11 +05:30
Rajiv SahalandRajiv Sahal dca2922ef8 fix: booker atom types (#15685)
Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
2024-07-08 12:00:55 +00:00
MOHD RAZAandAnik Dhabal Babu 8b5ef68652 fix: datePicker skeleton issue solved (#15675)
* datePicker skeleton issue solved

* Update

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-08 17:15:22 +05:30
RushikeshandAnik Dhabal Babu 5b2688dda0 fix: correct message and translation key (#15657)
* fix event_type_requres_google_cal  to requires_google_calendar

* revert the changes

* redirect to google calendar page

* add translation key for "requires_google_calendar_to_install"

* use key and add correct message

* use key

* fix

* revert

* correct message

* refactor event type steup tab UI

* small fix

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-05 14:35:08 +05:30
Udit TakkarandHariom 884ccc9b48 feat: RECORDING_TRANSCRIPTION_GENERATED webhook (#15589)
* feat: RECORDING_TRANSCRIPTION_READY webhook

* refactor: split into diff files and restructuring

* feat: finish batch processor finished

* chore: types

* fix: type error

* chore: change name of triggger

* test: add unit test

* fix: type

* fix: type err

* chore: test

* feat: add booking reference repository and other imp

* chore: log

* fix: import

* chore: type error

* fix: update test

* fix: test

* fix: test

* fix: add timeout

* chore: move beforeEach

* Mock missing env variables to test. These vars are not set in CI

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-07-05 07:39:23 +00:00
7d06d9f865 chore: remove personal branding if in an org and use org branding (#14872)
* chore: remove personal branding if in an org and use org branding

* fix: minor fix

* Update SettingsLayout.tsx

* Update SettingsLayout.tsx

* Update profile.tsx

* some fixes and e2e test

* fix: e2e test

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-07-05 00:35:46 +05:30
785ab8b74a feat: v1 API - add sort option on GET /bookings (#15374)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-04 06:11:47 +00:00
557498b80d feat: embed: remove "Preview" tab and instead split code/preview layout 50/50 vertically (#15492)
* feat: embed: remove "Preview" tab and instead split code/preview layout 50/50 vertically

* fix: use single preview tab

* fix e2e

* add border radius

* final e2e fix

* fix: don't show preview tab component

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-04 09:54:35 +05:30
Rajiv SahalandRajiv Sahal c3a532cff0 fix: platform seed script (#15617)
Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
2024-07-03 15:31:41 +00:00
ab5d4f1d03 chore: update formbricks-js to newest version (#15146)
* chore: update formbricks-js to newest version

* fix: Regenerate yarn.lock with right deps

* update formbricks-js version to 2.1

* Update yarn.lock

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-03 17:27:39 +02:00
VijayandAmit Sharma ae79154b83 fix: assignAllTeamMembers field wrongly set in db (#15654)
* fix for assignAllTeamMembers update

* update for true to false scenario

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

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-03 17:23:14 +02:00
Anik Dhabal BabuandUdit Takkar e33c676ec5 fix: toast message is not visble and some refactor (#15649)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-07-03 18:28:48 +05:30
8cd9285cc6 fix: Remove/Disable Duplicate button in Read Only mode of an event-type (#15511)
* fix: Remove/Disable Duplicate button in Read Only mode of an event-type

* fix: use readonly variable

* fix: hiding for smaller devices

* update test cases

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-03 15:23:22 +05:30
Udit Takkar 4f76d4468e fix: passing booking id in the payload (#15642) 2024-07-03 07:25:14 +00:00
RushikeshandAnik Dhabal Babu 92bf1ee3c7 refactor: remove duplicate object key in common.json (#15626)
* remove duplicate object key in common.json

* Update common.json

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-02 08:42:01 +00:00
e9aec23fbe fix: typo in german translation (#15637)
* fix: #11213, #11215

* Use default colour

* Fix typo in german translation

* fix_the_conflict

---------

Co-authored-by: Siddharth Movaliya <siddraj1992@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-07-02 11:33:23 +05:30
a6a60969df fix: Redirect URL with query params (#13912)
* Fix for  issue #13899 Redirect URL

* Update bookingSuccessRedirect.ts

* Update bookingSuccessRedirect.ts

* fix: url params

* chore: remove unused

* chore: remove comment

* Update

---------

Co-authored-by: Vikas Verma <inboxvikasverma@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-01 22:18:47 +00:00
Atchyut Preetham PulavarthiandAmit91848 e9d828639a fix(@cal.com/web): update debounce logic to work correctly and not show non-premium usernames as premium (#15629)
* fix(@cal.com/web): update debounce logic to work correctly and not show non-premium usernames as premium

- removes redundant useMemo hook from UsernameTextfield.tsx & PremiumTextField.tsx
- cancles the debounceAPICall on every unmount of UsernameTextfield.tsx & PremiumTextfield.tsx via useEffect cleanup function

Fixes #15628

* fix(@cal.com/web): update the debounce logic to use the pre-existing useDebounce hook

- update the debounce logic in UsernameTextField.tsx & PremiumTextField.tsx to use the pre-existing useDebounce hook instead of loadsh debounce
- update the debounce delay to 600ms to be consistent with the signup.tsx behavior

Fixes #15628

* fix: signup debounce not working properly

---------

Co-authored-by: Amit91848 <samit91848@gmail.com>
2024-07-01 15:45:09 +00:00
Amit SharmaandAnik Dhabal Babu 726c938b6d fix: make toast consisten with brand colour (#15638)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-01 15:43:27 +00:00
Rushikesh b43aaaccc2 fix: use correct object keys (#15631)
* use connected_google_calendar translation key

* use requires_google_calendar translation key

* Remove unused ExistingGoogleCal component
2024-07-01 11:28:14 +00:00
Balthasar Huber 0b5f828307 fix: Add fromRedirectOfNonOrgLink in getServerSideProps of private links (#15306) 2024-06-29 09:17:25 +00:00
Anik Dhabal BabuandSomay Chauhan 3807e339ce fix: Provide event rescheduling option before the event end time (#15150)
* feat: Provide event rescheduling option

* update

* update

* add checker function

* refactor and update

* update

* fix type error

* Update bookings-single-view.tsx

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-06-28 19:09:33 +05:30
Hariom Balhara ececef96e5 feat: Support running webapp and embed-core playground on HTTPS. It allows to test SameSite cookie attribute easily in embed (#15272)
* feat: Support running webapp and embed-core playground on HTTPS. It allows to test SameSite attribute easily in embed

* Update yarn.lock

* feat: Support running webapp and embed-core playground on HTTPS. It allows to test SameSite attribute easily in embed

* Update yarn.lock

* Fix git-setup.sh

* Fix yarn.lock
2024-06-28 13:06:59 +01:00
sean-brydonandKeith Williams 50338dcd61 fix: Update pr.yml (#15611)
* Update pr.yml

* Added the types back so PR update triggers when labeled

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-06-28 10:02:50 +00:00
Amit Sharmaandsean-brydon 285d1ec4c9 fix: Autodetection of time zone only updated default time zone (#15392)
* fix: Autodetection of time zone only updated default time zone

* fix: description text

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-28 10:12:33 +01:00
Udit Takkar c458b297ae fix: backward compatibility for old cal rooms (#15609)
* fix: record icon for old rooms

* chore: change function name
2024-06-28 09:42:38 +01:00
Udit TakkarandKeith Williams a52f7efe88 refactor: move functions to separate files (#15590)
* refactor: move functions to separate files

* chore: remove inline function

* chore: type error

* fix: type error

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-06-27 22:05:11 +00:00
3e10e2db36 feat: adds env variable for email sender name (#15598)
* feat: adds env variable for email sender name

* update env declaration file

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-06-27 18:25:13 -03:00
Omar López dfbcf4742f v4.2.3 (#15604)
release.yml / release (push) Canceled after 0s
2024-06-27 22:52:12 +02:00
3a2a70d83a fix: getPublicEventType of NonOrgUser fetch OrgUser event-type of same username (#15587)
* fix: getPublicEventType of NonOrgUser fetch OrgUser event-type with same username

* Fix the test that was supposed to verify the scenario

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2024-06-27 20:32:19 +00:00
3948b8d203 feat: booking no show webhook (#15502)
* WIP

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

* WIP

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

* WIP

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

* Type fixes

* Update webhook.e2e.ts

* Update noShow.handler.ts

* Log failed results

* Updates tests

* Show generic error on frontend.

* test: add basic webhook service test

* fix: webhook end to end test

* fix: type error

* fix: test

---------

Signed-off-by: zomars <zomars@me.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-06-27 20:12:09 +00:00
Keith Williams 6207afdf03 fix: PR update E2E tests problems (#15603) 2024-06-27 19:12:59 +00:00
Lauris Skraucis 52c7a89785 feat: organization schedules endpoints (#15584)
* feat: organization schedules endpoints

* refactor: add IsUserInOrg guard

* refactor: separate schedules controller folder

* tests

* chore: add roles guard and test it

* refactor: dont use Promise.all

* refactor: rely on profiles instead of user.organizationId

* feat: add pagination to getOrganizationSchedules

* refactor: default skip=0 and take=250

* chore: regenerate swagger doc
2024-06-27 19:04:26 +00:00
Hariom BalharaandAlex van Andel dda4ce5338 fix-moving-a-team-with-just-owner (#15595)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-06-27 17:57:48 +00:00
a02747fc52 fix: update z-index for intercom app launcher (#15516)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-27 17:57:29 +00:00
Morgan 03311def07 fix: update version apiv2 platform libraries for createNewUsersConnectToOrgIfExists (#15601) 2024-06-27 17:14:00 +00:00
5e382ffd21 feat: apple calendar connect atom (#15510)
* init apple calendar service

* update apple calendar service logic

* update typing for CalendarApp interface

* add id and type for apple calendar

* fixup: logic for apple calendar service

* update logic for checkIfCalendarConnected function

* add apple calendar service to calendars module

* fixup

* update calendars to include cases for apple calendar

* fix imports

* init frontend for apple calendar connect

* add apple connect atom to connect atom

* fixup

* fixup

* add alias for latest version of platform library

* fix import paths

* custom hook for saving apple calendar credentials

* add apple calendar to examples app

* add interface for credentials syncing calendars

* bring calendars controller to orginal state

* refactor

* update custom hook to sync credentials

* update atom

* resolve merge conflicts

* add endpoint for syncing calendar credentials

* fixup

* update atom

* update useCheck to invalidate queries onSubmit

* fix typo

* fixup

* remove unused tokens respository

* rename controller

* cleanup

* fix: reset react queryClient on access token change

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-06-27 15:51:27 +00:00
Udit Takkar 0e0dfcdd88 fix: reduce cal recording bitrate (#15588)
* fix: reduce cal recording bitrate

* chore: bitrate

* chore: add enable recording ui

* fix: genereate meeting token

* chore: add wait for recording
2024-06-27 15:43:20 +00:00
Morgan e260809b08 feat: organizations teams endpoints apiv2 (#15586)
* feat: organizations teams endpoints apiv2

* fixup! Merge branch 'main' into feat-api-v2-org-teams-endpoints

* chore: add roles guard to endpoints

* add tests

* fixup! Merge branch 'main' into feat-api-v2-org-teams-endpoints

* fixup! fixup! Merge branch 'main' into feat-api-v2-org-teams-endpoints

* fixup! fixup! fixup! Merge branch 'main' into feat-api-v2-org-teams-endpoints
2024-06-27 18:04:37 +02:00
sean-brydonandHariom Balhara 835443a0d1 fix: cancel sub when movingTeamToOrg (#15594)
* fix: cancel sub when movingTeamToOrg

* Cleanup code a little

* Update packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Update packages/trpc/server/routers/viewer/organizations/createTeams.handler.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-06-27 10:38:42 +00:00
Crowdin Bot 48842e72a6 New Crowdin translations by Github Action 2024-06-27 09:58:07 +00:00
Morgan ff8931f4a0 fix: path /me endpoint should capitalize timezone apiv2 (#15593) 2024-06-27 09:41:00 +00:00
Hariom Balhara 6670bbc1d7 fix: Error in team members migration during org onboarding (#15349)
* fix: Error in team members migration during org onboarding

* Add invitationMemberHandler tests

* Add unit tests

* Improve tests and refactor

* Improve tests and refactor

* Fix type issue

* Fix createNewUsersConnectToOrgIfExists args
2024-06-27 00:53:55 +00:00
Alex van Andel 750676f91a chore: Add dynamic import and trigger captureException (#15583) 2024-06-26 22:32:26 +00:00
Sangram Bahadur db60f8faed setting background color to red for remove button in profile section (#15585) 2024-06-26 20:52:20 +00:00
Somay Chauhan 2b3cb20fdf fix: office365video expiry_date (#15561)
* fix: office#65Video expiry_date

* Revert "fix: office#65Video expiry_date"

This reverts commit 6142287805b5c6a40e746126735745a5617334c7.

* Change logger.debug to logger.error for better visibility of all log cases
2024-06-26 18:12:36 +00:00
Morgan 7f1d45bb5d chore: minimum required roles guard api-v2 (#15576)
* chore: minimum required roles guard api-v2

* fixup! chore: minimum required roles guard api-v2

* fixup! fixup! chore: minimum required roles guard api-v2

* fixup! Merge branch 'chore-roles-guard-api-v2' of github.com:calcom/cal.com into chore-roles-guard-api-v2

* fixup! Merge branch 'chore-roles-guard-api-v2' of github.com:calcom/cal.com into chore-roles-guard-api-v2
2024-06-26 18:00:48 +00:00
Lauris Skraucis 6632c26063 fix: exclude web wrappers from dist (#15572) 2024-06-26 14:41:37 +00:00
Keith Williams 97a3189c12 chore: Update PR template (#15581) 2024-06-26 14:29:26 +00:00
Udit Takkar d25595b546 feat: add instant meeting expiry input (#15555)
* feat: add instant meeting expiry input

* fix: type err

* chore: remove type

* chore: update label

* chore: update column name
2024-06-26 13:56:05 +00:00
Keith Williams a28eb63d61 chore: Removed unwanted CI changes that made it into main (#15580) 2024-06-26 13:24:38 +00:00
Omar Lópezandsean-brydon 4f944ec12d hotfix: team billing on batch operations (#15570)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-06-26 12:06:07 +00:00
Keith Williams e33930e5c0 chore: Cache timezones by calcom version (#15556)
* chore: Cache timezones by calcom version

* Fix types
2024-06-26 11:43:02 +00:00
Lauris Skraucis 9613b9da6d chore: IsUserInOrd guard (#15579) 2024-06-26 11:21:39 +00:00
Morgan ec755b142c chore: IsTeamInOrg guard and decorator apiv2 (#15567) 2024-06-26 07:32:49 +00:00
338 changed files with 13034 additions and 5555 deletions
+1
View File
@@ -180,6 +180,7 @@ API_KEY_PREFIX=cal_
# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/
# Configures the global From: header whilst sending emails.
EMAIL_FROM='notifications@yourselfhostedcal.com'
EMAIL_FROM_NAME='Cal.com'
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
# Configuration to receive emails locally (mailhog)
+3 -3
View File
@@ -11,9 +11,9 @@
## Mandatory Tasks (DO NOT REMOVE)
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected)
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com)
- [ ] I have added or modified automated tests that prove my fix is effective or that my feature works (PRs might be rejected if logical changes are not properly tested)
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
## How should this be tested?
+2 -1
View File
@@ -17,6 +17,7 @@ runs:
cache-name: cache-db
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
key-2: ${{ github.event.pull_request.number || github.ref }}
key-3: ${{ github.event.pull_request.head.sha }}
DATABASE_URL: ${{ inputs.DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }}
@@ -24,7 +25,7 @@ runs:
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
with:
path: ${{ inputs.path }}
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
- run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed
if: steps.cache-db.outputs.cache-hit != 'true'
shell: bash
-54
View File
@@ -1,54 +0,0 @@
name: PR Labeled with ready-for-e2e
on:
pull_request_target:
types: [labeled]
branches:
- main
- gh-actions-test-branch
workflow_dispatch:
jobs:
run-e2e-jobs:
name: Run E2E Jobs
runs-on: buildjet-2vcpu-ubuntu-2204
permissions:
pull-requests: read
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- name: Check Label and Retrigger Checks
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const labelName = context.payload.label.name;
const prNumber = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
if (labelName === 'ready-for-e2e') {
console.log(`Running E2E jobs for PR #${prNumber}...`);
const checkRuns = await github.rest.checks.listForRef({
owner,
repo,
ref: context.payload.pull_request.head.sha,
});
for (const check of checkRuns.data.check_runs) {
console.log('check.name', check.name);
if (check.name.includes('e2e')) {
await github.rest.actions.reRunJob({
owner,
repo,
job_id: check.id,
});
console.log(`Triggering job #${check.id}`);
}
}
console.log(`Triggered E2E checks for PR #${prNumber}`);
} else {
console.log(`Label ${labelName} does not trigger re-running checks.`);
}
+17 -15
View File
@@ -2,6 +2,7 @@ name: PR Update
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
branches:
- main
- gh-actions-test-branch
@@ -105,13 +106,6 @@ jobs:
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
integration-test:
name: Tests
needs: [changes, check-label]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
build-api-v1:
name: Production builds
needs: [changes, check-label]
@@ -133,30 +127,37 @@ jobs:
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
integration-test:
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
e2e:
name: e2e
needs: [changes, check-label, build]
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
name: e2e-app-store
needs: [changes, check-label, build]
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
name: e2e-embed
needs: [changes, check-label, build]
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: e2e-embed-react
needs: [changes, check-label, build]
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
@@ -169,6 +170,7 @@ jobs:
required:
needs: [changes, lint, type-check, unit-test, integration-test, check-label, 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
+2
View File
@@ -21,6 +21,8 @@ export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(sch
export const schemaBookingGetParams = z.object({
dateFrom: iso8601.optional(),
dateTo: iso8601.optional(),
order: z.enum(["asc", "desc"]).default("asc"),
sortBy: z.enum(["createdAt", "updatedAt"]).optional(),
});
const schemaBookingEditParams = z
+25 -1
View File
@@ -51,6 +51,18 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
* type: string
* format: email
* example: [john.doe@example.com, jane.doe@example.com]
* - in: query
* name: order
* required: false
* schema:
* type: string
* enum: [asc, desc]
* - in: query
* name: sortBy
* required: false
* schema:
* type: string
* enum: [createdAt, updatedAt]
* operationId: listBookings
* tags:
* - bookings
@@ -177,7 +189,7 @@ export async function handler(req: NextApiRequest) {
isOrganizationOwnerOrAdmin,
pagination: { take, skip },
} = req;
const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query);
const { dateFrom, dateTo, order, sortBy } = schemaBookingGetParams.parse(req.query);
const args: Prisma.BookingFindManyArgs = {};
if (req.query.take && req.query.page) {
@@ -251,6 +263,18 @@ export async function handler(req: NextApiRequest) {
};
}
if (sortBy === "updatedAt") {
args.orderBy = {
updatedAt: order,
};
}
if (sortBy === "createdAt") {
args.orderBy = {
createdAt: order,
};
}
const data = await prisma.booking.findMany(args);
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
}
+2
View File
@@ -26,8 +26,10 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
"@calcom/platform-libraries-0.0.15": "npm:@calcom/platform-libraries@0.0.15",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4",
"@calcom/platform-libraries-0.0.14": "npm:@calcom/platform-libraries@0.0.14",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -7,6 +7,11 @@ export interface CalendarApp {
check(userId: number): Promise<ApiResponse>;
}
export interface CredentialSyncCalendarApp {
save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>;
check(userId: number): Promise<ApiResponse>;
}
export interface OAuthCalendarApp extends CalendarApp {
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
}
@@ -1,4 +1,5 @@
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
@@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
CalendarsService,
OutlookService,
GoogleCalendarService,
AppleCalendarService,
SelectedCalendarsRepository,
AppsRepository,
],
@@ -1,5 +1,6 @@
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
@@ -21,13 +22,22 @@ import {
Headers,
Redirect,
BadRequestException,
Post,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { z } from "zod";
import { APPS_READ } from "@calcom/platform-constants";
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
import {
SUCCESS_STATUS,
CALENDARS,
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
APPLE_CALENDAR,
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
@Controller({
@@ -39,7 +49,8 @@ export class CalendarsController {
constructor(
private readonly calendarsService: CalendarsService,
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService
private readonly googleCalendarService: GoogleCalendarService,
private readonly appleCalendarService: AppleCalendarService
) {}
@UseGuards(ApiAuthGuard)
@@ -133,6 +144,26 @@ export class CalendarsController {
}
}
@UseGuards(ApiAuthGuard)
@Post("/:calendar/credentials")
async syncCredentials(
@GetUser() user: User,
@Param("calendar") calendar: string,
@Body() body: { username: string; password: string }
): Promise<{ status: string }> {
const { username, password } = body;
switch (calendar) {
case APPLE_CALENDAR:
return await this.appleCalendarService.save(user.id, user.email, username, password);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
@Get("/:calendar/check")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@@ -143,6 +174,8 @@ export class CalendarsController {
return await this.outlookService.check(userId);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.check(userId);
case APPLE_CALENDAR:
return await this.appleCalendarService.check(userId);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -0,0 +1,92 @@
import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.14";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
constructor(
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository
) {}
async save(
userId: number,
userEmail: string,
username: string,
password: string
): Promise<{ status: string }> {
return await this.saveCalendarCredentials(userId, userEmail, username, password);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
APPLE_CALENDAR_TYPE,
userId
);
if (!appleCalendarCredentials) {
throw new BadRequestException("Credentials for apple calendar not found.");
}
if (appleCalendarCredentials.invalid) {
throw new BadRequestException("Invalid apple calendar credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const appleCalendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE
);
if (!appleCalendar) {
throw new UnauthorizedException("Apple calendar not connected.");
}
if (appleCalendar.error?.message) {
throw new UnauthorizedException(appleCalendar.error?.message);
}
return {
status: SUCCESS_STATUS,
};
}
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
if (username.length <= 1 || password.length <= 1)
throw new BadRequestException(`Username or password cannot be empty`);
const data = {
type: APPLE_CALENDAR_TYPE,
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
};
try {
const dav = new CalendarService({
id: 0,
...data,
user: { email: userEmail },
});
await dav?.listCalendars();
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
} catch (reason) {
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
}
return {
status: SUCCESS_STATUS,
};
}
}
@@ -17,6 +17,6 @@ import { Module } from "@nestjs/common";
OutputSchedulesService_2024_06_11,
],
controllers: [SchedulesController_2024_06_11],
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11],
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11, OutputSchedulesService_2024_06_11],
})
export class SchedulesModule_2024_06_11 {}
+13
View File
@@ -0,0 +1,13 @@
import { MembershipRole } from "@prisma/client";
export const SYSTEM_ADMIN_ROLE = "SYSADMIN";
export const ORG_ROLES = [
`ORG_${MembershipRole.OWNER}`,
`ORG_${MembershipRole.ADMIN}`,
`ORG_${MembershipRole.MEMBER}`,
] as const;
export const TEAM_ROLES = [
`TEAM_${MembershipRole.OWNER}`,
`TEAM_${MembershipRole.ADMIN}`,
`TEAM_${MembershipRole.MEMBER}`,
] as const;
@@ -0,0 +1,33 @@
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
import { Team } from "@calcom/prisma/client";
export type GetTeamReturnType = Team;
export const GetTeam = createParamDecorator<
keyof GetTeamReturnType | (keyof GetTeamReturnType)[],
ExecutionContext
>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const team = request.team as GetTeamReturnType;
if (!team) {
throw new Error("GetTeam decorator : Team not found");
}
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
...prev,
[curr]: team[curr],
};
}, {});
}
if (data) {
return team[data];
}
return team;
});
@@ -0,0 +1,4 @@
import { Reflector } from "@nestjs/core";
import { MembershipRole } from "@prisma/client";
export const MembershipRoles = Reflector.createDecorator<MembershipRole[]>();
@@ -1,4 +1,6 @@
import { SYSTEM_ADMIN_ROLE, ORG_ROLES, TEAM_ROLES } from "@/lib/roles/constants";
import { Reflector } from "@nestjs/core";
import { MembershipRole } from "@prisma/client";
export const Roles = Reflector.createDecorator<MembershipRole[]>();
export const Roles = Reflector.createDecorator<
(typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number] | typeof SYSTEM_ADMIN_ROLE
>();
@@ -1,4 +1,4 @@
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -27,7 +27,7 @@ export class OrganizationRolesGuard implements CanActivate {
await this.isPlatform(organizationId);
const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id);
const allowedRoles = this.reflector.get(Roles, context.getHandler());
const allowedRoles = this.reflector.get(MembershipRoles, context.getHandler());
this.isMembershipAccepted(membership.accepted);
this.isRoleAllowed(membership.role, allowedRoles);
@@ -0,0 +1,163 @@
import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class RolesGuard implements CanActivate {
private readonly logger = new Logger("RolesGuard Logger");
constructor(
private reflector: Reflector,
private membershipRepository: MembershipsRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const allowedRole = this.reflector.get(Roles, context.getHandler());
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:roles:${allowedRole}`;
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return cachedAccess;
}
let canAccess = false;
// User is not authenticated
if (!user) {
this.logger.log("User is not authenticated, denying access.");
canAccess = false;
}
// System admin can access everything
else if (user.isSystemAdmin) {
this.logger.log(`User (${user.id}) is system admin, allowing access.`);
canAccess = true;
}
// if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false
else if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) {
this.logger.log(`User (${user.id}) is not system admin, denying access.`);
canAccess = false;
}
// Checking the role of the user within the organization
else if (Boolean(orgId) && !Boolean(teamId)) {
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not a member of the organization.`);
}
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
canAccess = hasMinimumRole({
checkRole: `ORG_${membership.role}`,
minimumRole: allowedRole,
roles: ORG_ROLES,
});
}
}
// Checking the role of the user within the team
else if (Boolean(teamId) && !Boolean(orgId)) {
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
throw new ForbiddenException(`User is not a member of the team.`);
}
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
canAccess = hasMinimumRole({
checkRole: `TEAM_${membership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
}
// Checking the role for team and org, org is above team in term of permissions
else if (Boolean(teamId) && Boolean(orgId)) {
const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
if (!orgMembership) {
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not part of the organization.`);
}
// if the role checked is a TEAM role
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
// if the user is admin or owner of org, allow request because org > team
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
canAccess = true;
}
if (!teamMembership) {
this.logger.log(
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
);
throw new ForbiddenException(
"User is not part of the team and/or, is not an admin nor an owner of the organization."
);
}
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
canAccess = hasMinimumRole({
checkRole: `TEAM_${teamMembership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
// if allowed role is a ORG ROLE, check org membersip role
else if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
canAccess = hasMinimumRole({
checkRole: `ORG_${orgMembership.role}`,
minimumRole: allowedRole,
roles: ORG_ROLES,
});
}
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 3600);
return canAccess;
}
}
type Roles = (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number];
type HasMinimumTeamRoleProp = {
checkRole: (typeof TEAM_ROLES)[number];
minimumRole: string;
roles: typeof TEAM_ROLES;
};
type HasMinimumOrgRoleProp = {
checkRole: (typeof ORG_ROLES)[number];
minimumRole: string;
roles: typeof ORG_ROLES;
};
type HasMinimumRoleProp = HasMinimumTeamRoleProp | HasMinimumOrgRoleProp;
export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
const checkedRoleIndex = props.roles.indexOf(props.checkRole as never);
const requiredRoleIndex = props.roles.indexOf(props.minimumRole as never);
// minimum role given does not exist
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
throw new Error("Invalid role");
}
return checkedRoleIndex <= requiredRoleIndex;
}
@@ -0,0 +1,43 @@
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsTeamInOrg implements CanActivate {
constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId: string = request.params.teamId;
const orgId: string = request.params.orgId;
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
}
if (!teamId) {
throw new ForbiddenException("No team id found in request params.");
}
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
if (team && !team.isOrganization && team.parentId === Number(orgId)) {
request.team = team;
return true;
}
if (!team) {
throw new NotFoundException(`Team (${teamId}) not found.`);
}
return false;
}
}
@@ -0,0 +1,33 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsUserInOrg implements CanActivate {
constructor(private organizationsRepository: OrganizationsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const orgId: string = request.params.orgId;
const userId: string = request.params.userId;
if (!userId) {
throw new ForbiddenException("No user id found in request params.");
}
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
}
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
if (user) {
request.user = user;
return true;
}
return false;
}
}
@@ -1,6 +1,6 @@
import { AppConfig } from "@/config/type";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
@@ -47,7 +47,7 @@ export class BillingController {
@Get("/:teamId/check")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN", "MEMBER"])
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
async checkTeamBilling(
@Param("teamId") teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
@@ -64,7 +64,7 @@ export class BillingController {
@Post("/:teamId/subscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
@MembershipRoles(["OWNER", "ADMIN"])
async subscribeTeamToStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
@@ -18,6 +18,23 @@ export class MembershipsRepository {
return membership;
}
async findMembershipByTeamId(teamId: number, userId: number) {
const membership = await this.dbRead.prisma.membership.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
return membership;
}
async findMembershipByOrgId(orgId: number, userId: number) {
return this.findMembershipByTeamId(orgId, userId);
}
async isUserOrganizationAdmin(userId: number, organizationId: number) {
const adminMembership = await this.dbRead.prisma.membership.findFirst({
where: {
@@ -1,7 +1,7 @@
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 { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -64,7 +64,7 @@ export class OAuthClientsController {
@Post("/")
@HttpCode(HttpStatus.CREATED)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
@DocsCreatedResponse({
description: "Create an OAuth client",
@@ -96,7 +96,7 @@ export class OAuthClientsController {
@Get("/")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
@@ -107,7 +107,7 @@ export class OAuthClientsController {
@Get("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientById(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
const client = await this.oauthClientRepository.getOAuthClient(clientId);
@@ -119,7 +119,7 @@ export class OAuthClientsController {
@Get("/:clientId/managed-users")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientManagedUsersById(
@Param("clientId") clientId: string,
@@ -137,7 +137,7 @@ export class OAuthClientsController {
@Patch("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async updateOAuthClient(
@Param("clientId") clientId: string,
@@ -150,7 +150,7 @@ export class OAuthClientsController {
@Delete("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async deleteOAuthClient(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
this.logger.log(`Deleting OAuth Client with ID: ${clientId}`);
@@ -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-0.0.2";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.15";
@Injectable()
export class OAuthClientUsersService {
@@ -36,17 +36,17 @@ export class OAuthClientUsersService {
const email = this.getOAuthUserEmail(oAuthClientId, body.email);
user = (
await createNewUsersConnectToOrgIfExists({
usernamesOrEmails: [email],
input: {
teamId: organizationId,
role: "MEMBER",
usernameOrEmail: [email],
isOrg: true,
language: "en",
},
invitations: [
{
usernameOrEmail: email,
role: "MEMBER",
},
],
teamId: organizationId,
isOrg: true,
parentId: null,
autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users",
connectionInfoMap: {
orgConnectInfoByUsernameOrEmail: {
[email]: {
orgId: organizationId,
autoAccept: true,
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -8,6 +9,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
@@ -17,14 +19,20 @@ import { ApiSuccessResponse } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Organizations Team Endpoints", () => {
describe("User Authentication", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let org: Team;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
const userEmail = "org-teams-controller-e2e@api.com";
let org: Team;
let team: Team;
let team2: Team;
let teamCreatedViaApi: Team;
const userEmail = "org-admin-teams-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
@@ -37,6 +45,8 @@ describe("Organizations Team Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -48,6 +58,24 @@ describe("Organizations Team Endpoints", () => {
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
team2 = await teamsRepositoryFixture.create({
name: "Test org team 2",
isOrganization: false,
parent: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -68,12 +96,336 @@ describe("Organizations Team Endpoints", () => {
.then((response) => {
const responseBody: ApiSuccessResponse<Team[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toEqual([]);
expect(responseBody.data[0].id).toEqual(team.id);
expect(responseBody.data[1].id).toEqual(team2.id);
});
});
it("should get all the teams of the org paginated", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(team2.id);
});
});
it("should fail if org does not exist", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/120494059/teams`).expect(403);
});
it("should get the team of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(team.id);
expect(responseBody.data.parentId).toEqual(team.parentId);
});
});
it("should create the team of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
} satisfies CreateOrgTeamDto)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API");
expect(teamCreatedViaApi.parentId).toEqual(org.id);
});
});
it("should update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.send({
name: "Team created via API Updated",
} satisfies CreateOrgTeamDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API Updated");
expect(teamCreatedViaApi.parentId).toEqual(org.id);
});
});
it("should delete the team of the org we created via api", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(teamCreatedViaApi.id);
expect(responseBody.data.parentId).toEqual(teamCreatedViaApi.parentId);
});
});
it("should fail to get the team of the org we just deleted", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.expect(404);
});
it("should fail if the team does not exist", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
describe("Organizations Team Endpoints", () => {
describe("User Authentication - User is Org Member", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let team: Team;
let team2: Team;
const userEmail = "org-member-teams-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
team2 = await teamsRepositoryFixture.create({
name: "Test org team 2",
isOrganization: false,
parent: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should deny get all the teams of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
});
it("should deny get all the teams of the org paginated", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
});
it("should deny get the team of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(403);
});
it("should deny create the team of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}`)
.send({
name: "Team created via API Updated",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny delete the team of the org we created via api", async () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
describe("Organizations Team Endpoints", () => {
describe("User Authentication - User is Team Owner", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let team: Team;
let team2: Team;
const userEmail = "org-member-teams-owner-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
team2 = await teamsRepositoryFixture.create({
name: "Test org team 2",
isOrganization: false,
parent: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: team.id } },
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: team2.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should deny get all the teams of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
});
it("should deny get all the teams of the org paginated", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
});
it("should get the team of the org for which the user is team owner", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(200);
});
it("should deny create the team of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}`)
.send({
name: "Team created via API Updated",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny delete the team of the org we created via api", async () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
@@ -1,31 +1,106 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { OrgTeamOutputDto } from "@/modules/organizations/outputs/organization-team.output";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
import { ApiResponse, SkipTakePagination } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
@Controller({
path: "/v2/organizations/:orgId/teams",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
@Get()
@UseGuards()
@Roles("ORG_ADMIN")
async getAllTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@GetOrg() organization: Team,
@GetOrg("name") orgName: string
): Promise<ApiResponse<Team[]>> {
console.log(orgId, organization, orgName);
@Query() queryParams: SkipTakePagination
): Promise<ApiResponse<OrgTeamOutputDto[]>> {
const { skip, take } = queryParams;
const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250);
return {
status: SUCCESS_STATUS,
data: [],
data: teams.map((team) => plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" })),
};
}
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@Get("/:teamId")
async getTeam(@GetTeam() team: Team): Promise<ApiResponse<OrgTeamOutputDto>> {
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@Delete("/:teamId")
async deleteTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<OrgTeamOutputDto>> {
const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@Patch("/:teamId")
async updateTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: CreateOrgTeamDto
): Promise<ApiResponse<OrgTeamOutputDto>> {
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@Post()
@UseGuards()
@Roles("ORG_ADMIN")
async createTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreateOrgTeamDto
): Promise<ApiResponse<OrgTeamOutputDto>> {
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
}
@@ -0,0 +1,155 @@
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
import {
Controller,
UseGuards,
Get,
Post,
Param,
ParseIntPipe,
Body,
Patch,
Delete,
HttpCode,
HttpStatus,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsNumber, Min, Max, IsOptional } from "class-validator";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateScheduleInput_2024_06_11,
CreateScheduleOutput_2024_06_11,
DeleteScheduleOutput_2024_06_11,
GetScheduleOutput_2024_06_11,
GetSchedulesOutput_2024_06_11,
UpdateScheduleInput_2024_06_11,
UpdateScheduleOutput_2024_06_11,
} from "@calcom/platform-types";
class SkipTakePagination {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(1)
@Max(250)
@IsOptional()
take?: number;
@Transform(({ value }: { value: string }) => value && parseInt(value))
@IsNumber()
@Min(0)
@IsOptional()
skip?: number;
}
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Schedules")
export class OrganizationsSchedulesController {
constructor(
private schedulesService: SchedulesService_2024_06_11,
private organizationScheduleService: OrganizationsSchedulesService
) {}
@Roles("ORG_ADMIN")
@Get("/schedules")
async getOrganizationSchedules(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const schedules = await this.organizationScheduleService.getOrganizationSchedules(orgId, skip, take);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Post("/users/:userId/schedules")
async createUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Body() bodySchedule: CreateScheduleInput_2024_06_11
): Promise<CreateScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.createUserSchedule(userId, bodySchedule);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules/:scheduleId")
async getUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Param("scheduleId") scheduleId: number
): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserSchedule(userId, scheduleId);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules")
async getUserSchedules(
@Param("userId", ParseIntPipe) userId: number
): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(userId);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Patch("/users/:userId/schedules/:scheduleId")
async updateUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Param("scheduleId", ParseIntPipe) scheduleId: number,
@Body() bodySchedule: UpdateScheduleInput_2024_06_11
): Promise<UpdateScheduleOutput_2024_06_11> {
const updatedSchedule = await this.schedulesService.updateUserSchedule(userId, scheduleId, bodySchedule);
return {
status: SUCCESS_STATUS,
data: updatedSchedule,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Delete("/users/:userId/schedules/:scheduleId")
@HttpCode(HttpStatus.OK)
async deleteUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Param("scheduleId", ParseIntPipe) scheduleId: number
): Promise<DeleteScheduleOutput_2024_06_11> {
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
return {
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,338 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateScheduleInput_2024_06_11,
CreateScheduleOutput_2024_06_11,
GetScheduleOutput_2024_06_11,
GetSchedulesOutput_2024_06_11,
ScheduleAvailabilityInput_2024_06_11,
ScheduleOutput_2024_06_11,
UpdateScheduleInput_2024_06_11,
UpdateScheduleOutput_2024_06_11,
} from "@calcom/platform-types";
import { User, Team, Membership, Profile } from "@calcom/prisma/client";
describe("Organizations Schedules Endpoints", () => {
describe("User lacks required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "mr-robot@schedules-api.com";
let user: User;
let org: Team;
let membership: Membership;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "Ecorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should not be able to create schedule for org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
.send({
name: "work",
timeZone: "Europe/Rome",
isDefault: true,
})
.expect(403);
});
it("should not be able to get org schedules", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/schedules`).expect(403);
});
it("should mot be able to get user schedules", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
.expect(403);
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
describe("User has required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const userEmail = "mr-robot@schedules-api.com";
const username = "mr-robot";
let user: User;
let org: Team;
let profile: Profile;
let membership: Membership;
let createdSchedule: ScheduleOutput_2024_06_11;
const createScheduleInput: CreateScheduleInput_2024_06_11 = {
name: "work",
timeZone: "Europe/Rome",
isDefault: true,
};
const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
];
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "Ecorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
profile = await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: username,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should create schedule for org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
.send(createScheduleInput)
.expect(201)
.then(async (response) => {
const responseBody: CreateScheduleOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
createdSchedule = response.body.data;
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1);
const scheduleOwner = createdSchedule.ownerId
? await userRepositoryFixture.get(createdSchedule.ownerId)
: null;
expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id);
});
});
function outputScheduleMatchesExpected(
outputSchedule: ScheduleOutput_2024_06_11 | null,
expected: CreateScheduleInput_2024_06_11 & {
availability: CreateScheduleInput_2024_06_11["availability"];
} & {
overrides: CreateScheduleInput_2024_06_11["overrides"];
},
expectedAvailabilityLength: number
) {
expect(outputSchedule).toBeTruthy();
expect(outputSchedule?.name).toEqual(expected.name);
expect(outputSchedule?.timeZone).toEqual(expected.timeZone);
expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
const outputScheduleAvailability = outputSchedule?.availability[0];
expect(outputScheduleAvailability).toBeDefined();
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
}
it("should get org schedules", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/schedules`)
.expect(200)
.then(async (response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const schedules = response.body.data;
expect(schedules.length).toEqual(1);
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
});
});
it("should get org user schedule", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
.expect(200)
.then(async (response) => {
const responseBody: GetScheduleOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const schedule = response.body.data;
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(schedule, expectedSchedule, 1);
});
});
it("should get user schedules", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
.expect(200)
.then(async (response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const schedules = response.body.data;
expect(schedules.length).toEqual(1);
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
});
});
it("should update user schedule name", async () => {
const newScheduleName = "updated-schedule-name";
const body: UpdateScheduleInput_2024_06_11 = {
name: newScheduleName,
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
.send(body)
.expect(200)
.then((response: any) => {
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
const responseSchedule = responseData.data;
const expectedSchedule = { ...createdSchedule, name: newScheduleName };
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
createdSchedule = responseSchedule;
});
});
it("should delete user schedule", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
.expect(200);
});
afterAll(async () => {
await profileRepositoryFixture.delete(profile.id);
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,75 @@
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class CreateOrgTeamDto {
@IsString()
@Length(1)
readonly name!: string;
@IsOptional()
@IsString()
readonly slug?: string;
@IsOptional()
@IsUrl()
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
readonly bio?: string;
@IsOptional()
@IsBoolean()
readonly hideBranding?: boolean = false;
@IsOptional()
@IsBoolean()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
readonly hideBookATeamMember?: boolean;
@IsOptional()
@IsString()
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
@IsOptional()
@IsString()
readonly theme?: string;
@IsOptional()
@IsString()
readonly brandColor?: string;
@IsOptional()
@IsString()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
readonly bannerUrl?: string;
@IsOptional()
@IsString()
readonly timeFormat?: number;
@IsOptional()
@IsString()
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
readonly weekStart?: string = "Sunday";
}
@@ -1,14 +1,31 @@
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/organizations-teams.controller";
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, StripeModule],
providers: [OrganizationsRepository, OrganizationsService],
exports: [OrganizationsService, OrganizationsRepository],
controllers: [OrganizationsTeamsController],
imports: [PrismaModule, StripeModule, SchedulesModule_2024_06_11, UsersModule, RedisModule],
providers: [
OrganizationsRepository,
OrganizationsTeamsRepository,
OrganizationsService,
OrganizationsTeamsService,
MembershipsRepository,
OrganizationsSchedulesService,
OrganizationSchedulesRepository,
],
exports: [OrganizationsService, OrganizationsRepository, OrganizationsTeamsRepository],
controllers: [OrganizationsTeamsController, OrganizationsSchedulesController],
})
export class OrganizationsModule {}
@@ -70,4 +70,12 @@ export class OrganizationsRepository {
},
});
}
async findOrgUser(organizationId: number, userId: number) {
return this.dbRead.prisma.user.findUnique({
where: {
id: userId,
organizationId,
},
});
}
}
@@ -0,0 +1,107 @@
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class OrgTeamOutputDto {
@IsInt()
@Expose()
readonly id!: number;
@IsInt()
@IsOptional()
@Expose()
readonly parentId?: number;
@IsString()
@Length(1)
@Expose()
readonly name!: string;
@IsOptional()
@IsString()
@Expose()
readonly slug?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
@Expose()
readonly bio?: string;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBranding?: boolean;
@IsBoolean()
@Expose()
readonly isOrganization?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
@Expose()
readonly hideBookATeamMember?: boolean = false;
@IsOptional()
@IsString()
@Expose()
readonly metadata?: string;
@IsOptional()
@IsString()
@Expose()
readonly theme?: string;
@IsOptional()
@IsString()
@Expose()
readonly brandColor?: string;
@IsOptional()
@IsString()
@Expose()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
@Expose()
readonly bannerUrl?: string;
@IsOptional()
@IsString()
@Expose()
readonly timeFormat?: number;
@IsOptional()
@IsString()
@Expose()
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@Expose()
readonly weekStart?: string = "Sunday";
}
@@ -0,0 +1,23 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationSchedulesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getSchedulesByUserIds(userIds: number[], skip: number, take: number) {
return this.dbRead.prisma.schedule.findMany({
where: {
userId: {
in: userIds,
},
},
include: {
availability: true,
},
skip,
take,
});
}
}
@@ -0,0 +1,58 @@
import { PlatformPlan } from "@/modules/billing/types";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { StripeService } from "@/modules/stripe/stripe.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsTeamsRepository {
constructor(
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly stripeService: StripeService
) {}
async findOrgTeam(organizationId: number, teamId: number) {
return this.dbRead.prisma.team.findUnique({
where: {
id: teamId,
isOrganization: false,
parentId: organizationId,
},
});
}
async deleteOrgTeam(organizationId: number, teamId: number) {
return this.dbRead.prisma.team.delete({
where: {
id: teamId,
isOrganization: false,
parentId: organizationId,
},
});
}
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
return this.dbRead.prisma.team.create({
data: { ...data, parentId: organizationId },
});
}
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
return this.dbRead.prisma.team.update({
data: { ...data },
where: { id: teamId, parentId: organizationId, isOrganization: false },
});
}
async findOrgTeamsPaginated(organizationId: number, skip: number, take: number) {
return this.dbRead.prisma.team.findMany({
where: {
parentId: organizationId,
},
skip,
take,
});
}
}
@@ -0,0 +1,30 @@
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsSchedulesService {
constructor(
private readonly organizationSchedulesService: OrganizationSchedulesRepository,
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
private readonly usersRepository: UsersRepository
) {}
async getOrganizationSchedules(organizationId: number, skip = 0, take = 250) {
const users = await this.usersRepository.getOrganizationUsers(organizationId);
const usersIds = users.map((user) => user.id);
const schedules = await this.organizationSchedulesService.getSchedulesByUserIds(usersIds, skip, take);
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
for (const schedule of schedules) {
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
}
return responseSchedules;
}
}
@@ -0,0 +1,28 @@
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsTeamsService {
constructor(private readonly organizationsTeamRepository: OrganizationsTeamsRepository) {}
async getPaginatedOrgTeams(organizationId: number, skip = 0, take = 250) {
const teams = await this.organizationsTeamRepository.findOrgTeamsPaginated(organizationId, skip, take);
return teams;
}
async deleteOrgTeam(organizationId: number, teamId: number) {
const team = await this.organizationsTeamRepository.deleteOrgTeam(organizationId, teamId);
return team;
}
async updateOrgTeam(organizationId: number, teamId: number, data: CreateOrgTeamDto) {
const team = await this.organizationsTeamRepository.updateOrgTeam(organizationId, teamId, data);
return team;
}
async createOrgTeam(organizationId: number, data: CreateOrgTeamDto) {
const team = await this.organizationsTeamRepository.createOrgTeam(organizationId, data);
return team;
}
}
@@ -1,3 +1,4 @@
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator";
@@ -31,5 +32,6 @@ export class UpdateManagedUserInput {
@IsTimeZone()
@IsOptional()
@CapitalizeTimeZone()
timeZone?: string;
}
@@ -181,6 +181,18 @@ export class UsersRepository {
return user?.defaultScheduleId;
}
async getOrganizationUsers(organizationId: number) {
const profiles = await this.dbRead.prisma.profile.findMany({
where: {
organizationId,
},
include: {
user: true,
},
});
return profiles.map((profile) => profile.user);
}
}
function capitalizeTimezone(timezone: string) {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma } from "@prisma/client";
export class ProfileRepositoryFixture {
private primaReadClient: PrismaReadService["prisma"];
private prismaWriteClient: PrismaWriteService["prisma"];
constructor(private readonly module: TestingModule) {
this.primaReadClient = module.get(PrismaReadService).prisma;
this.prismaWriteClient = module.get(PrismaWriteService).prisma;
}
async get(profileId: number) {
return this.primaReadClient.profile.findFirst({ where: { id: profileId } });
}
async create(data: Prisma.ProfileCreateInput) {
return this.prismaWriteClient.profile.create({ data });
}
async delete(profileId: number) {
return this.prismaWriteClient.profile.delete({ where: { id: profileId } });
}
}
+2
View File
@@ -68,3 +68,5 @@ public/embed
# Copied app-store images
public/app-store
certificates
+9 -14
View File
@@ -1,17 +1,12 @@
import NotFoundPage from "@pages/404";
import { WithLayout } from "app/layoutHOC";
import type { GetStaticPropsContext } from "next";
import React from "react";
import { getTranslations } from "@server/lib/getTranslations";
const getData = async (context: GetStaticPropsContext) => {
const i18n = await getTranslations(context);
return {
i18n,
};
const NotFound = () => {
return (
<div data-testid="404-page">
<h1>404 - Page Not Found</h1>
<p>Sorry, the page you are looking for does not exist.</p>
</div>
);
};
export const dynamic = "force-static";
export default WithLayout({ getLayout: null, getData, Page: NotFoundPage });
export default NotFound;
+14 -3
View File
@@ -5,7 +5,7 @@ import { InstallAppButton } from "@calcom/app-store/components";
import { getEventLocationTypeFromApp, type EventLocationType } from "@calcom/app-store/locations";
import type { CredentialOwner } from "@calcom/app-store/types";
import { AppSetDefaultLinkDialog } from "@calcom/features/apps/components/AppSetDefaultLinkDialog";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { BulkEditDefaultModal } from "@calcom/features/eventtypes/components/BulkEditDefaultModal";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import { trpc, type RouterOutputs } from "@calcom/trpc";
@@ -153,6 +153,8 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
});
const { t } = useLocale();
const [eventTypeIds, setEventTypeIds] = useState<number[]>([]);
const { data: bulkEventTypes } = trpc.viewer.eventTypes.bulkEventFetch.useQuery();
const updateLocationsMutation = trpc.viewer.eventTypes.bulkUpdateToDefaultLocation.useMutation({
onSuccess: () => {
utils.viewer.getUsersDefaultConferencingApp.invalidate();
@@ -178,11 +180,20 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
)}
{bulkUpdateModal && (
<BulkEditDefaultForEventsModal
bulkUpdateFunction={updateLocationsMutation.mutate}
<BulkEditDefaultModal
handleSubmit={() => {
updateLocationsMutation.mutate({
eventTypeIds,
});
}}
open={bulkUpdateModal}
setOpen={setBulkUpdateModal}
isPending={updateLocationsMutation.isPending}
title={t("default_conferencing_bulk_title")}
description={t("default_conferencing_bulk_description")}
data={bulkEventTypes?.eventTypes}
ids={eventTypeIds}
setIds={setEventTypeIds}
/>
)}
</>
+48 -29
View File
@@ -1,12 +1,16 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import type { IframeHTMLAttributes } from "react";
import React, { useEffect, useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import classNames from "@calcom/lib/classNames";
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS } from "@calcom/lib/constants";
import { APP_NAME, COMPANY_NAME, SUPPORT_MAIL_ADDRESS, WEBAPP_URL } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -22,7 +26,6 @@ export type AppPageProps = {
isGlobal?: AppType["isGlobal"];
logo: string;
slug: string;
dirName: string | undefined;
variant: string;
body: React.ReactNode;
categories: string[];
@@ -70,9 +73,9 @@ export const AppPage = ({
dependencies,
concurrentMeetings,
paid,
dirName,
}: AppPageProps) => {
const { t, i18n } = useLocale();
const router = useRouter();
const searchParams = useCompatSearchParams();
const hasDescriptionItems = descriptionItems && descriptionItems.length > 0;
@@ -80,13 +83,49 @@ export const AppPage = ({
const mutation = useAddAppMutation(null, {
onSuccess: (data) => {
if (data?.setupPending) return;
setIsLoading(false);
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
setIsLoading(false);
},
});
/**
* @todo Refactor to eliminate the isLoading state by using mutation.isPending directly.
* Currently, the isLoading state is used to manage the loading indicator due to the delay in loading the next page,
* which is caused by heavy queries in getServersideProps. This causes the loader to turn off before the page changes.
*/
const [isLoading, setIsLoading] = useState<boolean>(mutation.isPending);
const handleAppInstall = () => {
setIsLoading(true);
if (isConferencing(categories)) {
mutation.mutate({
type,
variant,
slug,
returnTo:
WEBAPP_URL +
getAppOnboardingUrl({
slug,
step: AppOnboardingSteps.EVENT_TYPES_STEP,
}),
});
} else if (
!doesAppSupportTeamInstall({
appCategories: categories,
concurrentMeetings: concurrentMeetings,
isPaid: !!paid,
})
) {
mutation.mutate({ type });
} else {
router.push(getAppOnboardingUrl({ slug, step: AppOnboardingSteps.ACCOUNTS_STEP }));
}
};
const priceInDollar = Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
@@ -220,23 +259,12 @@ export const AppPage = ({
props = {
...props,
onClick: () => {
mutation.mutate({ type, variant, slug });
handleAppInstall();
},
loading: mutation.isPending,
loading: isLoading,
};
}
return (
<InstallAppButtonChild
appCategories={categories}
userAdminTeams={appDbQuery.data?.userAdminTeams}
addAppMutationInput={{ type, variant, slug }}
multiInstall
concurrentMeetings={concurrentMeetings}
paid={paid}
dirName={dirName}
{...props}
/>
);
return <InstallAppButtonChild multiInstall paid={paid} {...props} />;
}}
/>
)}
@@ -260,22 +288,13 @@ export const AppPage = ({
props = {
...props,
onClick: () => {
mutation.mutate({ type, variant, slug });
handleAppInstall();
},
loading: mutation.isPending,
loading: isLoading,
};
}
return (
<InstallAppButtonChild
appCategories={categories}
userAdminTeams={appDbQuery.data?.userAdminTeams}
addAppMutationInput={{ type, variant, slug }}
credentials={appDbQuery.data?.credentials}
concurrentMeetings={concurrentMeetings}
paid={paid}
dirName={dirName}
{...props}
/>
<InstallAppButtonChild credentials={appDbQuery.data?.credentials} paid={paid} {...props} />
);
}}
/>
@@ -1,77 +1,22 @@
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";
import type { AppFrontendPayload } from "@calcom/types/App";
import type { ButtonProps } from "@calcom/ui";
import {
Avatar,
Button,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuTrigger,
showToast,
} from "@calcom/ui";
import { Button } from "@calcom/ui";
export const InstallAppButtonChild = ({
userAdminTeams,
addAppMutationInput,
appCategories,
multiInstall,
credentials,
concurrentMeetings,
dirName,
paid,
onClick,
...props
}: {
userAdminTeams?: UserAdminTeams;
addAppMutationInput: { type: AppFrontendPayload["type"]; variant: string; slug: string };
appCategories: string[];
multiInstall?: boolean;
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) => {
if (data?.setupPending) return;
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
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
@@ -79,7 +24,6 @@ export const InstallAppButtonChild = ({
return (
<Button
data-testid="install-app-button"
onClick={_onClick}
{...props}
disabled={shouldDisableInstallation}
color="primary"
@@ -89,97 +33,14 @@ export const InstallAppButtonChild = ({
);
}
if (
!userAdminTeams?.length ||
!doesAppSupportTeamInstall({ appCategories, concurrentMeetings, isPaid: !!paid })
) {
return (
<Button
data-testid="install-app-button"
onClick={_onClick}
{...props}
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
color="primary"
disabled={shouldDisableInstallation}
size="base">
{multiInstall ? t("install_another") : t("install_app")}
</Button>
);
}
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>
<Button
data-testid="install-app-button"
{...props}
// @TODO: Overriding color and size prevent us from
// having to duplicate InstallAppButton for now.
color="primary"
size="base">
{multiInstall ? t("install_another") : t("install_app")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
className="w-auto"
onInteractOutside={(event) => {
if (mutation.isPending) event.preventDefault();
}}>
{mutation.isPending && (
<div className="z-1 fixed inset-0 flex items-center justify-center">
<Spinner />
</div>
)}
<DropdownMenuLabel>{t("install_app_on")}</DropdownMenuLabel>
{userAdminTeams.map((team) => {
const isInstalled =
credentials &&
credentials.some((credential) =>
credential?.teamId ? credential?.teamId === team.id : credential.userId === team.id
);
return (
<DropdownItem
className="flex"
type="button"
data-testid={team.isUser ? "install-app-button-personal" : "anything else"}
key={team.id}
disabled={isInstalled}
CustomStartIcon={
<Avatar
alt={team.logoUrl || ""}
imageSrc={getPlaceholderAvatar(team.logoUrl, team.name)} // if no image, use default avatar
size="sm"
/>
}
onClick={() => {
mutation.mutate(
team.isUser ? addAppMutationInput : { ...addAppMutationInput, teamId: team.id }
);
}}>
<p>
{t(team.name)} {isInstalled && `(${t("installed")})`}
</p>
</DropdownItem>
);
})}
</DropdownMenuContent>
</DropdownMenuPortal>
</Dropdown>
<Button
data-testid="install-app-button"
disabled={shouldDisableInstallation}
color="primary"
size="base"
{...props}>
{multiInstall ? t("install_another") : t("install_app")}
</Button>
);
};
@@ -2,7 +2,7 @@ import type { FC } from "react";
import React, { useState } from "react";
import { classNames } from "@calcom/lib";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Team, User } from "@calcom/prisma/client";
import { Avatar, StepCard } from "@calcom/ui";
@@ -31,7 +31,7 @@ const AccountSelector: FC<AccountSelectorProps> = ({
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"
selected && loading && "bg-muted animate-pulse"
)}
data-testid={testId}
onClick={() => {
@@ -42,7 +42,7 @@ const AccountSelector: FC<AccountSelectorProps> = ({
}}>
<Avatar
alt={avatar || ""}
imageSrc={avatar || `${CAL_URL}/${avatar}`} // if no image, use default avatar
imageSrc={getPlaceholderAvatar(avatar, name)} // if no image, use default avatar
size="sm"
/>
<div className="text-md pt-0.5 font-medium text-gray-500">
@@ -60,13 +60,20 @@ export type TeamsProp = (Pick<Team, "id" | "name" | "logoUrl"> & {
})[];
type AccountStepCardProps = {
teams: TeamsProp;
teams?: TeamsProp;
personalAccount: PersonalAccountProps;
onSelect: (id?: number) => void;
loading: boolean;
installableOnTeams: boolean;
};
export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAccount, onSelect, loading }) => {
export const AccountsStepCard: FC<AccountStepCardProps> = ({
teams,
personalAccount,
onSelect,
loading,
installableOnTeams,
}) => {
const { t } = useLocale();
return (
<StepCard>
@@ -80,17 +87,18 @@ export const AccountsStepCard: FC<AccountStepCardProps> = ({ teams, personalAcco
onClick={() => onSelect()}
loading={loading}
/>
{teams.map((team) => (
<AccountSelector
key={team.id}
testId={`install-app-button-team${team.id}`}
alreadyInstalled={team.alreadyInstalled}
avatar={team.logoUrl ?? ""}
name={team.name}
onClick={() => onSelect(team.id)}
loading={loading}
/>
))}
{installableOnTeams &&
teams?.map((team) => (
<AccountSelector
key={team.id}
testId={`install-app-button-team${team.id}`}
alreadyInstalled={team.alreadyInstalled}
avatar={team.logoUrl ?? ""}
name={team.name}
onClick={() => onSelect(team.id)}
loading={loading}
/>
))}
</div>
</StepCard>
);
@@ -1,3 +1,4 @@
import { zodResolver } from "@hookform/resolvers/zod";
import type { TEventType, TEventTypesForm } from "@pages/apps/installation/[[...step]]";
import { X } from "lucide-react";
import type { Dispatch, SetStateAction } from "react";
@@ -6,27 +7,34 @@ 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 { z } from "zod";
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import type { LocationObject } from "@calcom/core/location";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppCategories } from "@calcom/prisma/enums";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeMetaDataSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import { Button, Form } from "@calcom/ui";
import useAppsData from "@lib/hooks/useAppsData";
import EventTypeAppSettingsWrapper from "@components/apps/installation/EventTypeAppSettingsWrapper";
import EventTypeConferencingAppSettings from "@components/apps/installation/EventTypeConferencingAppSettings";
type TFormType = {
import { locationsResolver } from "~/event-types/views/event-types-single-view";
export type TFormType = {
id: number;
metadata: z.infer<typeof EventTypeMetaDataSchema>;
locations: LocationObject[];
bookingFields: z.infer<typeof eventTypeBookingFields>;
seatsPerTimeSlot: number | null;
};
type ConfigureStepCardProps = {
export type ConfigureStepCardProps = {
slug: string;
userName: string;
categories: AppCategories[];
credentialId?: number;
loading?: boolean;
isConferencing: boolean;
formPortalRef: React.RefObject<HTMLDivElement>;
eventTypes: TEventType[] | undefined;
setConfigureStep: Dispatch<SetStateAction<boolean>>;
@@ -35,51 +43,39 @@ type ConfigureStepCardProps = {
type EventTypeAppSettingsFormProps = Pick<
ConfigureStepCardProps,
"slug" | "userName" | "categories" | "credentialId" | "loading"
"slug" | "userName" | "categories" | "credentialId" | "loading" | "isConferencing"
> & {
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)}
/>
);
onSubmit: ({
locations,
bookingFields,
metadata,
}: {
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
bookingFields?: z.infer<typeof eventTypeBookingFields>;
locations?: LocationObject[];
}) => void;
};
const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSettingsFormProps>(
function EventTypeAppSettingsForm(props, ref) {
const { handleDelete, onSubmit, eventType, loading } = props;
const { handleDelete, onSubmit, eventType, loading, isConferencing } = props;
const { t } = useLocale();
const formMethods = useForm<TFormType>({
defaultValues: {
metadata: eventType?.metadata,
id: eventType.id,
metadata: eventType?.metadata ?? undefined,
locations: eventType?.locations ?? undefined,
bookingFields: eventType?.bookingFields ?? undefined,
seatsPerTimeSlot: eventType?.seatsPerTimeSlot ?? undefined,
},
resolver: zodResolver(
z.object({
locations: locationsResolver(t),
})
),
});
return (
@@ -87,8 +83,10 @@ const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSetti
form={formMethods}
id={`eventtype-${eventType.id}`}
handleSubmit={() => {
const data = formMethods.getValues("metadata");
onSubmit(data);
const metadata = formMethods.getValues("metadata");
const locations = formMethods.getValues("locations");
const bookingFields = formMethods.getValues("bookingFields");
onSubmit({ metadata, locations, bookingFields });
}}>
<div>
<div className="sm:border-subtle bg-default relative border p-4 dark:bg-black sm:rounded-md">
@@ -98,13 +96,17 @@ const EventTypeAppSettingsForm = forwardRef<HTMLButtonElement, EventTypeAppSetti
/{eventType.team ? eventType.team.slug : props.userName}/{eventType.slug}
</small>
</div>
<EventTypeAppSettingsWrapper {...props} />
{isConferencing ? (
<EventTypeConferencingAppSettings {...props} />
) : (
<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}>
<button type="submit" className="hidden" form={`eventtype-${eventType.id}`} ref={ref}>
Save
</button>
</div>
@@ -148,6 +150,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
if (!fields.some((field) => field.selected)) {
setConfigureStep(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fields]);
useEffect(() => {
@@ -176,7 +179,7 @@ export const ConfigureStepCard: FC<ConfigureStepCardProps> = ({
update(index, { ...field, selected: false, metadata: eventMetadataDb });
}}
onSubmit={(data) => {
update(index, { ...field, metadata: data });
update(index, { ...field, ...data });
setUpdatedEventTypesStatus((prev) =>
prev.map((item) => (item.id === field.id ? { ...item, updated: true } : item))
);
@@ -0,0 +1,41 @@
import type { TEventType } from "@pages/apps/installation/[[...step]]";
import { useEffect, type FC } from "react";
import { EventTypeAppSettings } from "@calcom/app-store/_components/EventTypeAppSettingsInterface";
import type { EventTypeAppsList } from "@calcom/app-store/utils";
import useAppsData from "@lib/hooks/useAppsData";
import type { ConfigureStepCardProps } from "@components/apps/installation/ConfigureStepCard";
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);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<EventTypeAppSettings
slug={slug}
eventType={eventType}
getAppData={getAppDataGetter(slug as EventTypeAppsList)}
setAppData={getAppDataSetter(slug as EventTypeAppsList, categories, credentialId)}
/>
);
};
export default EventTypeAppSettingsWrapper;
@@ -0,0 +1,118 @@
import type { TEventType } from "@pages/apps/installation/[[...step]]";
import { useMemo } from "react";
import { useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { SchedulingType } from "@calcom/prisma/client";
import { trpc } from "@calcom/trpc/react";
import { SkeletonContainer, SkeletonText } from "@calcom/ui";
import { Skeleton, Label } from "@calcom/ui";
import { QueryCell } from "@lib/QueryCell";
import type { TFormType } from "@components/apps/installation/ConfigureStepCard";
import type { TLocationOptions } from "@components/eventtype/Locations";
import type { TEventTypeLocation } from "@components/eventtype/Locations";
import Locations from "@components/eventtype/Locations";
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
const LocationsWrapper = ({
eventType,
slug,
}: {
eventType: TEventType & {
locationOptions?: TLocationOptions;
};
slug: string;
}) => {
const { t } = useLocale();
const formMethods = useFormContext<TFormType>();
const prefillLocation = useMemo(() => {
let res: SingleValueLocationOption | undefined = undefined;
for (const item of eventType?.locationOptions || []) {
for (const option of item.options) {
if (option.slug === slug) {
res = {
...option,
};
}
}
return res;
}
}, [slug, eventType?.locationOptions]);
return (
<div className="mt-2">
<Skeleton as={Label} loadingClassName="w-16" htmlFor="locations">
{t("location")}
</Skeleton>
<Locations
showAppStoreLink={false}
isChildrenManagedEventType={false}
isManagedEventType={false}
disableLocationProp={false}
eventType={eventType as TEventTypeLocation}
destinationCalendar={eventType.destinationCalendar}
locationOptions={eventType.locationOptions || []}
prefillLocation={prefillLocation}
team={null}
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
control={formMethods.control as unknown as Control<LocationFormValues>}
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
/>
</div>
);
};
const EventTypeConferencingAppSettings = ({ eventType, slug }: { eventType: TEventType; slug: string }) => {
const locationsQuery = trpc.viewer.locationOptions.useQuery({});
const { t } = useLocale();
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<SkeletonText className="my-2 h-8 w-full" />
</SkeletonContainer>
);
};
return (
<QueryCell
query={locationsQuery}
customLoader={<SkeletonLoader />}
success={({ data }) => {
let updatedEventType: TEventType & {
locationOptions?: TLocationOptions;
} = { ...eventType };
if (updatedEventType.schedulingType === SchedulingType.MANAGED) {
updatedEventType = {
...updatedEventType,
locationOptions: [
{
label: t("default"),
options: [
{
label: t("members_default_location"),
value: "",
icon: "/user-check.svg",
},
],
},
...data,
],
};
} else {
updatedEventType = { ...updatedEventType, locationOptions: data };
}
return <LocationsWrapper eventType={updatedEventType} slug={slug} />;
}}
/>
);
};
export default EventTypeConferencingAppSettings;
+81 -19
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { useState } from "react";
import { useForm, Controller, useFieldArray } from "react-hook-form";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import type { EventLocationType, getEventLocationValue } from "@calcom/app-store/locations";
import {
@@ -31,20 +31,20 @@ import {
DialogClose,
DialogContent,
DialogFooter,
Dropdown,
DropdownItem,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
Icon,
MeetingTimeInTimezones,
showToast,
TableActions,
TextAreaField,
Tooltip,
Dropdown,
DropdownMenuContent,
DropdownMenuTrigger,
DropdownMenuItem,
DropdownItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuCheckboxItem,
} from "@calcom/ui";
import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog";
@@ -691,13 +691,8 @@ const Attendee = (attendeeProps: AttendeeProps & NoShowProps) => {
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"
);
onSuccess: async (data) => {
showToast(t(data.message, { x: name || email }), "success");
},
onError: (err) => {
showToast(err.message, "error");
@@ -804,8 +799,8 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
});
const { t } = useLocale();
const noShowMutation = trpc.viewer.public.noShow.useMutation({
onSuccess: async () => {
showToast(t("no_show_updated"), "success");
onSuccess: async (data) => {
showToast(t(data.message), "success");
},
onError: (err) => {
showToast(err.message, "error");
@@ -884,6 +879,69 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
</Dropdown>
);
};
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { t } = useLocale();
const { copyToClipboard, isCopied } = useCopy();
const [selectedEmail, setSelectedEmail] = useState("");
return (
<Dropdown
open={openDropdown}
onOpenChange={(value) => {
setOpenDropdown(value);
setSelectedEmail("");
}}>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="radix-state-open:text-blue-500 hover:text-blue-500 focus:outline-none">
{t("plus_more", { count: guests.length - 1 })}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel className="text-xs font-medium uppercase">{t("guests")}</DropdownMenuLabel>
{guests.slice(1).map((guest) => (
<DropdownMenuItem key={guest.id}>
<DropdownItem
className="pr-6 focus:outline-none"
StartIcon={selectedEmail === guest.email ? "circle-check" : undefined}
onClick={(e) => {
e.preventDefault();
setSelectedEmail(guest.email);
}}>
<span className={`${selectedEmail !== guest.email ? "pl-6" : ""}`}>{guest.email}</span>
</DropdownItem>
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className=" flex justify-end space-x-2 p-2">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
disabled={selectedEmail.length === 0}
onClick={(e) => {
setOpenDropdown(false);
e.stopPropagation();
}}>
{t("email")}
</Button>
</Link>
<Button
color="secondary"
disabled={selectedEmail.length === 0}
onClick={(e) => {
e.preventDefault();
copyToClipboard(selectedEmail);
showToast(t("email_copied"), "success");
}}>
{!isCopied ? t("copy") : t("copied")}
</Button>
</div>
</DropdownMenuContent>
</Dropdown>
);
};
const DisplayAttendees = ({
attendees,
@@ -916,7 +974,11 @@ const DisplayAttendees = ({
<Attendee {...attendee} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
</p>
))}>
{isBookingInPast && <GroupedAttendees attendees={attendees} bookingUid={bookingUid} />}
{isBookingInPast ? (
<GroupedAttendees attendees={attendees} bookingUid={bookingUid} />
) : (
<GroupedGuests guests={attendees} />
)}
</Tooltip>
) : (
<Attendee {...attendees[1]} bookingUid={bookingUid} isBookingInPast={isBookingInPast} />
+19 -387
View File
@@ -1,18 +1,13 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ErrorMessage } from "@hookform/error-message";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useEffect, useState } from "react";
import { Controller, useFormContext, useFieldArray } from "react-hook-form";
import { Controller, useFormContext } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { MultiValue } from "react-select";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import type { FormValues, LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { md } from "@calcom/lib/markdownIt";
import { slugify } from "@calcom/lib/slugify";
@@ -23,54 +18,12 @@ import {
SettingsToggle,
Skeleton,
TextField,
Icon,
Editor,
SkeletonContainer,
SkeletonText,
Input,
PhoneInput,
Button,
showToast,
} from "@calcom/ui";
import CheckboxField from "@components/ui/form/CheckboxField";
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
import LocationSelect from "@components/ui/form/LocationSelect";
const getLocationFromType = (
type: EventLocationType["type"],
locationOptions: Pick<EventTypeSetupProps, "locationOptions">["locationOptions"]
) => {
for (const locationOption of locationOptions) {
const option = locationOption.options.find((option) => option.value === type);
if (option) {
return option;
}
}
};
const getLocationInfo = ({
eventType,
locationOptions,
}: Pick<EventTypeSetupProps, "eventType" | "locationOptions">) => {
const locationAvailable =
eventType.locations &&
eventType.locations.length > 0 &&
locationOptions.some((op) => op.options.find((opt) => opt.value === eventType.locations[0].type));
const locationDetails = eventType.locations &&
eventType.locations.length > 0 &&
!locationAvailable && {
slug: eventType.locations[0].type.replace("integrations:", "").replace(":", "-").replace("_video", ""),
name: eventType.locations[0].type
.replace("integrations:", "")
.replace(":", " ")
.replace("_video", "")
.split(" ")
.map((word) => word[0].toUpperCase() + word.slice(1))
.join(" "),
};
return { locationAvailable, locationDetails };
};
import Locations from "@components/eventtype/Locations";
const DescriptionEditor = ({ isEditable }: { isEditable: boolean }) => {
const formMethods = useFormContext<FormValues>();
@@ -106,25 +59,13 @@ export const EventSetupTab = (
) => {
const { t } = useLocale();
const formMethods = useFormContext<FormValues>();
const { eventType, team, destinationCalendar } = props;
const { eventType, team } = props;
const [multipleDuration, setMultipleDuration] = useState(
formMethods.getValues("metadata")?.multipleDuration
);
const orgBranding = useOrgBranding();
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
const locationOptions = props.locationOptions.map((locationOption) => {
const options = locationOption.options.filter((option) => {
// Skip "Organizer's Default App" for non-team members
return !team ? option.label !== t("organizer_default_conferencing_app") : true;
});
return {
...locationOption,
options,
};
});
const multipleDurationOptions = [
5, 10, 15, 20, 25, 30, 45, 50, 60, 75, 80, 90, 120, 150, 180, 240, 300, 360, 420, 480,
].map((mins) => ({
@@ -144,327 +85,6 @@ export const EventSetupTab = (
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
useLockedFieldsManager({ eventType, translate: t, formMethods });
const Locations = () => {
const { t } = useLocale();
const {
fields: locationFields,
append,
remove,
update: updateLocationField,
} = useFieldArray({
control: formMethods.control,
name: "locations",
});
const [animationRef] = useAutoAnimate<HTMLUListElement>();
const seatsEnabled = formMethods.getValues("seatsPerTimeSlotEnabled");
const validLocations = formMethods.getValues("locations").filter((location) => {
const eventLocation = getEventLocationType(location.type);
if (!eventLocation) {
// It's possible that the location app in use got uninstalled.
return false;
}
return true;
});
const defaultValue = isManagedEventType
? locationOptions.find((op) => op.label === t("default"))?.options[0]
: undefined;
const { locationDetails, locationAvailable } = getLocationInfo(props);
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
}) => {
const { eventLocationType, index, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
type="text"
required
onChange={onChange}
value={value}
{...(shouldLockDisableProps("locations").disabled ? { disabled: true } : {})}
className="my-0"
{...rest}
/>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
disabled={shouldLockDisableProps("locations").disabled}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
value={value}
onChange={onChange}
{...rest}
/>
);
}}
/>
);
}
return null;
};
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
const defaultInitialLocation = defaultValue || null;
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
defaultInitialLocation
);
return (
<div className="w-full">
<ul ref={animationRef} className="space-y-2">
{locationFields.map((field, index) => {
const eventLocationType = getEventLocationType(field.type);
const defaultLocation = field;
const option = getLocationFromType(field.type, locationOptions);
return (
<li key={field.id}>
<div className="flex w-full items-center">
<LocationSelect
name={`locations[${index}].type`}
placeholder={t("select")}
options={locationOptions}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={option}
isSearchable={false}
className="block min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
const canAddLocation =
eventLocationType.organizerInputType ||
!validLocations.find((location) => location.type === newLocationType);
if (canAddLocation) {
updateLocationField(index, {
type: newLocationType,
...(e.credentialId && {
credentialId: e.credentialId,
teamName: e.teamName,
}),
});
} else {
updateLocationField(index, {
type: field.type,
...(field.credentialId && {
credentialId: field.credentialId,
teamName: field.teamName,
}),
});
showToast(t("location_already_exists"), "warning");
}
// Whenever location changes, we need to reset the locations item in booking questions list else it overflows
// previously added values resulting in wrong behaviour
const existingBookingFields = formMethods.getValues("bookingFields");
const findLocation = existingBookingFields.findIndex(
(field) => field.name === "location"
);
if (findLocation >= 0) {
existingBookingFields[findLocation] = {
...existingBookingFields[findLocation],
type: "radioInput",
label: "",
placeholder: "",
};
formMethods.setValue("bookingFields", existingBookingFields, {
shouldDirty: true,
});
}
}
}}
/>
{!(shouldLockDisableProps("locations").disabled && isChildrenManagedEventType) && (
<button
data-testid={`delete-locations.${index}.type`}
className="min-h-9 block h-9 px-2"
type="button"
onClick={() => remove(index)}
aria-label={t("remove")}>
<div className="h-4 w-4">
<Icon name="x" className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
</div>
</button>
)}
</div>
{eventLocationType?.organizerInputType && (
<div className="mt-2 space-y-2">
<div className="w-full">
<div className="flex gap-2">
<div className="flex items-center justify-center">
<Icon name="corner-down-right" className="h-4 w-4" />
</div>
<LocationInput
defaultValue={
defaultLocation
? defaultLocation[eventLocationType.defaultValueVariable]
: undefined
}
eventLocationType={eventLocationType}
index={index}
/>
</div>
<ErrorMessage
errors={formMethods.formState.errors?.locations?.[index]}
name={eventLocationType.defaultValueVariable}
className="text-error my-1 ml-6 text-sm"
as="div"
id="location-error"
/>
</div>
<div className="ml-6">
<CheckboxField
name={`locations[${index}].displayLocationPublicly`}
data-testid="display-location"
disabled={shouldLockDisableProps("locations").disabled}
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}
onChange={(e) => {
const fieldValues = formMethods.getValues("locations")[index];
updateLocationField(index, {
...fieldValues,
displayLocationPublicly: e.target.checked,
});
}}
informationIconText={t("display_location_info_badge")}
/>
</div>
</div>
)}
</li>
);
})}
{(validLocations.length === 0 || showEmptyLocationSelect) && (
<div className="flex">
<LocationSelect
defaultMenuIsOpen={showEmptyLocationSelect}
placeholder={t("select")}
options={locationOptions}
value={selectedNewOption}
isDisabled={shouldLockDisableProps("locations").disabled}
defaultValue={defaultValue}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
const canAppendLocation =
eventLocationType.organizerInputType ||
!validLocations.find((location) => location.type === newLocationType);
if (canAppendLocation) {
append({
type: newLocationType,
...(e.credentialId && {
credentialId: e.credentialId,
teamName: e.teamName,
}),
});
setSelectedNewOption(e);
} else {
showToast(t("location_already_exists"), "warning");
setSelectedNewOption(null);
}
}
}}
/>
</div>
)}
{validLocations.some(
(location) =>
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
) && (
<div className="text-default flex items-center text-sm">
<div className="mr-1.5 h-3 w-3">
<Icon name="check" className="h-3 w-3" />
</div>
<Trans i18nKey="event_type_requres_google_cal">
<p>
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
Change it{" "}
<Link
href={`${WEBAPP_URL}/event-types/${formMethods.getValues("id")}?tabName=advanced`}
className="underline">
here.
</Link>{" "}
</p>
</Trans>
</div>
)}
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${WEBAPP_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
)}
{validLocations.length > 0 && !shouldLockDisableProps("locations").disabled && (
// && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin
<li>
<Button
data-testid="add-location"
StartIcon="plus"
color="minimal"
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined}
onClick={() => setShowEmptyLocationSelect(true)}>
{t("add_location")}
</Button>
</li>
)}
</ul>
<p className="text-default mt-2 text-sm">
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
Can&apos;t find the right video app? Visit our
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
App Store
</Link>
.
</Trans>
</p>
</div>
);
};
const lengthLockedProps = shouldLockDisableProps("length");
const descriptionLockedProps = shouldLockDisableProps("description");
@@ -626,7 +246,19 @@ export const EventSetupTab = (
name="locations"
control={formMethods.control}
defaultValue={eventType.locations || []}
render={() => <Locations />}
render={() => (
<Locations
showAppStoreLink={true}
isChildrenManagedEventType={isChildrenManagedEventType}
isManagedEventType={isManagedEventType}
disableLocationProp={shouldLockDisableProps("locations").disabled}
getValues={formMethods.getValues as unknown as UseFormGetValues<LocationFormValues>}
setValue={formMethods.setValue as unknown as UseFormSetValue<LocationFormValues>}
control={formMethods.control as unknown as Control<LocationFormValues>}
formState={formMethods.formState as unknown as FormState<LocationFormValues>}
{...props}
/>
)}
/>
</div>
</div>
@@ -2,7 +2,7 @@ import type { Webhook } from "@prisma/client";
import { useSession } from "next-auth/react";
import type { EventTypeSetup } from "pages/event-types/[type]";
import { useState } from "react";
import { useFormContext } from "react-hook-form";
import { useFormContext, Controller } from "react-hook-form";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
@@ -15,7 +15,17 @@ import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { Alert, Button, EmptyScreen, SettingsToggle, Dialog, DialogContent, showToast } from "@calcom/ui";
import {
Alert,
Button,
EmptyScreen,
SettingsToggle,
Dialog,
DialogContent,
showToast,
TextField,
Label,
} from "@calcom/ui";
type InstantEventControllerProps = {
eventType: EventTypeSetup;
@@ -85,7 +95,33 @@ export default function InstantEventController({
}
}}>
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
{instantEventState && <InstantMeetingWebhooks eventType={eventType} />}
{instantEventState && (
<div className="flex flex-col gap-2">
<Controller
name="instantMeetingExpiryTimeOffsetInSeconds"
render={({ field: { value, onChange } }) => (
<>
<Label>{t("set_instant_meeting_expiry_time_offset_description")}</Label>
<TextField
required
name="instantMeetingExpiryTimeOffsetInSeconds"
labelSrOnly
type="number"
defaultValue={value}
min={10}
containerClassName="max-w-80"
addOnSuffix={<>{t("seconds")}</>}
onChange={(e) => {
onChange(Math.abs(Number(e.target.value)));
}}
data-testid="instant-meeting-expiry-time-offset"
/>
</>
)}
/>
<InstantMeetingWebhooks eventType={eventType} />
</div>
)}
</div>
</SettingsToggle>
</>
@@ -213,9 +249,6 @@ const InstantMeetingWebhooks = ({ eventType }: { eventType: EventTypeSetup }) =>
</>
) : (
<>
<p className="text-default mb-4 text-sm font-normal">
{t("warning_payment_instant_meeting_event")}
</p>
<EmptyScreen
Icon="webhook"
headline={t("create_your_first_webhook")}
+447
View File
@@ -0,0 +1,447 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { ErrorMessage } from "@hookform/error-message";
import { Trans } from "next-i18next";
import Link from "next/link";
import type { EventTypeSetupProps } from "pages/event-types/[type]";
import { useEffect, useState } from "react";
import { Controller, useFieldArray } from "react-hook-form";
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
import type { EventLocationType } from "@calcom/app-store/locations";
import { getEventLocationType, MeetLocationType } from "@calcom/app-store/locations";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon, Input, PhoneInput, Button, showToast } from "@calcom/ui";
import CheckboxField from "@components/ui/form/CheckboxField";
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
import LocationSelect from "@components/ui/form/LocationSelect";
export type TEventTypeLocation = Pick<EventTypeSetupProps["eventType"], "locations">;
export type TLocationOptions = Pick<EventTypeSetupProps, "locationOptions">["locationOptions"];
export type TDestinationCalendar = { integration: string } | null;
export type TPrefillLocation = { credentialId?: number; type: string };
type LocationsProps = {
team: { id: number } | null;
destinationCalendar: TDestinationCalendar;
showAppStoreLink: boolean;
isChildrenManagedEventType?: boolean;
isManagedEventType?: boolean;
disableLocationProp?: boolean;
getValues: UseFormGetValues<LocationFormValues>;
setValue: UseFormSetValue<LocationFormValues>;
control: Control<LocationFormValues>;
formState: FormState<LocationFormValues>;
eventType: TEventTypeLocation;
locationOptions: TLocationOptions;
prefillLocation?: SingleValueLocationOption;
};
const getLocationFromType = (type: EventLocationType["type"], locationOptions: TLocationOptions) => {
for (const locationOption of locationOptions) {
const option = locationOption.options.find((option) => option.value === type);
if (option) {
return option;
}
}
};
const getLocationInfo = ({
eventType,
locationOptions,
}: {
eventType: TEventTypeLocation;
locationOptions: TLocationOptions;
}) => {
const locationAvailable =
eventType.locations &&
eventType.locations.length > 0 &&
locationOptions.some((op) => op.options.find((opt) => opt.value === eventType.locations[0].type));
const locationDetails = eventType.locations &&
eventType.locations.length > 0 &&
!locationAvailable && {
slug: eventType.locations[0].type.replace("integrations:", "").replace(":", "-").replace("_video", ""),
name: eventType.locations[0].type
.replace("integrations:", "")
.replace(":", " ")
.replace("_video", "")
.split(" ")
.map((word) => word[0].toUpperCase() + word.slice(1))
.join(" "),
};
return { locationAvailable, locationDetails };
};
const Locations: React.FC<LocationsProps> = ({
isChildrenManagedEventType,
disableLocationProp,
isManagedEventType,
getValues,
setValue,
control,
formState,
team,
eventType,
prefillLocation,
...props
}) => {
const { t } = useLocale();
const {
fields: locationFields,
append,
remove,
update: updateLocationField,
} = useFieldArray({
control,
name: "locations",
});
const locationOptions = props.locationOptions.map((locationOption) => {
const options = locationOption.options.filter((option) => {
// Skip "Organizer's Default App" for non-team members
return !team?.id ? option.label !== t("organizer_default_conferencing_app") : true;
});
return {
...locationOption,
options,
};
});
const [animationRef] = useAutoAnimate<HTMLUListElement>();
const seatsEnabled = !!getValues("seatsPerTimeSlot");
const validLocations =
getValues("locations")?.filter((location) => {
const eventLocation = getEventLocationType(location.type);
if (!eventLocation) {
// It's possible that the location app in use got uninstalled.
return false;
}
return true;
}) || [];
const defaultValue = isManagedEventType
? locationOptions.find((op) => op.label === t("default"))?.options[0]
: undefined;
const { locationDetails, locationAvailable } = getLocationInfo({
eventType,
locationOptions: props.locationOptions,
});
const LocationInput = (props: {
eventLocationType: EventLocationType;
defaultValue?: string;
index: number;
}) => {
const { eventLocationType, index, ...remainingProps } = props;
if (eventLocationType?.organizerInputType === "text") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<Input
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
type="text"
required
onChange={onChange}
value={value}
{...(disableLocationProp ? { disabled: true } : {})}
className="my-0"
{...rest}
/>
);
}}
/>
);
} else if (eventLocationType?.organizerInputType === "phone") {
const { defaultValue, ...rest } = remainingProps;
return (
<Controller
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
defaultValue={defaultValue}
render={({ field: { onChange, value } }) => {
return (
<PhoneInput
required
disabled={disableLocationProp}
placeholder={t(eventLocationType.organizerInputPlaceholder || "")}
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
value={value}
onChange={onChange}
{...rest}
/>
);
}}
/>
);
}
return null;
};
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
const defaultInitialLocation = defaultValue || null;
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(
defaultInitialLocation
);
useEffect(() => {
if (!!prefillLocation) {
const newLocationType = prefillLocation.value;
const canAppendLocation = !validLocations.find((location) => location.type === newLocationType);
if (canAppendLocation && !seatsEnabled) {
append({
type: newLocationType,
credentialId: prefillLocation?.credentialId,
});
setSelectedNewOption(prefillLocation);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [prefillLocation, seatsEnabled]);
return (
<div className="w-full">
<ul ref={animationRef} className="space-y-2">
{locationFields.map((field, index) => {
const eventLocationType = getEventLocationType(field.type);
const defaultLocation = field;
const option = getLocationFromType(field.type, locationOptions);
return (
<li key={field.id}>
<div className="flex w-full items-center">
<LocationSelect
name={`locations[${index}].type`}
placeholder={t("select")}
options={locationOptions}
isDisabled={disableLocationProp}
defaultValue={option}
isSearchable={false}
className="block min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
setShowEmptyLocationSelect(false);
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
const canAddLocation =
eventLocationType.organizerInputType ||
!validLocations?.find((location) => location.type === newLocationType);
if (canAddLocation) {
updateLocationField(index, {
type: newLocationType,
...(e.credentialId && {
credentialId: e.credentialId,
teamName: e.teamName ?? undefined,
}),
});
} else {
updateLocationField(index, {
type: field.type,
...(field.credentialId && {
credentialId: field.credentialId,
teamName: field.teamName ?? undefined,
}),
});
showToast(t("location_already_exists"), "warning");
}
// Whenever location changes, we need to reset the locations item in booking questions list else it overflows
// previously added values resulting in wrong behaviour
const existingBookingFields = getValues("bookingFields");
const findLocation = existingBookingFields.findIndex(
(field) => field.name === "location"
);
if (findLocation >= 0) {
existingBookingFields[findLocation] = {
...existingBookingFields[findLocation],
type: "radioInput",
label: "",
placeholder: "",
};
setValue("bookingFields", existingBookingFields, {
shouldDirty: true,
});
}
}
}}
/>
{!(disableLocationProp && isChildrenManagedEventType) && (
<button
data-testid={`delete-locations.${index}.type`}
className="min-h-9 block h-9 px-2"
type="button"
onClick={() => remove(index)}
aria-label={t("remove")}>
<div className="h-4 w-4">
<Icon name="x" className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
</div>
</button>
)}
</div>
{eventLocationType?.organizerInputType && (
<div className="mt-2 space-y-2">
<div className="w-full">
<div className="flex gap-2">
<div className="flex items-center justify-center">
<Icon name="corner-down-right" className="h-4 w-4" />
</div>
<LocationInput
data-testid={`${eventLocationType.type}-location-input`}
defaultValue={
defaultLocation
? defaultLocation[eventLocationType.defaultValueVariable]
: undefined
}
eventLocationType={eventLocationType}
index={index}
/>
</div>
<ErrorMessage
errors={formState.errors?.locations?.[index]}
name={eventLocationType.defaultValueVariable}
className="text-error my-1 ml-6 text-sm"
as="div"
id="location-error"
/>
</div>
<div className="ml-6">
<CheckboxField
name={`locations[${index}].displayLocationPublicly`}
data-testid="display-location"
disabled={disableLocationProp}
defaultChecked={defaultLocation?.displayLocationPublicly}
description={t("display_location_label")}
onChange={(e) => {
const fieldValues = getValues("locations")[index];
updateLocationField(index, {
...fieldValues,
displayLocationPublicly: e.target.checked,
});
}}
informationIconText={t("display_location_info_badge")}
/>
</div>
</div>
)}
</li>
);
})}
{(validLocations.length === 0 || showEmptyLocationSelect) && (
<div className="flex">
<LocationSelect
defaultMenuIsOpen={showEmptyLocationSelect}
placeholder={t("select")}
options={locationOptions}
value={selectedNewOption}
isDisabled={disableLocationProp}
defaultValue={defaultValue}
isSearchable={false}
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
menuPlacement="auto"
onChange={(e: SingleValueLocationOption) => {
setShowEmptyLocationSelect(false);
if (e?.value) {
const newLocationType = e.value;
const eventLocationType = getEventLocationType(newLocationType);
if (!eventLocationType) {
return;
}
const canAppendLocation =
eventLocationType.organizerInputType ||
!validLocations.find((location) => location.type === newLocationType);
if (canAppendLocation) {
append({
type: newLocationType,
...(e.credentialId && {
credentialId: e.credentialId,
teamName: e.teamName ?? undefined,
}),
});
setSelectedNewOption(e);
} else {
showToast(t("location_already_exists"), "warning");
setSelectedNewOption(null);
}
}
}}
/>
</div>
)}
{validLocations.some(
(location) =>
location.type === MeetLocationType && props.destinationCalendar?.integration !== "google_calendar"
) && (
<div className="text-default flex items-center text-sm">
<div className="mr-1.5 h-3 w-3">
<Icon name="check" className="h-3 w-3" />
</div>
<p className="text-default text-sm">
<Trans i18nKey="event_type_requires_google_calendar">
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
Connect it
<Link className="cursor-pointer text-blue-500 underline" href="/apps/google-calendar">
here
</Link>
.
</Trans>
</p>
</div>
)}
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
<p className="pl-1 text-sm leading-none text-red-600">
{t("app_not_connected", { appName: locationDetails.name })}{" "}
<a className="underline" href={`${WEBAPP_URL}/apps/${locationDetails.slug}`}>
{t("connect_now")}
</a>
</p>
)}
{validLocations.length > 0 && !disableLocationProp && (
// && !isChildrenManagedEventType : Add this to hide add-location button only when location is disabled by Admin
<li>
<Button
data-testid="add-location"
StartIcon="plus"
color="minimal"
disabled={seatsEnabled}
tooltip={seatsEnabled ? t("seats_option_doesnt_support_multi_location") : undefined}
onClick={() => setShowEmptyLocationSelect(true)}>
{t("add_location")}
</Button>
</li>
)}
</ul>
{props.showAppStoreLink && (
<p className="text-default mt-2 text-sm">
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
Can&apos;t find the right video app? Visit our
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
App Store
</Link>
.
</Trans>
</p>
)}
</div>
);
};
export default Locations;
@@ -1,15 +1,16 @@
import classNames from "classnames";
// eslint-disable-next-line no-restricted-imports
import { debounce, noop } from "lodash";
import { noop } from "lodash";
import { useSession } from "next-auth/react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { RefCallback } from "react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { getPremiumPlanPriceValue } from "@calcom/app-store/stripepayment/lib/utils";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import type { RouterOutputs } from "@calcom/trpc/react";
@@ -71,17 +72,8 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
const isCurrentUsernamePremium =
user && user.metadata && hasKeyInMetadata(user, "isPremium") ? !!user.metadata.isPremium : false;
const [isInputUsernamePremium, setIsInputUsernamePremium] = useState(false);
const debouncedApiCall = useMemo(
() =>
debounce(async (username: string) => {
// TODO: Support orgSlug
const { data } = await fetchUsername(username, null);
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
setIsInputUsernamePremium(data.premium);
setUsernameIsAvailable(data.available);
}, 150),
[currentUsername]
);
// debounce the username input, set the delay to 600ms to be consistent with signup form
const debouncedUsername = useDebounce(inputUsernameValue, 600);
useEffect(() => {
// Use the current username or if it's not set, use the one available from stripe
@@ -89,12 +81,22 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
}, [setInputUsernameValue, currentUsername, stripeCustomer?.username]);
useEffect(() => {
if (!inputUsernameValue) {
debouncedApiCall.cancel();
return;
async function checkUsername(username: string | undefined) {
if (!username) {
setUsernameIsAvailable(false);
setMarkAsError(false);
setIsInputUsernamePremium(false);
return;
}
const { data } = await fetchUsername(username, null);
setMarkAsError(!data.available && !!currentUsername && username !== currentUsername);
setIsInputUsernamePremium(data.premium);
setUsernameIsAvailable(data.available);
}
debouncedApiCall(inputUsernameValue);
}, [debouncedApiCall, inputUsernameValue]);
checkUsername(debouncedUsername);
}, [debouncedUsername, currentUsername]);
const updateUsername = trpc.viewer.updateProfile.useMutation({
onSuccess: async () => {
@@ -1,11 +1,12 @@
import classNames from "classnames";
// eslint-disable-next-line no-restricted-imports
import { debounce, noop } from "lodash";
import { noop } from "lodash";
import { useSession } from "next-auth/react";
import type { RefCallback } from "react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { fetchUsername } from "@calcom/lib/fetchUsername";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { TRPCClientErrorLike } from "@calcom/trpc/client";
import { trpc } from "@calcom/trpc/react";
@@ -41,31 +42,28 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
const [markAsError, setMarkAsError] = useState(false);
const [openDialogSaveUsername, setOpenDialogSaveUsername] = useState(false);
const debouncedApiCall = useMemo(
() =>
debounce(async (username) => {
// TODO: Support orgSlug
// debounce the username input, set the delay to 600ms to be consistent with signup form
const debouncedUsername = useDebounce(inputUsernameValue, 600);
useEffect(() => {
async function checkUsername(username: string | undefined) {
if (!username) {
setUsernameIsAvailable(false);
setMarkAsError(false);
return;
}
if (currentUsername !== username) {
const { data } = await fetchUsername(username, null);
setMarkAsError(!data.available);
setUsernameIsAvailable(data.available);
}, 150),
[]
);
useEffect(() => {
if (!inputUsernameValue) {
debouncedApiCall.cancel();
setUsernameIsAvailable(false);
setMarkAsError(false);
return;
} else {
setUsernameIsAvailable(false);
}
}
if (currentUsername !== inputUsernameValue) {
debouncedApiCall(inputUsernameValue);
} else {
setUsernameIsAvailable(false);
}
}, [inputUsernameValue, debouncedApiCall, currentUsername]);
checkUsername(debouncedUsername);
}, [debouncedUsername, currentUsername]);
const updateUsernameMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async () => {
@@ -39,13 +39,17 @@ export default function LocationSelect(props: Props<LocationOption, false, Group
Option: (props) => {
return (
<components.Option {...props}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
<div data-testid={`location-select-item-${props.data.value}`}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
</div>
</components.Option>
);
},
SingleValue: (props) => (
<components.SingleValue {...props}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
<div data-testid={`location-select-item-${props.data.value}`}>
<OptionWithIcon icon={props.data.icon} label={props.data.label} />
</div>
</components.SingleValue>
),
}}
+3 -3
View File
@@ -2,8 +2,8 @@ import type { GetServerSidePropsContext } from "next";
import { getAppRegistry, getAppRegistryWithCredentials } from "@calcom/app-store/_appRegistry";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import getUserAdminTeams from "@calcom/features/ee/teams/lib/getUserAdminTeams";
import type { UserAdminTeams } from "@calcom/features/ee/teams/lib/getUserAdminTeams";
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
import { UserRepository } from "@calcom/lib/server/repository/user";
import type { AppCategories } from "@calcom/prisma/enums";
import { ssrInit } from "@server/lib/ssr";
@@ -17,7 +17,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
let appStore, userAdminTeams: UserAdminTeams;
if (session?.user?.id) {
userAdminTeams = await getUserAdminTeams({ userId: session.user.id, getUserInfo: true });
userAdminTeams = await UserRepository.getUserAdminTeams(session.user.id);
appStore = await getAppRegistryWithCredentials(session.user.id, userAdminTeams);
} else {
appStore = await getAppRegistry();
+2 -4
View File
@@ -1,5 +1,6 @@
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { workflowSelect } from "@calcom/features/ee/workflows/lib/getAllWorkflows";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { BookingStatus } from "@calcom/prisma/enums";
@@ -61,10 +62,7 @@ export const getEventTypesFromDB = async (id: number) => {
workflows: {
select: {
workflow: {
select: {
id: true,
steps: true,
},
select: workflowSelect,
},
},
},
@@ -112,6 +112,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
eventSlug: slug,
isTeamEvent,
org,
fromRedirectOfNonOrgLink: context.query.orgRedirection === "true",
});
if (!eventData) {
+55
View File
@@ -0,0 +1,55 @@
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
// TODO: use BookingRepository
export const getBooking = async (bookingId: number) => {
const booking = await prisma.booking.findUniqueOrThrow({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
uid: true,
location: true,
isRecorded: true,
eventTypeId: true,
eventType: {
select: {
teamId: true,
parentId: true,
},
},
user: {
select: {
id: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking) {
log.error(
"Couldn't find Booking Id:",
safeStringify({
bookingId,
})
);
throw new HttpError({
message: `Booking of id ${bookingId} does not exist or does not contain daily video as location`,
statusCode: 404,
});
}
return booking;
};
export type getBookingResponse = Awaited<ReturnType<typeof getBooking>>;
@@ -0,0 +1,24 @@
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { BookingReferenceRepository } from "@calcom/lib/server/repository/bookingReference";
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
export const getBookingReference = async (roomName: string) => {
const bookingReference = await BookingReferenceRepository.findDailyVideoReferenceByRoomName({ roomName });
if (!bookingReference || !bookingReference.bookingId) {
log.error(
"bookingReference not found error:",
safeStringify({
bookingReference,
roomName,
})
);
throw new HttpError({ message: "Booking reference not found", statusCode: 200 });
}
return bookingReference;
};
@@ -0,0 +1,40 @@
import { getTranslation } from "@calcom/lib/server/i18n";
import type { CalendarEvent } from "@calcom/types/Calendar";
import type { getBookingResponse } from "./getBooking";
export const getCalendarEvent = async (booking: getBookingResponse) => {
const t = await getTranslation(booking?.user?.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
id: attendee.id,
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "Europe/London",
language: { translate: t, locale: booking?.user?.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
};
return Promise.resolve(evt);
};
+63
View File
@@ -0,0 +1,63 @@
import { z } from "zod";
const commonSchema = z
.object({
version: z.string(),
type: z.string(),
id: z.string(),
event_ts: z.number().optional(),
})
.passthrough();
export const meetingEndedSchema = commonSchema.extend({
payload: z
.object({
meeting_id: z.string(),
end_ts: z.number().optional(),
room: z.string(),
start_ts: z.number().optional(),
})
.passthrough(),
});
export const recordingReadySchema = commonSchema.extend({
payload: z.object({
recording_id: z.string(),
end_ts: z.number().optional(),
room_name: z.string(),
start_ts: z.number().optional(),
status: z.string(),
max_participants: z.number().optional(),
duration: z.number().optional(),
s3_key: z.string().optional(),
}),
});
export const batchProcessorJobFinishedSchema = commonSchema.extend({
payload: z
.object({
id: z.string(),
status: z.string(),
input: z.object({
sourceType: z.string(),
recordingId: z.string(),
}),
output: z
.object({
transcription: z.array(z.object({ format: z.string() }).passthrough()),
})
.passthrough(),
})
.passthrough(),
});
export type TBatchProcessorJobFinished = z.infer<typeof batchProcessorJobFinishedSchema>;
export const downloadLinkSchema = z.object({
download_link: z.string(),
});
export const testRequestSchema = z.object({
test: z.enum(["test"]),
});
@@ -0,0 +1,244 @@
import {
createBookingScenario,
getScenarioData,
TestData,
getDate,
getMockBookingAttendee,
getOrganizer,
getBooker,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import { expectWebhookToHaveBeenCalledWith } from "@calcom/web/test/utils/bookingScenario/expects";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, afterEach, test, vi, beforeEach, beforeAll } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
import { getDownloadLinkOfCalVideoByRecordingId } from "@calcom/core/videoClient";
import prisma from "@calcom/prisma";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import { BookingStatus } from "@calcom/prisma/enums";
import handler from "@calcom/web/pages/api/recorded-daily-video";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
beforeAll(() => {
// Setup env vars
vi.stubEnv("SENDGRID_API_KEY", "FAKE_SENDGRID_API_KEY");
vi.stubEnv("SENDGRID_EMAIL", "FAKE_SENDGRID_EMAIL");
});
vi.mock("@calcom/app-store/dailyvideo/lib", () => {
return {
getRoomNameFromRecordingId: vi.fn(),
getBatchProcessorJobAccessLink: vi.fn(),
};
});
vi.mock("@calcom/core/videoClient", () => {
return {
getDownloadLinkOfCalVideoByRecordingId: vi.fn(),
};
});
const BATCH_PROCESSOR_JOB_FINSISHED_PAYLOAD = {
version: "1.1.0",
type: "batch-processor.job-finished",
id: "77b1cb9e-cd79-43cd-bad6-3ccaccba26be",
payload: {
id: "77b1cb9e-cd79-43cd-bad6-3ccaccba26be",
status: "finished",
input: {
sourceType: "recordingId",
recordingId: "eb9e84de-783e-4e14-875d-94700ee4b976",
},
output: {
transcription: [
{
format: "json",
s3Config: {
key: "transcript.json",
bucket: "daily-bucket",
region: "us-west-2",
},
},
{
format: "srt",
s3Config: {
key: "transcript.srt",
bucket: "daily-bucket",
region: "us-west-2",
},
},
{
format: "txt",
s3Config: {
key: "transcript.txt",
bucket: "daily-bucket",
region: "us-west-2",
},
},
{
format: "vtt",
s3Config: {
key: "transcript.vtt",
bucket: "daily-bucket",
region: "us-west-2",
},
},
],
},
},
event_ts: 1717688213.803,
};
const timeout = process.env.CI ? 5000 : 20000;
const TRANSCRIPTION_ACCESS_LINK = {
id: "MOCK_ID",
preset: "transcript",
status: "finished",
transcription: [
{
format: "json",
link: "https://download.json",
},
{
format: "srt",
link: "https://download.srt",
},
],
};
describe("Handler: /api/recorded-daily-video", () => {
beforeEach(() => {
fetchMock.resetMocks();
});
afterEach(() => {
vi.resetAllMocks();
fetchMock.resetMocks();
});
test(
`Batch Processor Job finished triggers RECORDING_TRANSCRIPTION_GENERATED webhooks`,
async () => {
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
});
const bookingUid = "n5Wv3eHgconAED2j4gcVhP";
const iCalUID = `${bookingUid}@Cal.com`;
const subscriberUrl = "http://my-webhook.example.com";
const recordingDownloadLink = "https://download-link.com";
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
await createBookingScenario(
getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: [WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED],
subscriberUrl,
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
slotInterval: 15,
length: 15,
users: [
{
id: 101,
},
],
},
],
bookings: [
{
uid: bookingUid,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
userId: organizer.id,
metadata: {
videoCallUrl: "https://existing-daily-video-call-url.example.com",
},
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
credentialId: null,
},
],
attendees: [
getMockBookingAttendee({
id: 2,
name: booker.name,
email: booker.email,
locale: "en",
timeZone: "Asia/Kolkata",
noShow: false,
}),
],
iCalUID,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);
vi.mocked(getRoomNameFromRecordingId).mockResolvedValue("MOCK_ID");
vi.mocked(getBatchProcessorJobAccessLink).mockResolvedValue(TRANSCRIPTION_ACCESS_LINK);
vi.mocked(getDownloadLinkOfCalVideoByRecordingId).mockResolvedValue({
download_link: recordingDownloadLink,
});
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: BATCH_PROCESSOR_JOB_FINSISHED_PAYLOAD,
prisma,
});
await handler(req, res);
await expectWebhookToHaveBeenCalledWith(subscriberUrl, {
triggerEvent: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
payload: {
type: "Test Booking Title",
uid: bookingUid,
downloadLinks: {
transcription: TRANSCRIPTION_ACCESS_LINK.transcription,
recording: recordingDownloadLink,
},
organizer: {
email: organizer.email,
name: organizer.name,
timeZone: organizer.timeZone,
language: { locale: "en" },
utcOffset: 330,
},
},
});
},
timeout
);
});
@@ -0,0 +1,112 @@
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
import type { CalendarEvent } from "@calcom/types/Calendar";
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler:triggerRecordingReadyWebhook"] });
type Booking = {
userId: number | undefined;
eventTypeId: number | null;
eventTypeParentId: number | null | undefined;
teamId?: number | null;
};
const getWebhooksByEventTrigger = async (eventTrigger: WebhookTriggerEvents, booking: Booking) => {
const isTeamBooking = booking.teamId;
const isBookingForManagedEventtype = booking.teamId && booking.eventTypeParentId;
const triggerForUser = !isTeamBooking || isBookingForManagedEventtype;
const organizerUserId = triggerForUser ? booking.userId : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId: booking.teamId });
const subscriberOptions = {
userId: organizerUserId,
eventTypeId: booking.eventTypeId,
triggerEvent: eventTrigger,
teamId: booking.teamId,
orgId,
};
return getWebhooks(subscriberOptions);
};
export const triggerRecordingReadyWebhook = async ({
evt,
downloadLink,
booking,
}: {
evt: CalendarEvent;
downloadLink: string;
booking: Booking;
}) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
const webhooks = await getWebhooksByEventTrigger(eventTrigger, booking);
log.debug(
"Webhooks:",
safeStringify({
webhooks,
})
);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
downloadLink,
}).catch((e) => {
log.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
);
})
);
await Promise.all(promises);
};
export const triggerTranscriptionGeneratedWebhook = async ({
evt,
downloadLinks,
booking,
}: {
evt: CalendarEvent;
downloadLinks?: {
transcription: TGetTranscriptAccessLink["transcription"];
recording: string;
};
booking: Booking;
}) => {
const webhooks = await getWebhooksByEventTrigger(
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
booking
);
log.debug(
"Webhooks:",
safeStringify({
webhooks,
})
);
const promises = webhooks.map((webhook) =>
sendPayload(
webhook.secret,
WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
new Date().toISOString(),
webhook,
{
...evt,
downloadLinks,
}
).catch((e) => {
log.error(
`Error executing webhook for event: ${WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
);
})
);
await Promise.all(promises);
};
@@ -296,7 +296,7 @@ export default function Success(props: PageProps) {
function getTitle(): string {
const titleSuffix = props.recurringBookings ? "_recurring" : "";
const titlePrefix = isRoundRobin ? "round_robin_" : "";
if (isCancelled) {
if (isCancelled || isBookingInPast) {
return "";
}
if (needsConfirmation) {
@@ -340,6 +340,8 @@ export default function Success(props: PageProps) {
const providerName = guessEventLocationType(location)?.label;
const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label;
const isBookingInPast = new Date(bookingInfo.endTime) < new Date();
const isReschedulable = !isCancelled && !isBookingInPast;
const bookingCancelledEventProps = {
booking: bookingInfo,
@@ -412,7 +414,7 @@ export default function Success(props: PageProps) {
imageSrc={`${bookingInfo.user.avatarUrl}`}
/>
)}
{giphyImage && !needsConfirmation && !isCancelled && (
{giphyImage && !needsConfirmation && isReschedulable && (
// eslint-disable-next-line @next/next/no-img-element
<img src={giphyImage} className="w-full rounded-lg" alt="Gif from Giphy" />
)}
@@ -421,17 +423,19 @@ export default function Success(props: PageProps) {
"mx-auto flex h-12 w-12 items-center justify-center rounded-full",
isRoundRobin &&
"border-cal-bg dark:border-cal-bg-muted absolute bottom-0 right-0 z-10 h-12 w-12 border-8",
!giphyImage && !isCancelled && !needsConfirmation ? "bg-success" : "",
!giphyImage && !isCancelled && needsConfirmation ? "bg-subtle" : "",
isCancelled ? "bg-error" : ""
!giphyImage && isReschedulable && !needsConfirmation ? "bg-success" : "",
!giphyImage && isReschedulable && needsConfirmation ? "bg-subtle" : "",
isCancelled || isBookingInPast ? "bg-error" : ""
)}>
{!giphyImage && !needsConfirmation && !isCancelled && (
{!giphyImage && !needsConfirmation && isReschedulable && (
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
)}
{needsConfirmation && !isCancelled && (
{needsConfirmation && isReschedulable && (
<Icon name="calendar" className="text-emphasis h-5 w-5" />
)}
{isCancelled && <Icon name="x" className="h-5 w-5 text-red-600 dark:text-red-200" />}
{(isCancelled || isBookingInPast) && (
<Icon name="x" className="h-5 w-5 text-red-600 dark:text-red-200" />
)}
</div>
</div>
<div className="mb-8 mt-6 text-center last:mb-0">
@@ -439,7 +443,7 @@ export default function Success(props: PageProps) {
className="text-emphasis text-2xl font-semibold leading-6"
data-testid={isCancelled ? "cancelled-headline" : ""}
id="modal-headline">
{needsConfirmation && !isCancelled
{needsConfirmation && isReschedulable
? props.recurringBookings
? t("booking_submitted_recurring")
: t("booking_submitted")
@@ -447,6 +451,8 @@ export default function Success(props: PageProps) {
? seatReferenceUid
? t("no_longer_attending")
: t("event_cancelled")
: isBookingInPast
? t("event_expired")
: props.recurringBookings
? t("meeting_is_scheduled_recurring")
: t("meeting_is_scheduled")}
@@ -650,7 +656,7 @@ export default function Success(props: PageProps) {
)}
{!requiresLoginToUpdate &&
(!needsConfirmation || !userIsOwner) &&
!isCancelled &&
isReschedulable &&
(!isCancellationMode ? (
<>
<hr className="border-subtle mb-8" />
@@ -708,7 +714,7 @@ export default function Success(props: PageProps) {
{userIsOwner &&
!needsConfirmation &&
!isCancellationMode &&
!isCancelled &&
isReschedulable &&
!!calculatedDuration && (
<>
<hr className="border-subtle mt-8" />
@@ -164,7 +164,7 @@ const Item = ({
</small>
) : null}
{readOnly && (
<Badge variant="gray" className="ml-2">
<Badge variant="gray" className="ml-2" data-testid="readonly-badge">
{t("readonly")}
</Badge>
)}
@@ -195,7 +195,7 @@ const Item = ({
</small>
) : null}
{readOnly && (
<Badge variant="gray" className="ml-2">
<Badge variant="gray" className="ml-2" data-testid="readonly-badge">
{t("readonly")}
</Badge>
)}
@@ -513,7 +513,8 @@ export const EventTypeList = ({
</DropdownItem>
</DropdownMenuItem>
)}
{!isManagedEventType && !isChildrenManagedEventType && (
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
@@ -541,25 +542,24 @@ export const EventTypeList = ({
</DropdownMenuItem>
)}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) &&
!isChildrenManagedEventType && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{!readOnly && !isChildrenManagedEventType && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</Dropdown>
</ButtonGroup>
@@ -629,7 +629,7 @@ export const EventTypeList = ({
</DropdownItem>
</DropdownMenuItem>
)}
{!isManagedEventType && !isChildrenManagedEventType && (
{!readOnly && !isManagedEventType && !isChildrenManagedEventType && (
<DropdownMenuItem className="outline-none">
<DropdownItem
onClick={() => openDuplicateModal(type, group)}
@@ -640,24 +640,23 @@ export const EventTypeList = ({
</DropdownMenuItem>
)}
{/* readonly is only set when we are on a team - if we are on a user event type null will be the value. */}
{(group.metadata?.readOnly === false || group.metadata.readOnly === null) &&
!isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
{!readOnly && !isChildrenManagedEventType && (
<>
<DropdownMenuItem className="outline-none">
<DropdownItem
color="destructive"
onClick={() => {
setDeleteDialogOpen(true);
setDeleteDialogTypeId(type.id);
setDeleteDialogSchedulingType(type.schedulingType);
}}
StartIcon="trash"
className="w-full rounded-none">
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
{!isManagedEventType && (
<div className="hover:bg-subtle flex h-9 cursor-pointer flex-row items-center justify-between px-4 py-2">
@@ -4,6 +4,7 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { isValidPhoneNumber } from "libphonenumber-js";
import type { TFunction } from "next-i18next";
import dynamic from "next/dynamic";
// eslint-disable-next-line @calcom/eslint/deprecated-imports-next-router
import { useRouter } from "next/router";
@@ -14,6 +15,7 @@ import { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import { getEventLocationType } from "@calcom/app-store/locations";
import { validateCustomEventName } from "@calcom/core/event";
import type { Workflow } from "@calcom/features/ee/workflows/lib/types";
import type { ChildrenEventType } from "@calcom/features/eventtypes/components/ChildrenEventTypeSelect";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { validateIntervalLimitOrder } from "@calcom/lib";
@@ -144,7 +146,70 @@ const querySchema = z.object({
export type EventTypeSetupProps = RouterOutputs["viewer"]["eventTypes"]["get"];
export type EventTypeSetup = RouterOutputs["viewer"]["eventTypes"]["get"]["eventType"];
const EventTypePage = (props: EventTypeSetupProps) => {
export const locationsResolver = (t: TFunction) => {
return z
.array(
z
.object({
type: z.string(),
address: z.string().optional(),
link: z.string().url().optional(),
phone: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
hostPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
displayLocationPublicly: z.boolean().optional(),
credentialId: z.number().optional(),
teamName: z.string().optional(),
})
.passthrough()
.superRefine((val, ctx) => {
if (val?.link) {
const link = val.link;
const eventLocationType = getEventLocationType(val.type);
if (
eventLocationType &&
!eventLocationType.default &&
eventLocationType.linkType === "static" &&
eventLocationType.urlRegExp
) {
const valid = z.string().regex(new RegExp(eventLocationType.urlRegExp)).safeParse(link).success;
if (!valid) {
const sampleUrl = eventLocationType.organizerInputPlaceholder;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: t("invalid_url_error_message", {
label: eventLocationType.label,
sampleUrl: sampleUrl ?? "https://cal.com",
}),
});
}
return;
}
const valid = z.string().url().optional().safeParse(link).success;
if (!valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: `Invalid URL`,
});
}
}
return;
})
)
.optional();
};
const EventTypePage = (props: EventTypeSetupProps & { allActiveWorkflows?: Workflow[] }) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const telemetry = useTelemetry();
@@ -251,6 +316,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
destinationCalendar: eventType.destinationCalendar,
recurringEvent: eventType.recurringEvent || null,
isInstantEvent: eventType.isInstantEvent,
instantMeetingExpiryTimeOffsetInSeconds: eventType.instantMeetingExpiryTimeOffsetInSeconds,
description: eventType.description ?? undefined,
schedule: eventType.schedule || undefined,
bookingLimits: eventType.bookingLimits || undefined,
@@ -325,69 +391,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
bookingFields: eventTypeBookingFields,
locations: z
.array(
z
.object({
type: z.string(),
address: z.string().optional(),
link: z.string().url().optional(),
phone: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
hostPhoneNumber: z
.string()
.refine((val) => isValidPhoneNumber(val))
.optional(),
displayLocationPublicly: z.boolean().optional(),
credentialId: z.number().optional(),
teamName: z.string().optional(),
})
.passthrough()
.superRefine((val, ctx) => {
if (val?.link) {
const link = val.link;
const eventLocationType = getEventLocationType(val.type);
if (
eventLocationType &&
!eventLocationType.default &&
eventLocationType.linkType === "static" &&
eventLocationType.urlRegExp
) {
const valid = z
.string()
.regex(new RegExp(eventLocationType.urlRegExp))
.safeParse(link).success;
if (!valid) {
const sampleUrl = eventLocationType.organizerInputPlaceholder;
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: t("invalid_url_error_message", {
label: eventLocationType.label,
sampleUrl: sampleUrl ?? "https://cal.com",
}),
});
}
return;
}
const valid = z.string().url().optional().safeParse(link).success;
if (!valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [eventLocationType?.defaultValueVariable ?? "link"],
message: `Invalid URL`,
});
}
}
return;
})
)
.optional(),
locations: locationsResolver(t),
})
// TODO: Add schema for other fields later.
.passthrough()
@@ -463,11 +467,10 @@ const EventTypePage = (props: EventTypeSetupProps) => {
instant: <EventInstantTab eventType={eventType} isTeamEvent={!!team} />,
recurring: <EventRecurringTab eventType={eventType} />,
apps: <EventAppsTab eventType={{ ...eventType, URL: permalink }} />,
workflows: (
<EventWorkflowsTab
eventType={eventType}
workflows={eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow)}
/>
workflows: props.allActiveWorkflows ? (
<EventWorkflowsTab eventType={eventType} workflows={props.allActiveWorkflows} />
) : (
<></>
),
webhooks: <EventWebhooksTab eventType={eventType} />,
ai: <EventAITab eventType={eventType} isTeamEvent={!!team} />,
@@ -704,7 +707,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
<EventTypeSingleLayout
enabledAppsNumber={numberOfActiveApps}
installedAppsNumber={eventTypeApps?.items.length || 0}
enabledWorkflowsNumber={eventType.workflows.length}
enabledWorkflowsNumber={props.allActiveWorkflows ? props.allActiveWorkflows.length : 0}
eventType={eventType}
activeWebhooksNumber={eventType.webhooks.filter((webhook) => webhook.active).length}
team={team}
@@ -835,13 +838,13 @@ const EventTypePage = (props: EventTypeSetupProps) => {
}}
/>
) : null}
<AssignmentWarningDialog
isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}
setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}
pendingRoute={pendingRoute}
leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}
id={eventType.id}
/>
{/*<AssignmentWarningDialog*/}
{/* isOpenAssignmentWarnDialog={isOpenAssignmentWarnDialog}*/}
{/* setIsOpenAssignmentWarnDialog={setIsOpenAssignmentWarnDialog}*/}
{/* pendingRoute={pendingRoute}*/}
{/* leaveWithoutAssigningHosts={leaveWithoutAssigningHosts}*/}
{/* id={eventType.id}*/}
{/*/>*/}
</>
);
};
@@ -852,7 +855,25 @@ const EventTypePageWrapper: React.FC<PageProps> & {
const { data } = trpc.viewer.eventTypes.get.useQuery({ id: props.type });
if (!data) return null;
return <EventTypePage {...(data as EventTypeSetupProps)} />;
const eventType = data.eventType;
const { data: workflows } = trpc.viewer.workflows.getAllActiveWorkflows.useQuery({
eventType: {
workflows: eventType.workflows,
teamId: eventType.teamId,
userId: eventType.userId,
parent: eventType.parent,
metadata: eventType.metadata,
},
});
const propsData = {
...(data as EventTypeSetupProps),
allActiveWorkflows: workflows,
};
return <EventTypePage {...propsData} />;
};
export default EventTypePageWrapper;
+85 -18
View File
@@ -1,10 +1,48 @@
import { useTranscription } from "@daily-co/daily-react";
import { useTranscription, useRecording } from "@daily-co/daily-react";
import { useDaily, useDailyEvent } from "@daily-co/daily-react";
import React, { Fragment, useCallback, useRef, useState, useLayoutEffect, useEffect } from "react";
import { TRANSCRIPTION_STARTED_ICON, TRANSCRIPTION_STOPPED_ICON } from "@calcom/lib/constants";
import {
TRANSCRIPTION_STARTED_ICON,
RECORDING_IN_PROGRESS_ICON,
TRANSCRIPTION_STOPPED_ICON,
RECORDING_DEFAULT_ICON,
} from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
const BUTTONS = {
STOP_TRANSCRIPTION: {
label: "Stop",
tooltip: "Stop transcription",
iconPath: TRANSCRIPTION_STARTED_ICON,
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
START_TRANSCRIPTION: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
START_RECORDING: {
label: "Record",
tooltip: "Start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
WAIT_FOR_RECORDING_TO_START: {
label: "Starting..",
tooltip: "Please wait while we start recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
STOP_RECORDING: {
label: "Stop",
tooltip: "Stop recording",
iconPath: RECORDING_IN_PROGRESS_ICON,
iconPathDarkMode: RECORDING_IN_PROGRESS_ICON,
},
};
export const CalAiTranscribe = () => {
const daily = useDaily();
const { t } = useLocale();
@@ -15,6 +53,7 @@ export const CalAiTranscribe = () => {
const transcriptRef = useRef<HTMLDivElement | null>(null);
const transcription = useTranscription();
const recording = useRecording();
useDailyEvent(
"app-message",
@@ -26,36 +65,64 @@ export const CalAiTranscribe = () => {
useDailyEvent("transcription-started", (ev) => {
daily?.updateCustomTrayButtons({
transcription: {
label: "Stop",
tooltip: "Stop transcription",
iconPath: TRANSCRIPTION_STARTED_ICON,
iconPathDarkMode: TRANSCRIPTION_STARTED_ICON,
},
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.STOP_TRANSCRIPTION,
});
});
useDailyEvent("recording-started", (ev) => {
daily?.updateCustomTrayButtons({
recording: BUTTONS.STOP_RECORDING,
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
});
});
useDailyEvent("transcription-stopped", (ev) => {
daily?.updateCustomTrayButtons({
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
iconPath: TRANSCRIPTION_STOPPED_ICON,
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
},
recording: recording?.isRecording ? BUTTONS.STOP_RECORDING : BUTTONS.START_RECORDING,
transcription: BUTTONS.START_TRANSCRIPTION,
});
});
useDailyEvent("custom-button-click", (ev) => {
if (ev?.button_id !== "transcription") {
return;
}
useDailyEvent("recording-stopped", (ev) => {
daily?.updateCustomTrayButtons({
recording: BUTTONS.START_RECORDING,
transcription: transcription?.isTranscribing ? BUTTONS.STOP_TRANSCRIPTION : BUTTONS.START_TRANSCRIPTION,
});
});
const toggleRecording = async () => {
if (recording?.isRecording) {
await daily?.stopRecording();
} else {
daily?.updateCustomTrayButtons({
recording: BUTTONS.WAIT_FOR_RECORDING_TO_START,
transcription: transcription?.isTranscribing
? BUTTONS.STOP_TRANSCRIPTION
: BUTTONS.START_TRANSCRIPTION,
});
await daily?.startRecording({
// 480p
videoBitrate: 2000,
});
}
};
const toggleTranscription = async () => {
if (transcription?.isTranscribing) {
daily?.stopTranscription();
} else {
daily?.startTranscription();
}
};
useDailyEvent("custom-button-click", async (ev) => {
if (ev?.button_id === "recording") {
toggleRecording();
} else if (ev?.button_id === "transcription") {
toggleTranscription();
}
});
useLayoutEffect(() => {
@@ -1,6 +1,10 @@
import MarkdownIt from "markdown-it";
import type { GetServerSidePropsContext } from "next";
import {
generateGuestMeetingTokenFromOwnerMeetingToken,
setEnableRecordingUIForOrganizer,
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { UserRepository } from "@calcom/lib/server/repository/user";
@@ -39,6 +43,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
references: {
select: {
id: true,
uid: true,
type: true,
meetingUrl: true,
@@ -103,12 +108,31 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const session = await getServerSession({ req });
// set meetingPassword to null for guests
const oldVideoReference = getCalVideoReference(bookingObj.references);
// set meetingPassword for guests
if (session?.user.id !== bookingObj.user?.id) {
const guestMeetingPassword = await generateGuestMeetingTokenFromOwnerMeetingToken(
oldVideoReference.meetingPassword
);
bookingObj.references.forEach((bookRef) => {
bookRef.meetingPassword = null;
bookRef.meetingPassword = guestMeetingPassword;
});
}
// Only for backward compatibility for organizer
else {
const meetingPassword = await setEnableRecordingUIForOrganizer(
oldVideoReference.id,
oldVideoReference.meetingPassword
);
if (!!meetingPassword) {
bookingObj.references.forEach((bookRef) => {
bookRef.meetingPassword = meetingPassword;
});
}
}
const videoReference = getCalVideoReference(bookingObj.references);
return {
@@ -9,7 +9,7 @@ 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 } from "@calcom/lib/constants";
import { TRANSCRIPTION_STOPPED_ICON } from "@calcom/lib/constants";
import { TRANSCRIPTION_STOPPED_ICON, RECORDING_DEFAULT_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";
@@ -50,6 +50,12 @@ export default function JoinCall(props: PageProps) {
...(typeof meetingPassword === "string" && { token: meetingPassword }),
...(hasTeamPlan && {
customTrayButtons: {
recording: {
label: "Record",
tooltip: "Start or stop recording",
iconPath: RECORDING_DEFAULT_ICON,
iconPathDarkMode: RECORDING_DEFAULT_ICON,
},
transcription: {
label: "Cal.ai",
tooltip: "Transcription powered by AI",
+14
View File
@@ -54,6 +54,20 @@ if (!process.env.NEXT_PUBLIC_API_V2_URL) {
console.error("Please set NEXT_PUBLIC_API_V2_URL");
}
const getHttpsUrl = (url) => {
if (!url) return url;
if (url.startsWith("http://")) {
return url.replace("http://", "https://");
}
return url;
};
if (process.argv.includes("--experimental-https")) {
process.env.NEXT_PUBLIC_WEBAPP_URL = getHttpsUrl(process.env.NEXT_PUBLIC_WEBAPP_URL);
process.env.NEXTAUTH_URL = getHttpsUrl(process.env.NEXTAUTH_URL);
process.env.NEXT_PUBLIC_EMBED_LIB_URL = getHttpsUrl(process.env.NEXT_PUBLIC_EMBED_LIB_URL);
}
const validJson = (jsonString) => {
try {
const o = JSON.parse(jsonString);
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.2.2",
"version": "4.2.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -8,6 +8,7 @@
"analyze:browser": "BUNDLE_ANALYZE=browser next build",
"clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next",
"dev": "next dev",
"dev-https": "NODE_TLS_REJECT_UNAUTHORIZED=0 next dev --experimental-https",
"dx": "yarn dev",
"test-codegen": "yarn playwright codegen http://localhost:3000",
"type-check": "tsc --pretty --noEmit",
+8 -1
View File
@@ -1,8 +1,10 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import stripe from "@calcom/app-store/stripepayment/lib/server";
import dayjs from "@calcom/dayjs";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { IS_STRIPE_ENABLED } from "@calcom/lib/constants";
import { prisma } from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
@@ -66,7 +68,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: true,
},
});
if (existingUser) {
return res.status(401).json({ message: USER_ALREADY_EXISTING_MESSAGE });
}
@@ -101,6 +102,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
},
});
if (IS_STRIPE_ENABLED && userMetadataParsed.stripeCustomerId) {
await stripe.customers.update(userMetadataParsed.stripeCustomerId, {
email: updatedEmail,
});
}
// The user is trying to update the email to an already existing unverified secondary email of his
// so we swap the emails and its verified status
if (existingSecondaryUser?.userId === user.id) {
+1 -3
View File
@@ -1,7 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import type { Session } from "next-auth";
import getInstalledAppPath from "@calcom/app-store/_utils/getInstalledAppPath";
import { throwIfNotHaveAdminAccessToTeam } from "@calcom/app-store/_utils/throwIfNotHaveAdminAccessToTeam";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
@@ -62,7 +61,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
const handlers = await handlerMap[handlerKey as keyof typeof handlerMap];
if (!handlers) throw new HttpError({ statusCode: 404, message: `No handlers found for ${handlerKey}` });
const handler = handlers[apiEndpoint as keyof typeof handlers] as AppHandler;
let redirectUrl = "/apps/installed";
if (typeof handler === "undefined")
throw new HttpError({ statusCode: 404, message: `API handler not found` });
@@ -70,7 +68,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
await handler(req, res);
} else {
await defaultIntegrationAddHandler({ user: req.session?.user, teamId: Number(teamId), ...handler });
redirectUrl = handler.redirect?.url || getInstalledAppPath(handler);
const redirectUrl = handler.redirect?.url ?? undefined;
res.json({ url: redirectUrl, newTab: handler.redirect?.newTab });
}
if (!res.writableEnded) return res.status(200);
+103 -233
View File
@@ -1,8 +1,7 @@
import type { WebhookTriggerEvents } from "@prisma/client";
import { createHmac } from "crypto";
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { getRoomNameFromRecordingId, getBatchProcessorJobAccessLink } from "@calcom/app-store/dailyvideo/lib";
import {
getDownloadLinkOfCalVideoByRecordingId,
submitBatchProcessorTranscriptionJob,
@@ -10,210 +9,48 @@ import {
import { getAllTranscriptsAccessLinkFromRoomName } from "@calcom/core/videoClient";
import { sendDailyVideoRecordingEmails } from "@calcom/emails";
import { sendDailyVideoTranscriptEmails } from "@calcom/emails";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import getOrgIdFromMemberOrTeamId from "@calcom/lib/getOrgIdFromMemberOrTeamId";
import { getTeamIdFromEventType } from "@calcom/lib/getTeamIdFromEventType";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { defaultHandler } from "@calcom/lib/server";
import { getTranslation } from "@calcom/lib/server/i18n";
import prisma, { bookingMinimalSelect } from "@calcom/prisma";
import type { CalendarEvent } from "@calcom/types/Calendar";
import prisma from "@calcom/prisma";
import { getBooking } from "@calcom/web/lib/daily-webhook/getBooking";
import { getBookingReference } from "@calcom/web/lib/daily-webhook/getBookingReference";
import { getCalendarEvent } from "@calcom/web/lib/daily-webhook/getCalendarEvent";
import {
meetingEndedSchema,
recordingReadySchema,
batchProcessorJobFinishedSchema,
downloadLinkSchema,
testRequestSchema,
} from "@calcom/web/lib/daily-webhook/schema";
import {
triggerRecordingReadyWebhook,
triggerTranscriptionGeneratedWebhook,
} from "@calcom/web/lib/daily-webhook/triggerWebhooks";
const log = logger.getSubLogger({ prefix: ["daily-video-webhook-handler"] });
const commonSchema = z
.object({
version: z.string(),
type: z.string(),
id: z.string(),
event_ts: z.number().optional(),
})
.passthrough();
const meetingEndedSchema = commonSchema.extend({
payload: z
.object({
meeting_id: z.string(),
end_ts: z.number().optional(),
room: z.string(),
start_ts: z.number().optional(),
})
.passthrough(),
});
const recordingReadySchema = commonSchema.extend({
payload: z.object({
recording_id: z.string(),
end_ts: z.number().optional(),
room_name: z.string(),
start_ts: z.number().optional(),
status: z.string(),
max_participants: z.number().optional(),
duration: z.number().optional(),
s3_key: z.string().optional(),
}),
});
const downloadLinkSchema = z.object({
download_link: z.string(),
});
const triggerWebhook = async ({
evt,
downloadLink,
booking,
}: {
evt: CalendarEvent;
downloadLink: string;
booking: {
userId: number | undefined;
eventTypeId: number | null;
eventTypeParentId: number | null | undefined;
teamId?: number | null;
};
}) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const organizerUserId = triggerForUser ? booking.userId : null;
const orgId = await getOrgIdFromMemberOrTeamId({ memberId: organizerUserId, teamId: booking.teamId });
const subscriberOptions = {
userId: organizerUserId,
eventTypeId: booking.eventTypeId,
triggerEvent: eventTrigger,
teamId: booking.teamId,
orgId,
};
const webhooks = await getWebhooks(subscriberOptions);
log.debug(
"Webhooks:",
safeStringify({
webhooks,
})
);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
downloadLink,
}).catch((e) => {
log.error(
`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}, bookingId: ${evt.bookingId}, bookingUid: ${evt.uid}`,
safeStringify(e)
);
})
);
await Promise.all(promises);
const computeSignature = (
hmacSecret: string,
reqBody: NextApiRequest["body"],
webhookTimestampHeader: string | string[] | undefined
) => {
const signature = `${webhookTimestampHeader}.${JSON.stringify(reqBody)}`;
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
const hmac = createHmac("sha256", base64DecodedSecret);
const computed_signature = hmac.update(signature).digest("base64");
return computed_signature;
};
const getBooking = async (bookingId: number) => {
const booking = await prisma.booking.findUniqueOrThrow({
where: {
id: bookingId,
},
select: {
...bookingMinimalSelect,
uid: true,
location: true,
isRecorded: true,
eventTypeId: true,
eventType: {
select: {
teamId: true,
parentId: true,
},
},
user: {
select: {
id: true,
timeZone: true,
email: true,
name: true,
locale: true,
destinationCalendar: true,
},
},
},
});
if (!booking) {
log.error(
"Couldn't find Booking Id:",
safeStringify({
bookingId,
})
);
}
return booking;
const getDownloadLinkOfCalVideo = async (recordingId: string) => {
const response = await getDownloadLinkOfCalVideoByRecordingId(recordingId);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
return downloadLink;
};
const getBookingReference = async (roomName: string) => {
const bookingReference = await prisma.bookingReference.findFirst({
where: { type: "daily_video", uid: roomName, meetingId: roomName },
select: { bookingId: true },
});
if (!bookingReference) {
log.error(
"bookingReference not found error:",
safeStringify({
bookingReference,
roomName,
})
);
}
return bookingReference;
};
const getCalendarEvent = async (booking: Awaited<ReturnType<typeof getBooking>>) => {
const t = await getTranslation(booking?.user?.locale ?? "en", "common");
const attendeesListPromises = booking.attendees.map(async (attendee) => {
return {
id: attendee.id,
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: {
translate: await getTranslation(attendee.locale ?? "en", "common"),
locale: attendee.locale ?? "en",
},
};
});
const attendeesList = await Promise.all(attendeesListPromises);
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
description: booking.description || undefined,
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
name: booking.user?.name || "Nameless",
timeZone: booking.user?.timeZone || "Europe/London",
language: { translate: t, locale: booking?.user?.locale ?? "en" },
},
attendees: attendeesList,
uid: booking.uid,
};
return Promise.resolve(evt);
};
const testRequestSchema = z.object({
test: z.enum(["test"]),
});
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
return res.status(405).json({ message: "No SendGrid API key or email" });
@@ -223,18 +60,19 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
return res.status(200).json({ message: "Test request successful" });
}
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
if (!hmacSecret) {
return res.status(405).json({ message: "No Daily Webhook Secret" });
}
const testMode = process.env.NEXT_PUBLIC_IS_E2E || process.env.INTEGRATION_TEST_MODE;
const signature = `${req.headers["x-webhook-timestamp"]}.${JSON.stringify(req.body)}`;
const base64DecodedSecret = Buffer.from(hmacSecret, "base64");
const hmac = createHmac("sha256", base64DecodedSecret);
const computed_signature = hmac.update(signature).digest("base64");
if (!testMode) {
const hmacSecret = process.env.DAILY_WEBHOOK_SECRET;
if (!hmacSecret) {
return res.status(405).json({ message: "No Daily Webhook Secret" });
}
if (req.headers["x-webhook-signature"] !== computed_signature) {
return res.status(403).json({ message: "Signature does not match" });
const computed_signature = computeSignature(hmacSecret, req.body, req.headers["x-webhook-timestamp"]);
if (req.headers["x-webhook-signature"] !== computed_signature) {
return res.status(403).json({ message: "Signature does not match" });
}
}
log.debug(
@@ -261,19 +99,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
message: "Recording not finished",
});
}
const bookingReference = await getBookingReference(room_name);
if (!bookingReference || !bookingReference.bookingId) {
return res.status(200).send({ message: "Booking reference not found" });
}
const booking = await getBooking(bookingReference.bookingId);
if (!booking) {
return res.status(404).send({
message: `Booking of room_name ${room_name} does not exist or does not contain daily video as location`,
});
}
const booking = await getBooking(bookingReference.bookingId as number);
const evt = await getCalendarEvent(booking);
@@ -286,9 +114,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
const response = await getDownloadLinkOfCalVideoByRecordingId(recording_id);
const downloadLinkResponse = downloadLinkSchema.parse(response);
const downloadLink = downloadLinkResponse.download_link;
const downloadLink = await getDownloadLinkOfCalVideo(recording_id);
const teamId = await getTeamIdFromEventType({
eventType: {
@@ -297,7 +123,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
},
});
await triggerWebhook({
await triggerRecordingReadyWebhook({
evt,
downloadLink,
booking: {
@@ -317,6 +143,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
// send emails to all attendees only when user has team plan
await sendDailyVideoRecordingEmails(evt, downloadLink);
return res.status(200).json({ message: "Success" });
} else if (req.body.type === "meeting.ended") {
const meetingEndedResponse = meetingEndedSchema.safeParse(req.body);
@@ -325,20 +152,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
message: "Invalid Payload",
});
}
const { room } = meetingEndedResponse.data.payload;
const bookingReference = await getBookingReference(room);
if (!bookingReference || !bookingReference.bookingId) {
return res.status(200).send({ message: "Booking reference not found" });
}
const booking = await getBooking(bookingReference.bookingId);
if (!booking) {
return res.status(404).send({
message: `Booking of room_name ${room} does not exist or does not contain daily video as location`,
});
}
const booking = await getBooking(bookingReference.bookingId as number);
const transcripts = await getAllTranscriptsAccessLinkFromRoomName(room);
@@ -349,10 +167,62 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
await sendDailyVideoTranscriptEmails(evt, transcripts);
return res.status(200).json({ message: "Success" });
} else if (req.body?.type === "batch-processor.job-finished") {
const batchProcessorJobFinishedResponse = batchProcessorJobFinishedSchema.safeParse(req.body);
if (!batchProcessorJobFinishedResponse.success) {
return res.status(400).send({
message: "Invalid Payload",
});
}
const { id, input } = batchProcessorJobFinishedResponse.data.payload;
const roomName = await getRoomNameFromRecordingId(input.recordingId);
const bookingReference = await getBookingReference(roomName);
const booking = await getBooking(bookingReference.bookingId as number);
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: booking?.eventType?.teamId ?? null },
parentId: booking?.eventType?.parentId ?? null,
},
});
const evt = await getCalendarEvent(booking);
const recording = await getDownloadLinkOfCalVideo(input.recordingId);
const batchProcessorJobAccessLink = await getBatchProcessorJobAccessLink(id);
await triggerTranscriptionGeneratedWebhook({
evt,
downloadLinks: {
transcription: batchProcessorJobAccessLink.transcription,
recording,
},
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});
return res.status(200).json({ message: "Success" });
} else {
log.error("Invalid type in /recorded-daily-video", req.body);
return res.status(200).json({ message: "Invalid type in /recorded-daily-video" });
}
} catch (err) {
log.error("Error in /recorded-daily-video", err);
return res.status(500).json({ message: "something went wrong" });
if (err instanceof HttpError) {
return res.status(err.statusCode).json({ message: err.message });
} else {
return res.status(500).json({ message: "something went wrong" });
}
}
}
-1
View File
@@ -53,7 +53,6 @@ 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}
+124 -56
View File
@@ -8,17 +8,22 @@ import { Toaster } from "react-hot-toast";
import { z } from "zod";
import checkForMultiplePaymentApps from "@calcom/app-store/_utils/payments/checkForMultiplePaymentApps";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import type { EventTypeAppSettingsComponentProps, EventTypeModel } from "@calcom/app-store/types";
import { isConferencing as isConferencingApp } from "@calcom/app-store/utils";
import type { LocationObject } from "@calcom/core/location";
import { getLocale } from "@calcom/features/auth/lib/getLocale";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingRedirectUrl } from "@calcom/lib/apps/getAppOnboardingRedirectUrl";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { CAL_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc/react";
import type { AppMeta } from "@calcom/types/App";
@@ -34,8 +39,13 @@ import { EventTypesStepCard } from "@components/apps/installation/EventTypesStep
import { StepHeader } from "@components/apps/installation/StepHeader";
export type TEventType = EventTypeAppSettingsComponentProps["eventType"] &
Pick<EventTypeModel, "metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position"> & {
Pick<
EventTypeModel,
"metadata" | "schedulingType" | "slug" | "requiresConfirmation" | "position" | "destinationCalendar"
> & {
selected: boolean;
locations: LocationFormValues["locations"];
bookingFields?: LocationFormValues["bookingFields"];
};
export type TEventTypesForm = {
@@ -47,7 +57,6 @@ const STEPS = [
AppOnboardingSteps.EVENT_TYPES_STEP,
AppOnboardingSteps.CONFIGURE_STEP,
] as const;
const MAX_NUMBER_OF_STEPS = STEPS.length;
type StepType = (typeof STEPS)[number];
@@ -63,11 +72,21 @@ type StepObj = Record<
type OnboardingPageProps = {
appMetadata: AppMeta;
step: StepType;
teams: TeamsProp;
teams?: TeamsProp;
personalAccount: PersonalAccountProps;
eventTypes?: TEventType[];
userName: string;
credentialId?: number;
showEventTypesStep: boolean;
isConferencing: boolean;
installableOnTeams: boolean;
};
type TUpdateObject = {
id: number;
metadata?: z.infer<typeof EventTypeMetaDataSchema>;
bookingFields?: z.infer<typeof eventTypeBookingFields>;
locations?: LocationObject[];
};
const OnboardingPage = ({
@@ -78,6 +97,9 @@ const OnboardingPage = ({
eventTypes,
userName,
credentialId,
showEventTypesStep,
isConferencing,
installableOnTeams,
}: OnboardingPageProps) => {
const { t } = useLocale();
const pathname = usePathname();
@@ -92,16 +114,15 @@ const OnboardingPage = ({
[AppOnboardingSteps.EVENT_TYPES_STEP]: {
getTitle: () => `${t("select_event_types_header")}`,
getDescription: (appName) => `${t("select_event_types_description", { appName })}`,
stepNumber: 2,
stepNumber: installableOnTeams ? 2 : 1,
},
[AppOnboardingSteps.CONFIGURE_STEP]: {
getTitle: (appName) => `${t("configure_app_header", { appName })}`,
getDescription: () => `${t("configure_app_description")}`,
stepNumber: 3,
stepNumber: installableOnTeams ? 3 : 2,
},
} as const;
const [configureStep, setConfigureStep] = useState(false);
const [isSelectingAccount, setIsSelectingAccount] = useState(false);
const currentStep: AppOnboardingSteps = useMemo(() => {
if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) {
@@ -111,6 +132,13 @@ const OnboardingPage = ({
}, [step, configureStep]);
const stepObj = STEPS_MAP[currentStep];
const maxSteps = useMemo(() => {
if (!showEventTypesStep) {
return 1;
}
return installableOnTeams ? STEPS.length : STEPS.length - 1;
}, [showEventTypesStep, installableOnTeams]);
const utils = trpc.useContext();
const formPortalRef = useRef<HTMLDivElement>(null);
@@ -120,9 +148,19 @@ const OnboardingPage = ({
eventTypes,
},
});
const mutation = useAddAppMutation(null, {
onSuccess: (data) => {
if (data?.setupPending) return;
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
},
});
useEffect(() => {
eventTypes && formMethods.setValue("eventTypes", eventTypes);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventTypes]);
const updateMutation = trpc.viewer.eventTypes.update.useMutation({
@@ -156,47 +194,22 @@ const OnboardingPage = ({
});
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(
mutation.mutate({
type: appMetadata.type,
variant: appMetadata.variant,
slug: appMetadata.slug,
...(teamId && { teamId }),
// for oAuth apps
...(showEventTypesStep && {
returnTo:
WEBAPP_URL +
getAppOnboardingUrl({
slug: appMetadata.slug,
step: AppOnboardingSteps.EVENT_TYPES_STEP,
teamId,
})
);
}
} catch (error) {
setIsSelectingAccount(false);
router.push(`/apps`);
}
step: AppOnboardingSteps.EVENT_TYPES_STEP,
}),
}),
});
};
const handleSetUpLater = () => {
@@ -233,10 +246,21 @@ const OnboardingPage = ({
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,
});
let updateObject: TUpdateObject = { id: value.id };
if (isConferencing) {
updateObject = {
...updateObject,
locations: value.locations,
bookingFields: value.bookingFields ? value.bookingFields : undefined,
};
} else {
updateObject = {
...updateObject,
metadata: value.metadata,
};
}
return updateMutation.mutateAsync(updateObject);
});
try {
await Promise.all(mutationPromises);
@@ -248,14 +272,15 @@ const OnboardingPage = ({
<StepHeader
title={stepObj.getTitle(appMetadata.name)}
subtitle={stepObj.getDescription(appMetadata.name)}>
<Steps maxSteps={MAX_NUMBER_OF_STEPS} currentStep={stepObj.stepNumber} disableNavigation />
<Steps maxSteps={maxSteps} currentStep={stepObj.stepNumber} disableNavigation />
</StepHeader>
{currentStep === AppOnboardingSteps.ACCOUNTS_STEP && (
<AccountsStepCard
teams={teams}
personalAccount={personalAccount}
onSelect={handleSelectAccount}
loading={isSelectingAccount}
loading={mutation.isPending}
installableOnTeams={installableOnTeams}
/>
)}
{currentStep === AppOnboardingSteps.EVENT_TYPES_STEP &&
@@ -278,6 +303,7 @@ const OnboardingPage = ({
setConfigureStep={setConfigureStep}
eventTypes={eventTypes}
handleSetUpLater={handleSetUpLater}
isConferencing={isConferencing}
/>
)}
</Form>
@@ -385,19 +411,31 @@ const getEventTypes = async (userId: number, teamId?: number) => {
users: { select: { username: true } },
seatsPerTimeSlot: true,
slug: true,
locations: true,
userId: true,
destinationCalendar: true,
bookingFields: true,
},
where: teamId ? { teamId } : { userId, teamId: null },
/**
* filter out managed events for now
* @todo: can install apps to managed event types
*/
where: teamId ? { teamId } : { userId, parent: null, 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,
locations: item.locations as unknown as LocationObject[],
bookingFields: eventTypeBookingFields.parse(item.bookingFields || []),
}));
};
@@ -434,12 +472,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
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";
const extendsEventType = appMetadata?.extendsFeature === "EventType";
const isConferencing = isConferencingApp(appMetadata.categories);
const showEventTypesStep = extendsEventType || isConferencing;
console.log("sshowEventTypesStephowEventTypesStep: ", showEventTypesStep);
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);
@@ -460,7 +499,31 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
}
if (parsedStepParam == AppOnboardingSteps.EVENT_TYPES_STEP) {
if (!showEventTypesStep) {
return {
redirect: {
permanent: false,
destination: `/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`,
},
};
}
eventTypes = await getEventTypes(user.id, parsedTeamIdParam);
if (isConferencing) {
const destinationCalendar = await prisma.destinationCalendar.findFirst({
where: {
userId: user.id,
eventTypeId: null,
},
});
for (let index = 0; index < eventTypes.length; index++) {
let eventType = eventTypes[index];
if (!eventType.destinationCalendar) {
eventType = { ...eventType, destinationCalendar };
}
eventTypes[index] = eventType;
}
}
if (eventTypes.length === 0) {
return {
redirect: {
@@ -498,6 +561,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
...(await serverSideTranslations(locale, ["common"])),
app,
appMetadata,
showEventTypesStep,
step: parsedStepParam,
teams: teamsWithIsAppInstalled,
personalAccount,
@@ -505,9 +569,13 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
teamId: parsedTeamIdParam ?? null,
userName: user.username,
credentialId,
isConferencing,
// conferencing apps dont support team install
installableOnTeams: !isConferencing,
} as OnboardingPageProps,
};
} catch (err) {
console.log("eerrerrerrerrerrerrerrerrrr: ", err);
if (err instanceof z.ZodError) {
return { redirect: { permanent: false, destination: "/apps" } };
}
+14 -3
View File
@@ -3,7 +3,7 @@ import Link from "next/link";
import { useRouter, usePathname } from "next/navigation";
import { useCallback, useState } from "react";
import { BulkEditDefaultForEventsModal } from "@calcom/features/eventtypes/components/BulkEditDefaultForEventsModal";
import { BulkEditDefaultModal } from "@calcom/features/eventtypes/components/BulkEditDefaultModal";
import { NewScheduleButton, ScheduleListItem } from "@calcom/features/schedules";
import Shell from "@calcom/features/shell/Shell";
import { AvailabilitySliderTable } from "@calcom/features/timezone-buddy/components/AvailabilitySliderTable";
@@ -24,12 +24,14 @@ import SkeletonLoader from "@components/availability/SkeletonLoader";
export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availability"]["list"]) {
const { t } = useLocale();
const [bulkUpdateModal, setBulkUpdateModal] = useState(false);
const [eventTypeIds, setEventTypeIds] = useState<number[]>([]);
const utils = trpc.useUtils();
const meQuery = trpc.viewer.me.useQuery();
const router = useRouter();
const { data } = trpc.viewer.eventTypes.bulkEventFetch.useQuery();
const deleteMutation = trpc.viewer.availability.schedule.delete.useMutation({
onMutate: async ({ scheduleId }) => {
await utils.viewer.availability.list.cancel();
@@ -143,11 +145,20 @@ export function AvailabilityList({ schedules }: RouterOutputs["viewer"]["availab
</Link>
</div>
{bulkUpdateModal && (
<BulkEditDefaultForEventsModal
<BulkEditDefaultModal
isPending={bulkUpdateDefaultAvailabilityMutation.isPending}
open={bulkUpdateModal}
setOpen={setBulkUpdateModal}
bulkUpdateFunction={bulkUpdateDefaultAvailabilityMutation.mutate}
title={t("default_conferencing_bulk_title")}
description={t("default_conferencing_bulk_description")}
ids={eventTypeIds}
setIds={setEventTypeIds}
data={data?.eventTypes}
handleSubmit={() => {
bulkUpdateDefaultAvailabilityMutation.mutate({
eventTypeIds,
});
}}
/>
)}
</>
@@ -642,6 +642,7 @@ const ProfileForm = ({
{showRemoveAvatarButton && (
<Button
color="secondary"
className="hover:bg-red-600"
onClick={() => {
onChange(null);
}}>
+2 -9
View File
@@ -123,15 +123,8 @@ function UsernameField({
});
}
checkUsername();
}, [
debouncedUsername,
setPremium,
disabled,
orgSlug,
setUsernameTaken,
formState.isSubmitting,
formState.isSubmitSuccessful,
]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedUsername, disabled, orgSlug, formState.isSubmitting, formState.isSubmitSuccessful]);
return (
<div>
@@ -5,39 +5,41 @@ const ALL_APPS = ["fathom", "matomo", "plausible", "ga4", "gtm", "metapixel"];
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("Check analytics Apps ", () => {
test("Check analytics Apps by skipping the configure step", async ({ appsPage, page, users }) => {
const user = await users.create();
await user.apiLogin();
await page.goto("/apps/");
await appsPage.goToAppsCategory("analytics");
for (const app of ALL_APPS) {
await appsPage.installAppSkipConfigure(app);
await appsPage.goBackToAppsPage();
}
await page.goto("/event-types");
await appsPage.goToEventType("30 min");
await appsPage.goToAppsTab();
await appsPage.verifyAppsInfo(0);
for (const app of ALL_APPS) {
await appsPage.activeApp(app);
}
await appsPage.verifyAppsInfo(6);
test.describe("check analytics Apps", () => {
test.describe("check analytics apps by skipping the configure step", () => {
ALL_APPS.forEach((app) => {
test(`check analytics app: ${app} by skipping the configure step`, async ({
appsPage,
page,
users,
}) => {
const user = await users.create();
await user.apiLogin();
await page.goto("apps/categories/analytics");
await appsPage.installAnalyticsAppSkipConfigure(app);
await page.goto("/event-types");
await appsPage.goToEventType("30 min");
await appsPage.goToAppsTab();
await appsPage.verifyAppsInfo(0);
await appsPage.activeApp(app);
await appsPage.verifyAppsInfo(1);
});
});
});
test("Check analytics Apps using the new flow", async ({ appsPage, page, users }) => {
const user = await users.create();
await user.apiLogin();
const eventTypes = await user.getUserEventsAsOwner();
const eventTypesIds = eventTypes.map((item) => item.id);
for (const app of ALL_APPS) {
await page.goto("/apps/categories/analytics");
await appsPage.installApp(app, eventTypesIds);
}
for (const id of eventTypesIds) {
await appsPage.verifyAppsInfoNew(ALL_APPS, id);
}
test.describe("check analytics apps using the new flow", () => {
ALL_APPS.forEach((app) => {
test(`check analytics app: ${app}`, async ({ appsPage, page, users }) => {
const user = await users.create();
await user.apiLogin();
const eventTypes = await user.getUserEventsAsOwner();
const eventTypesIds = eventTypes.map((item) => item.id);
await page.goto("/apps/categories/analytics");
await appsPage.installAnalyticsApp(app, eventTypesIds);
for (const id of eventTypesIds) {
await appsPage.verifyAppsInfoNew(app, id);
}
});
});
});
});
@@ -0,0 +1,146 @@
import { test } from "../../lib/fixtures";
export type TApp = {
slug: string;
type: string;
organizerInputPlaceholder?: string;
label: string;
};
type TAllApps = {
[key: string]: TApp;
};
const ALL_APPS: TAllApps = {
around: {
slug: "around",
type: "integrations:around_video",
organizerInputPlaceholder: "https://www.around.co/rick",
label: "Around Video",
},
campfire: {
slug: "campfire",
type: "integrations:campfire_video",
organizerInputPlaceholder: "https://party.campfire.to/your-team",
label: "Campfire",
},
demodesk: {
slug: "demodesk",
type: "integrations:demodesk_video",
organizerInputPlaceholder: "https://demodesk.com/meet/mylink",
label: "Demodesk",
},
discord: {
slug: "discord",
type: "integrations:discord_video",
organizerInputPlaceholder: "https://discord.gg/420gg69",
label: "Discord",
},
eightxeight: {
slug: "eightxeight",
type: "integrations:eightxeight_video",
organizerInputPlaceholder: "https://8x8.vc/company",
label: "8x8",
},
"element-call": {
slug: "element-call",
type: "integrations:element-call_video",
organizerInputPlaceholder: "https://call.element.io/",
label: "Element Call",
},
facetime: {
slug: "facetime",
type: "integrations:facetime_video",
organizerInputPlaceholder: "https://facetime.apple.com/join=#v=1&p=zU9w7QzuEe",
label: "Facetime",
},
mirotalk: {
slug: "mirotalk",
type: "integrations:mirotalk_video",
organizerInputPlaceholder: "https://p2p.mirotalk.com/join/80085ShinyPhone",
label: "Mirotalk",
},
ping: {
slug: "ping",
type: "integrations:ping_video",
organizerInputPlaceholder: "https://www.ping.gg/call/theo",
label: "Ping.gg",
},
riverside: {
slug: "riverside",
type: "integrations:riverside_video",
organizerInputPlaceholder: "https://riverside.fm/studio/abc123",
label: "Riverside Video",
},
roam: {
slug: "roam",
type: "integrations:roam_video",
organizerInputPlaceholder: "https://ro.am/r/#/p/yHwFBQrRTMuptqKYo_wu8A/huzRiHnR-np4RGYKV-c0pQ",
label: "Roam",
},
salesroom: {
slug: "salesroom",
type: "integrations:salesroom_video",
organizerInputPlaceholder: "https://user.sr.chat",
label: "Salesroom",
},
sirius_video: {
slug: "sirius_video",
type: "integrations:sirius_video_video",
organizerInputPlaceholder: "https://sirius.video/sebastian",
label: "Sirius Video",
},
whereby: {
slug: "whereby",
type: "integrations:whereby_video",
label: "Whereby Video",
organizerInputPlaceholder: "https://www.whereby.com/cal",
},
};
const ALL_APPS_ARRAY: TApp[] = Object.values(ALL_APPS);
/**
* @todo add tests for
* shimmervideo
* sylapsvideo
* googlevideo
* huddle
* jelly
* jistivideo
* office365video
* mirotalk
* tandemvideo
* webex
* zoomvideo
*/
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("check non-oAuth link-based conferencing apps", () => {
ALL_APPS_ARRAY.forEach((app) => {
test(`check conferencing app: ${app.slug} by skipping the configure step`, async ({
appsPage,
page,
users,
}) => {
const user = await users.create();
await user.apiLogin();
await page.goto("apps/categories/conferencing");
await appsPage.installConferencingAppSkipConfigure(app.slug);
await appsPage.verifyConferencingApp(app);
});
});
});
test.describe("check non-oAuth link-based conferencing apps using the new flow", () => {
ALL_APPS_ARRAY.forEach((app) => {
test(`can add ${app.slug} app and book with it`, async ({ appsPage, page, users }) => {
const user = await users.create();
await user.apiLogin();
const eventTypes = await user.getUserEventsAsOwner();
const eventTypeIds = eventTypes.map((item) => item.id).filter((item, index) => index < 2);
await appsPage.installConferencingAppNewFlow(app, eventTypeIds);
await appsPage.verifyConferencingAppNew(app, eventTypeIds);
});
});
});
+1 -1
View File
@@ -119,7 +119,7 @@ test.describe("Availablity", () => {
await expect(page.getByTestId("availablity-title")).toHaveValue("More working hours");
});
await test.step("Can delete a schedule", async () => {
await page.getByRole("button", { name: /Go Back/i }).click();
await page.getByTestId("go-back-button").click();
await page.locator('[data-testid="schedules"] > li').nth(1).getByTestId("schedule-more").click();
await page.locator('[data-testid="delete-schedule"]').click();
const toast = await page.waitForSelector('[data-testid="toast-success"]');
+20 -2
View File
@@ -63,7 +63,7 @@ test.describe("Bookings", () => {
});
});
test.describe("Past bookings", () => {
test("Mark first guest as no-show", async ({ page, users, bookings }) => {
test("Mark first guest as no-show", async ({ page, users, bookings, webhooks }) => {
const firstUser = await users.create();
const secondUser = await users.create();
@@ -81,8 +81,8 @@ test.describe("Bookings", () => {
],
});
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
await firstUser.apiLogin();
const webhookReceiver = await webhooks.createReceiver();
await page.goto(`/bookings/past`);
const pastBookings = page.locator('[data-testid="past-bookings"]');
const firstPastBooking = pastBookings.locator('[data-testid="booking-item"]').nth(0);
@@ -95,6 +95,24 @@ test.describe("Bookings", () => {
await firstGuest.click();
await expect(titleAndAttendees.locator('[data-testid="unmark-no-show"]')).toBeVisible();
await expect(titleAndAttendees.locator('[data-testid="mark-no-show"]')).toBeHidden();
await webhookReceiver.waitForRequestCount(1);
const [request] = webhookReceiver.requestList;
const body = request.body;
// remove dynamic properties that differs depending on where you run the tests
const dynamic = "[redacted/dynamic]";
// @ts-expect-error we are modifying the object
body.createdAt = dynamic;
expect(body).toMatchObject({
triggerEvent: "BOOKING_NO_SHOW_UPDATED",
createdAt: "[redacted/dynamic]",
payload: {
message: "first@cal.com marked as no-show",
attendees: [{ email: "first@cal.com", noShow: true, utcOffset: null }],
bookingUid: bookingWhereFirstUserIsOrganizer?.uid,
bookingId: bookingWhereFirstUserIsOrganizer?.id,
},
});
webhookReceiver.close();
});
test("Mark 3rd attendee as no-show", async ({ page, users, bookings }) => {
const firstUser = await users.create();
+21 -16
View File
@@ -59,8 +59,9 @@ test.describe("Embed Code Generator Tests", () => {
orgSlug: null,
});
await goToPreviewTab(page);
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
calLink: `${pro.username}/30-min`,
@@ -96,7 +97,9 @@ test.describe("Embed Code Generator Tests", () => {
orgSlug: null,
});
await goToPreviewTab(page);
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "floating-popup",
calLink: `${pro.username}/30-min`,
@@ -132,7 +135,9 @@ test.describe("Embed Code Generator Tests", () => {
orgSlug: null,
});
await goToPreviewTab(page);
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "element-click",
calLink: `${pro.username}/30-min`,
@@ -170,8 +175,9 @@ test.describe("Embed Code Generator Tests", () => {
orgSlug: null,
});
await goToPreviewTab(page);
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
calLink: decodeURIComponent(embedUrl),
@@ -227,7 +233,9 @@ test.describe("Embed Code Generator Tests", () => {
orgSlug: org.slug,
});
await goToPreviewTab(page);
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "inline",
calLink: `${user.username}/30-min`,
@@ -266,7 +274,9 @@ test.describe("Embed Code Generator Tests", () => {
orgSlug: org.slug,
});
await goToPreviewTab(page);
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "floating-popup",
calLink: `${user.username}/30-min`,
@@ -304,7 +314,9 @@ test.describe("Embed Code Generator Tests", () => {
orgSlug: org.slug,
});
await goToPreviewTab(page);
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await expectToContainValidPreviewIframe(page, {
embedType: "element-click",
calLink: `${user.username}/30-min`,
@@ -320,13 +332,6 @@ function chooseEmbedType(page: Page, embedType: EmbedType) {
page.locator(`[data-testid=${embedType}]`).click();
}
async function goToPreviewTab(page: Page) {
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(1000);
await page.locator("[data-testid=horizontal-tab-Preview]").click();
}
async function goToReactCodeTab(page: Page) {
// To prevent early timeouts
// eslint-disable-next-line playwright/no-wait-for-timeout
+13 -23
View File
@@ -6,7 +6,14 @@ import { randomString } from "@calcom/lib/random";
import { test } from "./lib/fixtures";
import { testBothFutureAndLegacyRoutes } from "./lib/future-legacy-routes";
import { bookTimeSlot, createNewEventType, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
import {
bookTimeSlot,
createNewEventType,
gotoBookingPage,
gotoFirstEventType,
saveEventType,
selectFirstAvailableTimeSlotNextMonth,
} from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
@@ -91,6 +98,8 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
const firstFullSlug = await page.locator(`[data-testid=event-type-slug-${eventTypeId}]`).innerText();
const firstSlug = firstFullSlug.split("/")[2];
await expect(page.locator("[data-testid=readonly-badge]")).toBeHidden();
await page.click(`[data-testid=event-type-options-${eventTypeId}]`);
await page.click(`[data-testid=event-type-duplicate-${eventTypeId}]`);
// Wait for the dialog to appear so we can get the URL
@@ -333,11 +342,11 @@ testBothFutureAndLegacyRoutes.describe("Event Types tests", () => {
// Remove Both of the locations
const removeButtomId = "delete-locations.0.type";
await page.getByTestId(removeButtomId).click();
await page.getByTestId(removeButtomId).click();
await page.getByTestId(removeButtomId).nth(0).click();
await page.getByTestId(removeButtomId).nth(0).click();
// Add Multiple Organizer Phone Number options
await page.getByTestId("location-select").click();
await page.getByTestId("location-select").last().click();
await page.locator(`text="Organizer Phone Number"`).click();
const organizerPhoneNumberInputName = (idx: number) => `locations[${idx}].hostPhoneNumber`;
@@ -367,25 +376,6 @@ const selectAttendeePhoneNumber = async (page: Page) => {
await page.locator(`text=${locationOptionText}`).click();
};
async function gotoFirstEventType(page: Page) {
const $eventTypes = page.locator("[data-testid=event-types] > li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
await page.waitForURL((url) => {
return !!url.pathname.match(/\/event-types\/.+/);
});
}
async function saveEventType(page: Page) {
await page.locator("[data-testid=update-eventtype]").click();
}
async function gotoBookingPage(page: Page) {
const previewLink = await page.locator("[data-testid=preview-button]").getAttribute("href");
await page.goto(previewLink ?? "");
}
/**
* Adds n+1 location to the event type
*/
+87 -30
View File
@@ -1,7 +1,13 @@
import { expect, type Page } from "@playwright/test";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { shouldRedirectToAppOnboarding } from "@calcom/lib/apps/shouldRedirectToAppOnboarding";
import type { TApp } from "../apps/conferencing/conferencingApps.e2e";
import {
bookTimeSlot,
gotoBookingPage,
gotoFirstEventType,
saveEventType,
selectFirstAvailableTimeSlotNextMonth,
} from "../lib/testUtils";
export function createAppsFixture(page: Page) {
return {
@@ -9,39 +15,92 @@ export function createAppsFixture(page: Page) {
await page.getByTestId(`app-store-category-${category}`).nth(1).click();
await page.goto("apps/categories/analytics");
},
installAppSkipConfigure: async (app: string) => {
installAnalyticsAppSkipConfigure: async (app: string) => {
await page.getByTestId(`app-store-app-card-${app}`).click();
await page.getByTestId("install-app-button").click();
const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata];
if (shouldRedirectToAppOnboarding(appMetadata)) {
await page.click('[data-testid="install-app-button-personal"]');
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
await page.click('[data-testid="set-up-later"]');
}
await page.click('[data-testid="install-app-button-personal"]');
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
await page.click('[data-testid="set-up-later"]');
},
installApp: async (app: string, eventTypeIds: number[]) => {
installAnalyticsApp: async (app: string, eventTypeIds: number[]) => {
await page.getByTestId(`app-store-app-card-${app}`).click();
(await page.waitForSelector('[data-testid="install-app-button"]')).click();
const appMetadata = appStoreMetadata[app as keyof typeof appStoreMetadata];
if (shouldRedirectToAppOnboarding(appMetadata)) {
await page.click('[data-testid="install-app-button-personal"]');
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
await page.click('[data-testid="install-app-button-personal"]');
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
for (const id of eventTypeIds) {
await page.click(`[data-testid="select-event-type-${id}"]`);
for (const id of eventTypeIds) {
await page.click(`[data-testid="select-event-type-${id}"]`);
}
await page.click(`[data-testid="save-event-types"]`);
// adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it
if (app === "gtm") {
await page.waitForLoadState("domcontentloaded");
for (let index = 0; index < eventTypeIds.length; index++) {
await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id");
}
}
await page.click(`[data-testid="configure-step-save"]`);
await page.waitForURL("/event-types");
},
await page.click(`[data-testid="save-event-types"]`);
installConferencingAppSkipConfigure: async (app: string) => {
await page.getByTestId(`app-store-app-card-${app}`).click();
await page.getByTestId("install-app-button").click();
await page.waitForURL(`apps/installation/event-types?slug=${app}`);
await page.click('[data-testid="set-up-later"]');
},
verifyConferencingApp: async (app: TApp) => {
await page.goto("/event-types");
await gotoFirstEventType(page);
await page.getByTestId("location-select").last().click();
await page.getByTestId(`location-select-item-${app.type}`).click();
if (app.organizerInputPlaceholder) {
await page.getByTestId(`${app.type}-location-input`).fill(app.organizerInputPlaceholder);
}
await page.locator("[data-testid=display-location]").last().check();
await saveEventType(page);
await page.waitForLoadState("networkidle");
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await page.waitForLoadState("networkidle");
// adding random-tracking-id to gtm-tracking-id-input because this field is required and the test fails without it
if (app === "gtm") {
await page.waitForLoadState("domcontentloaded");
for (let index = 0; index < eventTypeIds.length; index++) {
await page.getByTestId("gtm-tracking-id-input").nth(index).fill("random-tracking-id");
}
}
await page.click(`[data-testid="configure-step-save"]`);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("[data-testid=where] ")).toContainText(app.label);
},
installConferencingAppNewFlow: async (app: TApp, eventTypeIds: number[]) => {
await page.goto("apps/categories/conferencing");
await page.getByTestId(`app-store-app-card-${app.slug}`).click();
await page.getByTestId("install-app-button").click();
await page.waitForURL(`apps/installation/event-types?slug=${app.slug}`);
for (const id of eventTypeIds) {
await page.click(`[data-testid="select-event-type-${id}"]`);
}
await page.click(`[data-testid="save-event-types"]`);
for (let eindex = 0; eindex < eventTypeIds.length; eindex++) {
if (!app.organizerInputPlaceholder) continue;
await page.getByTestId(`${app.type}-location-input`).nth(eindex).fill(app.organizerInputPlaceholder);
}
await page.click(`[data-testid="configure-step-save"]`);
await page.waitForURL("/event-types");
},
verifyConferencingAppNew: async (app: TApp, eventTypeIds: number[]) => {
for (const id of eventTypeIds) {
await page.goto(`/event-types/${id}`);
await page.waitForLoadState("networkidle");
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page, { name: `Test Testson`, email: `test@example.com` });
await page.waitForLoadState("networkidle");
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("[data-testid=where] ")).toContainText(app.label);
}
},
goBackToAppsPage: async () => {
@@ -57,14 +116,12 @@ export function createAppsFixture(page: Page) {
await page.locator(`[data-testid='${app}-app-switch']`).click();
},
verifyAppsInfo: async (activeApps: number) => {
await expect(page.locator(`text=6 apps, ${activeApps} active`)).toBeVisible();
await expect(page.locator(`text=1 apps, ${activeApps} active`)).toBeVisible();
},
verifyAppsInfoNew: async (apps: string[], eventTypeId: number) => {
verifyAppsInfoNew: async (app: string, eventTypeId: number) => {
await page.goto(`event-types/${eventTypeId}?tabName=apps`);
await page.waitForLoadState("domcontentloaded");
for (const app of apps) {
await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible();
}
await expect(page.locator(`[data-testid='${app}-app-switch'][data-state="checked"]`)).toBeVisible();
},
};
}
+39
View File
@@ -0,0 +1,39 @@
import { expect, type Page } from "@playwright/test";
import { createHttpServer } from "../lib/testUtils";
export function createWebhookPageFixture(page: Page) {
return {
createTeamReceiver: async () => {
const webhookReceiver = createHttpServer();
await page.goto(`/settings/developer/webhooks`);
await page.click('[data-testid="new_webhook"]');
await page.click('[data-testid="option-team-1"]');
await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new");
const url = page.url();
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
return { webhookReceiver, teamId };
},
createReceiver: async () => {
const webhookReceiver = createHttpServer();
await page.goto(`/settings/developer/webhooks`);
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
return webhookReceiver;
},
};
}
+2 -1
View File
@@ -30,6 +30,7 @@ export function createWorkflowPageFixture(page: Page) {
await selectEventType("30 min");
}
await saveWorkflow();
await page.getByTestId("go-back-button").click();
};
const saveWorkflow = async () => {
@@ -71,7 +72,7 @@ export function createWorkflowPageFixture(page: Page) {
};
const selectEventType = async (name: string) => {
await page.getByText("Select...").click();
await page.getByTestId("multi-select-check-boxes").click();
await page.getByText(name, { exact: true }).click();
};
+6
View File
@@ -16,6 +16,7 @@ import { createBookingPageFixture } from "../fixtures/regularBookings";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
import { createServersFixture } from "../fixtures/servers";
import { createUsersFixture } from "../fixtures/users";
import { createWebhookPageFixture } from "../fixtures/webhooks";
import { createWorkflowPageFixture } from "../fixtures/workflows";
export interface Fixtures {
@@ -34,6 +35,7 @@ export interface Fixtures {
features: ReturnType<typeof createFeatureFixture>;
eventTypePage: ReturnType<typeof createEventTypeFixture>;
appsPage: ReturnType<typeof createAppsFixture>;
webhooks: ReturnType<typeof createWebhookPageFixture>;
}
declare global {
@@ -110,4 +112,8 @@ export const test = base.extend<Fixtures>({
const appsPage = createAppsFixture(page);
await use(appsPage);
},
webhooks: async ({ page }, use) => {
const webhooks = createWebhookPageFixture(page);
await use(webhooks);
},
});
+19
View File
@@ -371,3 +371,22 @@ export async function doOnOrgDomain(
// When App directory is there, this is the 404 page text. We should work on fixing the 404 page as it changed due to app directory.
export const NotFoundPageTextAppDir = "This page does not exist.";
// export const NotFoundPageText = "ERROR 404";
export async function gotoFirstEventType(page: Page) {
const $eventTypes = page.locator("[data-testid=event-types] > li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
await page.waitForURL((url) => {
return !!url.pathname.match(/\/event-types\/.+/);
});
}
export async function gotoBookingPage(page: Page) {
const previewLink = await page.locator("[data-testid=preview-button]").getAttribute("href");
await page.goto(previewLink ?? "");
}
export async function saveEventType(page: Page) {
await page.locator("[data-testid=update-eventtype]").click();
}
@@ -1,6 +1,7 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import { uuid } from "short-uuid";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -28,9 +29,9 @@ function getOrgOrigin(orgSlug: string | null) {
}
test.describe("Bookings", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
users.deleteAll();
test.afterEach(async ({ orgs, users, page }) => {
await users.deleteAll();
await orgs.deleteAll();
});
test.describe("Team Event", () => {
@@ -324,6 +325,8 @@ test.describe("Bookings", () => {
const username = "john";
const userInsideOrganization = await users.create({
username,
useExactUsername: true,
email: `john-inside-${uuid()}@example.com`,
name: "John Inside Organization",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
@@ -339,6 +342,8 @@ test.describe("Bookings", () => {
const userOutsideOrganization = await users.create({
username,
name: "John Outside Organization",
email: `john-outside-${uuid()}@example.com`,
useExactUsername: true,
eventTypes: [
{
title: "John Outside Org's Meeting",
@@ -155,7 +155,7 @@ test.describe("Organization Logo", async () => {
await page.getByTestId("upload-avatar").click();
await page.getByText("Update").click();
await page.getByTestId("update-org-profile-button").click();
await page.waitForSelector("text=Your organization updated successfully");
const response = await prisma.avatar.findUniqueOrThrow({
+1
View File
@@ -45,6 +45,7 @@ testBothFutureAndLegacyRoutes.describe("Teams - NonOrg", (routeVariant) => {
await user.apiLogin();
page.goto(`/settings/teams/${team.id}/onboard-members`);
await page.waitForLoadState("networkidle");
await test.step("Can add members", async () => {
// Click [data-testid="new-member-button"]
+20 -118
View File
@@ -1,4 +1,3 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
@@ -10,7 +9,6 @@ import { test } from "./lib/fixtures";
import {
bookOptinEvent,
bookTimeSlot,
createHttpServer,
createUserWithSeatedEventAndAttendees,
gotoRoutingLink,
selectFirstAvailableTimeSlotNextMonth,
@@ -24,54 +22,16 @@ test.afterEach(async ({ users }) => {
await users.deleteAll();
});
async function createWebhookReceiver(page: Page) {
const webhookReceiver = createHttpServer();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
return webhookReceiver;
}
test.describe("BOOKING_CREATED", async () => {
test("add webhook & test that creating an event triggers a webhook call", async ({
page,
users,
webhooks,
}, _testInfo) => {
const webhookReceiver = createHttpServer();
const user = await users.create();
const [eventType] = user.eventTypes;
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
const webhookReceiver = await webhooks.createReceiver();
// --- Book the first available day next month in the pro user's "30min"-event
await page.goto(`/${user.username}/${eventType.slug}`);
@@ -169,8 +129,8 @@ test.describe("BOOKING_REJECTED", async () => {
test("can book an event that requires confirmation and then that booking can be rejected by organizer", async ({
page,
users,
webhooks,
}) => {
const webhookReceiver = createHttpServer();
// --- create a user
const user = await users.create();
@@ -182,24 +142,7 @@ test.describe("BOOKING_REJECTED", async () => {
// --- login as that user
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
const webhookReceiver = await webhooks.createReceiver();
await page.goto("/bookings/unconfirmed");
await page.click('[data-testid="reject"]');
await page.click('[data-testid="rejection-confirm"]');
@@ -293,30 +236,14 @@ test.describe("BOOKING_REQUESTED", async () => {
test("can book an event that requires confirmation and get a booking requested event", async ({
page,
users,
webhooks,
}) => {
const webhookReceiver = createHttpServer();
// --- create a user
const user = await users.create();
// --- login as that user
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
// page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
const webhookReceiver = await webhooks.createReceiver();
// --- visit user page
await page.goto(`/${user.username}`);
@@ -410,13 +337,18 @@ test.describe("BOOKING_REQUESTED", async () => {
});
test.describe("BOOKING_RESCHEDULED", async () => {
test("can reschedule a booking and get a booking rescheduled event", async ({ page, users, bookings }) => {
test("can reschedule a booking and get a booking rescheduled event", async ({
page,
users,
bookings,
webhooks,
}) => {
const user = await users.create();
const [eventType] = user.eventTypes;
await user.apiLogin();
const webhookReceiver = await createWebhookReceiver(page);
const webhookReceiver = await webhooks.createReceiver();
const booking = await bookings.create(user.id, user.username, eventType.id, {
status: BookingStatus.ACCEPTED,
@@ -451,6 +383,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
page,
users,
bookings,
webhooks,
}) => {
const { user, eventType, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
@@ -464,7 +397,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
await user.apiLogin();
const webhookReceiver = await createWebhookReceiver(page);
const webhookReceiver = await webhooks.createReceiver();
const bookingAttendees = await prisma.attendee.findMany({
where: { bookingId: booking.id },
@@ -670,24 +603,11 @@ test.describe("MEETING_ENDED, MEETING_STARTED", async () => {
});
test.describe("FORM_SUBMITTED", async () => {
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
const user = await users.create(null, {
hasTeam: true,
});
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms, webhooks }) => {
const user = await users.create();
await user.apiLogin();
await page.goto(`/settings/developer/webhooks/new`);
// Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await page.click("[type=submit]");
// Page contains the url
expect(page.locator(`text='${webhookReceiver.url}'`)).toBeDefined();
const webhookReceiver = await webhooks.createReceiver();
await page.waitForLoadState("networkidle");
const form = await routingForms.create({
@@ -736,21 +656,12 @@ test.describe("FORM_SUBMITTED", async () => {
webhookReceiver.close();
});
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
test("on submitting team form, triggers team webhook", async ({ page, users, routingForms, webhooks }) => {
const user = await users.create(null, {
hasTeam: true,
});
await user.apiLogin();
await page.goto(`/settings/developer/webhooks`);
const teamId = await clickFirstTeamWebhookCta(page);
// Add webhook
await page.fill('[name="subscriberUrl"]', webhookReceiver.url);
await page.fill('[name="secret"]', "secret");
await page.click("[type=submit]");
const { webhookReceiver, teamId } = await webhooks.createTeamReceiver();
const form = await routingForms.create({
name: "Test Form",
userId: user.id,
@@ -797,12 +708,3 @@ test.describe("FORM_SUBMITTED", async () => {
webhookReceiver.close();
});
});
async function clickFirstTeamWebhookCta(page: Page) {
await page.click('[data-testid="new_webhook"]');
await page.click('[data-testid="option-team-1"]');
await page.waitForURL((u) => u.pathname === "/settings/developer/webhooks/new");
const url = page.url();
const teamId = Number(new URL(url).searchParams.get("teamId")) as number;
return teamId;
}
+2 -1
View File
@@ -19,12 +19,13 @@ test.describe("Workflow Tab - Event Type", () => {
await assertListCount(3);
});
test("Editing an existing workflow", async ({ workflowPage }) => {
test("Editing an existing workflow", async ({ workflowPage, page }) => {
const { saveWorkflow, fillNameInput, editSelectedWorkflow, hasWorkflowInList } = workflowPage;
await editSelectedWorkflow("Test Workflow");
await fillNameInput("Edited Workflow");
await saveWorkflow();
await page.getByTestId("go-back-button").click();
await hasWorkflowInList("Edited Workflow");
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" borderRadius="4px" fill="white" class="jsx-2238566114 icon"><path d="M7.05673 11.7832H6.6259V10.6179H7.06326C7.4941 10.6179 7.7291 10.7737 7.7291 11.1667V11.1938C7.7291 11.5867 7.50715 11.7832 7.05673 11.7832Z" class="jsx-2238566114"></path><path fill-rule="evenodd" clip-rule="evenodd" d="M3 10C3 7.79086 4.79086 6 7 6H17C19.2091 6 21 7.79086 21 10V14C21 16.2091 19.2091 18 17 18H7C4.79086 18 3 16.2091 3 14V10ZM5.29422 14.4187V9.57453H7.12201C8.34271 9.57453 9.02161 10.0894 9.02161 11.1125V11.1396C9.02161 11.8306 8.6691 12.2168 8.17952 12.4133L9.29577 14.4187H7.88576L6.93923 12.6843H6.6259V14.4187H5.29422ZM10.3881 14.4187V9.57453H13.6128V10.645H11.7198V11.4783H13.2277V12.4675H11.7198V13.3482H13.7173V14.4187H10.3881ZM16.9974 14.5C18.205 14.5 19.0798 13.8496 19.1711 12.5962H17.8395C17.7742 13.1314 17.4413 13.3889 16.9713 13.3889C16.3315 13.3889 15.9725 12.8943 15.9725 12.0203V11.9661C15.9725 11.0854 16.3511 10.5976 16.9517 10.5976C17.4217 10.5976 17.6828 10.8686 17.735 11.3631H19.1124C19.0014 10.0827 18.1593 9.5 16.9452 9.5C15.5808 9.5 14.5625 10.4824 14.5625 11.9729C14.5625 13.4681 15.464 14.5 16.9974 14.5Z" class="jsx-2238566114"></path></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -879,7 +879,7 @@
"add_new_event_type": "Neuen Termintyp hinzufügen",
"new_event_type_to_book_description": "Erstellen Sie einen neuen Termintyp, mit dem Personen Zeiten buchen können.",
"length": "Länge",
"minimum_booking_notice": "Mindesvorlaufzeit für eine Buchung",
"minimum_booking_notice": "Mindestvorlaufzeit für eine Buchung",
"offset_toggle": "Versetzte Startzeiten",
"offset_toggle_description": "Versetzt die den Buchern angezeigten Zeitfenster um eine bestimmte Anzahl von Minuten",
"offset_start": "Versetzt um",

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