Compare commits

...
84 Commits
Author SHA1 Message Date
Keith Williams 46f67bba9b chore: v4.0.9 (#15035)
release.yml / release (push) Canceled after 0s
2024-05-14 21:00:57 +00:00
Anik Dhabal Babu d9806da049 fix: better errors for platform (#14987)
## What does this PR do?

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

- Fixes #14977 

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

## 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)

## How should this be tested?

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

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

## Checklist

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

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings
2024-05-14 11:56:44 -07:00
Alex van Andel 42fc49488d fix: Impersonation flicker on timezone change (#14738) 2024-05-14 11:56:25 -07:00
Omar LópezandCarina Wollendorfer e030fe0e07 fix: error when unlinking google account (#15011)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-05-14 10:10:41 -07:00
Udit Takkar 51428087ef chore: enable transcription for teams plan users (#15025) 2024-05-14 09:28:13 -04:00
Morgan 3942df369d chore: open disableGuests for event-types apiv2 (#15001)
* chore: open disableGuests for eventtypes apiv2

* fixup! chore: open disableGuests for eventtypes apiv2

* fixup! fixup! chore: open disableGuests for eventtypes apiv2

* chore: add disableGuests to create handler
2024-05-14 10:50:12 +00:00
Morgan 1d309a6f09 chore: hide oauthClientId from session email in booker (#15002) 2024-05-13 18:25:34 +00:00
Amit Sharma 56f31eee7a fix: Regression: Can’t set person to forward OOO bookings (#15003) 2024-05-13 10:29:56 -04:00
2dae0bfb8c fix: add retryCount to workflowReminder (#14943)
* add retry count to workflow reminder

* add logic to for retry count

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-13 13:25:22 +00:00
Hariom Balhara 917c7b0764 fix: Allow less than 30 seats for orgs (#14995)
* fix: Allow less than 30 seats for orgs

* Add test

* Stub env variables
2024-05-13 11:37:10 +00:00
fd64b0f468 fix: meeting ended and started webhooks trigger (#14864)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-10 12:49:16 -07:00
Syed Ali Shahbaz b1909337db fix: SAML fixes for uppercase email & GOOGLE → SAML idp switch (#14971)
* fix uppercase email slip

* fix google → SAML idp change
2024-05-10 15:09:45 +00:00
vikrant845andborawakevikrant 039bf6d4e3 fix: Fixed email embed for team when specific times are chosen embeds the wrong URL (#14975)
* Fixed team embed url

* Added comments

---------

Co-authored-by: borawakevikrant <borawakevikrant@gmail.com>
2024-05-10 14:05:09 +00:00
sean-brydonandUdit Takkar 83e15c4ce1 feat: orgs create dynamic events from org member list (#14912)
* implement basic UI - WIP

* Add copy + open in tab + center correctly

* use paragraph for link

* fix button spacing and domain takes up w-full

* Add fade in to bar instead of framer motion

* Move truncate to block level element instead of p

* chore:fix tablet styles

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-10 13:06:11 +00:00
Hariom Balhara 7d6ecf7201 Release new versions for embed (#14958) 2024-05-10 12:49:47 +00:00
Rajiv Sahal bc42c27230 chore: dashboard fixes (#14968)
* add rewrites for platform dashboard

* fix loading checking

* add checks

* dont display banner for platform client

* custom hooks for pllatform

* add comment for better explanation

* get data from custom hook

* update returned user attributes

* get data from custom hook
2024-05-10 15:16:03 +03:00
Peer Richelsen 57abb26c31 fixed giphy (#14973) 2024-05-10 12:02:10 +00:00
Hariom Balhara a136511c8a fix: Show error log (#14960) 2024-05-10 08:47:57 +01:00
Morgan c65dca9486 fix: api v2 hotfix google calendar, exception details, billing check guard (#14967) 2024-05-10 07:36:51 +00:00
Omar López 816d31918d chore: v4.0.8 (#14965)
release.yml / release (push) Canceled after 0s
2024-05-09 13:20:59 -07:00
Ayush MhetreandCarina Wollendorfer 097a69d7d7 fix: save workflow button not visible on dev env (#14910)
* fix: save workflow button not visible on dev env

* refactor: use the CALCOM_ENV constant

* fix: ui fix for header on workspace pages

* refactor: remove unused variable

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-05-09 17:46:39 +00:00
c2a07e221d feat: platform onboarding flow and dashboard (#14721)
* add endpoint to fetch managed user from client id

* update typings

* minor tweaks

* custom hook to fetch managed users from client id

* add translations for platform onboarding

* add isPlatformOrg boolean to figure out which is platform and which is not

* set isPlatform hook based on data obtained from org

* add limitWidth prop to control component width

* add props to shell to make sidebar display different tabs based on if the shell isPlattform or not

* platform related pages

* fix merge conflicts

* fix merge conflicts

* platform oauth client form and card

* remove everything related to platform from organization

* update oauth client card and form

* fixup

* fix imports and remove logs

* fixup

* update redirect url

* split oauth client form into separate update and create forms

* separate forms for create and edit oauth clients

* fixup

* fixup

* dynamic routes for oauth client edit page

* fixup fixup

* fix to not show error when redirect uri is empty

* refactor create handler for org

* cleaup comments

* add custom hook to check user billing

* export managed user type

* refactor platform index page

* refactor edit and create pages

* dashboard component containing oauth client list and managed user

* common oauth client form used for create and edit form

* platform pricing helper

* platform pricing component

* fix typing and data response for billing

* use custom hook to check team billing info

* fix type checks

* upgrade conditional rendering for upgrade to org banner

* add isLoading prop to check button loading state

* pass in button handler

* add custom hook to subscribe to stripe and typings

* update typings

* fix incorrect endpoint

* pass in team id as prop

* fix type check

* update stripe success and cancel redirect url

* add and pass redirect url param to custom hook

* custom hooks for platform

* cleanup

* update imports

* fix merge conflicts

* fixup

* fixup fixup

* fixup

* merge conflicts fixup

* merge conlficts battle :(

* minor fixes

* skip admin checks for a platform client

* fix typo

* append slug with _platform for a platform user

* PR feedback

* dashboard refactor

* bring back org form to its orginal state

* add platform folder to ee

* update typings

* use new create platform form

* fixup

* fix typo and update plans

* simplifying rendering

* remove managed users endpoint since it already exists

* url for endpoint

* rename tabs

* pr feedback

* managed users endpoint

* update endpoint

* remove form

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: exception <erik@erosemberg.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-05-09 13:01:10 -04:00
Morgan 6d8fbfc49a fix: gcal save get credentials from token.tokens (#14962) 2024-05-09 14:08:31 +00:00
Carina WollendorferandCarinaWolli 304ef29618 fix: buffer times in handleNewBooking (#14871)
* get buffer busy time from bookings outside of start and end time

* add test for buffers

* fix typo

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-05-09 13:39:32 +00:00
Lauris Skraucis b8890c841c fix: managed user timeZone, timeFormat and weekStart during creation (#14955)
* refactor: create, update user inputs use types instead of custom validators

* fix: save weekStart, timeFormat, timeZone when creating user

* fix: save weekStart, timeFormat, timeZone when creating user

* e2e: fix env variables and test weekStart, timeZone and timeFormat during user create
2024-05-09 10:25:14 -03:00
Amit SharmaandKeith Williams 2f5768b74f fix: Only able to create one webhook per event type (#14922)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-09 02:40:13 +00:00
Carina WollendorferandCarinaWolli c1416173e8 attempt to fix flaky e2e test (#14949)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-05-08 18:07:30 -04:00
Morgan 726af4970c fix: sync book event form values with bookingData (#14945)
* fix: sync book event form values with bookingData

* fixup! fix: sync book event form values with bookingData
2024-05-08 15:14:40 +00:00
Keith Williams d15b7d8168 chore: Update PR template to include more mandatory tasks (#14941)
* chore: Update PR template to include more mandatory tasks

* Add XXXX to GitHub number

* Update PULL_REQUEST_TEMPLATE.md
2024-05-08 14:37:17 +01:00
Alex van Andel c5bd45fdfb feat: Redirect when event type does not match booking type (#14460)
* feat: Redirect when event type does not match booking type

* Favour early return

* If multiple bookingUid/rescheduleUid given, use first

* rescheduleUid/bookingUid behaviour is required
2024-05-08 10:33:22 -03:00
Morgan 5d7b2a12a7 fix: redis cache key overlap (#14938) 2024-05-08 10:41:34 +00:00
Morgan 7c90d1ed76 feat: handleCreateBooking for platform booker (#14939) 2024-05-08 16:11:01 +05:30
Peer Richelsen 5ca10d1e83 feat: Added admin-api page (#14890)
* added admin-api page

* i18n
2024-05-08 10:15:20 +00:00
sean-brydon f0e59c2331 fix: pass query params to dynamic meetings (#14920) 2024-05-08 09:53:07 +00:00
Alex van AndelandKeith Williams f812d92045 chore: Release/v4.0.7 (#14933)
release.yml / release (push) Canceled after 0s
* chore: release v4.0.7

* fix: Remove lockfile, could be broken

* fix: Bump lockfile (again)

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-08 00:17:51 +01:00
Crowdin Bot 23a17c4e8a New Crowdin translations by Github Action 2024-05-07 22:24:57 +00:00
Keith Williams 5beb039003 fix: Caching key for CI database (#14932)
* fix: Caching key for db

* Fix line length
2024-05-07 22:21:39 +00:00
Keith Williams 63dd357664 chore: Removed duplicate production build (#14836)
* chore: Removed duplicate production build

* fix references

* Move env vars to top level of e2e

* job renaming

* Added env vars to top level of all e2e jobs

* Removed part of cache key that causes issues and is moot

* Using buildjet hardware so the caching works

* clean up
2024-05-07 12:56:19 -07:00
b6a57381c7 feat: redirect after booking: option to forward parameters (#14235)
* feat: redirect after booking: option to forward parameters

* fix: unit test

* fix: stripe passing its own params

* fix: manually add stripe params

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-07 12:15:20 -04:00
Alex van Andel 7cf840723b Async scalars & less queries due to grouping (#14904) 2024-05-07 15:18:07 +01:00
sean-brydonandUdit Takkar bc0c40d260 feat: cal-3452 parameter for impersonation (#14605)
* feat: impersonate trigger when there is a URL param

* feat: update intercom sidebar

* fix: add to onOpen too

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-07 10:33:19 -03:00
MorganandKeith Williams 89612a7a68 fix: add prisma client generated types to atoms package (#14916)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-07 12:59:41 +00:00
a2620a0e50 feat: atoms team booking (#14525)
* feat: setup usePublicEvent with orgSlug and duration

* feat: setup useAvailableSlots with eventTypeSlug and orgSlug

* fix & feat: fix TS issues and allow passing multiple users to Booker

* refactor: dont show [15min] [30min] [60min] [1h30min] picker

* fix: pass orgSlug to handleNewBooking -> loadUsers -> findUsersByUsername to not return empty users[]

* refactor: display attendees in booking confirmation screen

* fix: getting slots get orgSlug from props not event

* revert: Booker username use props instead of hardcoded values

* refactor: setup BookerStore org and use it for selectedTime + isDynamic logic

* refactor: hide 'what is this meeting about' input in final booking step

* fix: TS error

* revert: what is the meeting about field hiding

* revert: make org as org? in booker store

* revert: createNewBooking -> loadUsers -> get forced orgSlug from header not body

* refactor: useHandleBookEvent get orgSlug from booker store not props

* fix: if entity undefined dont access orgSlug

* refactor: add isDynamic prop as queryKey for usePublicEvent

* refactor: remove unused prop

* re-add docs

* fix: typescript error

* fix: force platform orgSlug in handleNewBooking.ts

* fix: remove duplicate setSelectedDuration declaration

* refactor: destructure properies from body instead of req.body

* fix: entity optional and hide event members

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-05-07 12:51:00 +00:00
sean-brydon cfcf5b168c fix: slot check (#14917) 2024-05-07 12:43:10 +00:00
3f4d9962a7 fix: reschedule with google meet (#14730)
* fix: reschedule with google meet

* fix: type err

* test: add google meet test

* chore: save progress

* test: add individual event type

* chore: add one more check

* fix: type err

* chore: use updatedEvent before createdEvent

* refactor: update event

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-07 18:09:12 +05:30
Keith Williams 83917e8655 chore: Add build of API v2 to PR checks (#14902)
* chore: Add build of API v2 to PR checks

* Actually added the v2 build to the pipeline...

* Added back the original yarn install

* Updated the main PR flow to require API builds before tests
2024-05-07 09:05:49 -03:00
Carina WollendorferandCarinaWolli f4bd0dbcc9 remove global variable (#14907)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-05-07 11:04:27 +01:00
c87b8b43f9 Add resourceOwner.id related test (#14816)
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-06 23:50:59 +00:00
shaik-zeeshanandJoe Au-Yeung b2baaea84c feat: "Add an Override" calendar starts with the day specified in general settings (#14663)
* feat: Add an Override calendar starts with the day specified in general settings

* fix: types not matching

* fix: update edit calendar to use user's weekStart

* fix: weekstart defaults to 0 when not provided

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-05-06 19:00:03 -03:00
Vikas PatilandJoe Au-Yeung 0e431ba7c6 feat: posthog app added (#14801)
* feat: posthog app added

* fix:requested changes

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-05-06 15:54:53 -03:00
sean-brydon b62f6eee3c fix: date override modal (#14854) 2024-05-06 18:34:17 +00:00
Peer Richelsen 2d0cac0dad fix: windows app styling (#14805) 2024-05-06 17:41:45 +00:00
Morgan 0c3d1b3034 chore: bump atoms v1.0.40 (#14901) 2024-05-06 17:31:08 +00:00
74f6d7686b fix: zod error message and missing translation (#14787)
* fix: issue#14785 and issue#14786

* fix: some changes

* fix

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-06 16:03:24 +00:00
Amit SharmaandKeith Williams 49601bed6f fix: Sender ID info not being shown on 'info' hover in 'add action' flow (#14879)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-06 12:16:03 -03:00
ErikandMorgan b9659051d4 chore: Cache cityTimezones in Platform (#14823)
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-05-06 12:00:36 -03:00
Peer RichelsenandKeith Williams 9c96507af2 chore: centered cal video logo (#14896)
* centered cal video logo

* Update apps/web/modules/videos/views/videos-single-view.tsx

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-05-06 14:25:14 +00:00
Utkersh Rajvenshi 2352babe93 fix: UI breaking in AddActionDialog on reducing browser window height (#14892) 2024-05-06 14:24:45 +00:00
Keith Williams 1467815c3f chore: Use 4vCPU BuildJet hardware for type checks (#14900) 2024-05-06 10:31:37 -03:00
7254cd97e2 chore: Use better buildjet hardware for e2e (#14835)
* chore: Use better buildjet server for e2e

* Backed down buildjet hardware where not needed

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-05-06 12:27:14 +00:00
Morgan 4cb77abfd7 fix: api v2 docs in prod (#14898) 2024-05-06 11:50:21 +00:00
8deedfd361 fix: Booking Cancelled Webhook - Organiser webhook payload is missing id. #14697 (#14745)
* fix: include id information to be consistent with create/reschedule flows

* fix: include username as well

* function expectBookingCancelledWebhookToHaveBeenFired created

* chore: undo yarn.lock

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-05-06 08:14:09 +00:00
3a0c766ed5 fix: Support adding a non-existent email as the owner of the organization (#14569)
* fix: Support adding an non-existent email as the owner of the organization

* Remove dead code

* fixes

* add test

* self review changes

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

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>

* Fix typo everywhere

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-05-06 07:09:22 +00:00
Hariom Balhara 4f57e3201d test: Google CalendarService test (#14793) 2024-05-06 12:33:16 +05:30
MOHD RAZA e404f7eb88 UI breaking in AddActionDialog fixed (#14883) 2024-05-06 07:02:43 +00:00
5c2b561a5c fix webhook e2e test (#14850)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-05-03 14:59:52 +00:00
Amit Sharma 57b404f721 feat: Add reschedule information with booking Read (#14853) 2024-05-03 14:31:04 +00:00
Amit SharmaandCarina Wollendorfer df9a6621cf fix: Sender ID info not shown on hover (#14846)
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-05-03 14:08:09 +00:00
c478f73327 feat: orgs trigger alert when loading a calendar and no availability is (#14796)
* Install upstash redis again - base of noficationSender

* Setup email handler etc and use tasker

* Remove logs

* Sending emails and correct format and spacing in emails

* Update email styles

* Update email styles

* add switch to enable feature

* fix: reset tasker types - will fix in new PR

* WIP: test suite

* WIP: test suite

* Add redis service and add WIP mock test

* Add tests for redis service and fix lpush

* More working tests

* More working tests

* fix: type error + typo

* update export to match i18n next mock

* (debug) push for debug

* Fix: fixed hosting in mocks

* Add common.json i18n

* add better test descriptions

* reset mocks after each test to allow concurancy

* Remove redundant RedisService.test.ts

* Rename and remove redundant isAdmin check

* Rename to .d.ts

* Add key versioning to constructRedisKey

* Update packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.ts

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

* Update packages/trpc/server/routers/viewer/slots/handleNotificationWhenNoSlots.ts

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

* Add try/catch

* fix breaking when option === undefined

* Add missing await to Promise.all

* Rename data stored in redis to save space

* Include thrown error in logs

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
2024-05-03 13:48:04 +00:00
Hariom Balhara 88f4dd2446 Ensure that availability is fetched in parallel (#14809) 2024-05-03 13:56:10 +01:00
Keith Williams 97b2816897 v4.0.6 (#14865)
release.yml / release (push) Canceled after 0s
2024-05-02 22:23:46 +00:00
Erik 00372b6b3b chore: Discard unused Platform stripe events (#14822)
* chore: discard unused webhooks

* chore: discard early
2024-05-02 16:31:50 +00:00
Hariom Balhara 6c8fced247 fix: Snippet path in preview (#14830)
* fix: Snippet path in preview

* Add test
2024-05-02 16:03:57 +00:00
Erik 52813b01e6 chore: don't try to increase usage for legacy plans (#14856) 2024-05-02 15:24:42 +00:00
Erik 77a98d96c8 chore: attach unique request ids (#14857) 2024-05-02 15:24:24 +00:00
Peer RichelsenandUdit Takkar 676eac357f chore: fixed enterprise warning and description (#14791)
* fixed enterprise warning and description

* chore: remove fullstop

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-05-02 15:14:13 +00:00
Joe Au-Yeung 5bafd1b264 chore: Allow disabling attendee emails if SMS_ATTENDEE is a part of the workflow (#14806)
* Allow disabling confirmation emails to attendees if workflow contains SMS

* Update copy
2024-05-02 10:44:08 -04:00
0391bfe887 test: add unit tests for sms sending (#14737)
* add tests for workflow sms

* fix type error

* improvements

* add tests for team workflow

* rename test

* add emailsToReceive everywhere

* fix type errors

* code clean up

* fix fresh-booking.test.ts

* add destination email to emailsToReceive

* remove unused webhooks

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-05-02 10:42:38 -04:00
Peer Richelsen c18407d75a full screen ratings (#14839) 2024-05-02 09:43:28 +00:00
Erik a631286770 chore: Add default to match migration for Platform Billing (#14847) 2024-05-02 05:29:32 +00:00
Carina WollendorferandCarinaWolli dd78741158 fix e2e test (#14845)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-05-01 20:45:40 +00:00
Peer Richelsen 8f69811e25 chore: Update README.md (#14819) 2024-05-01 21:18:53 +02:00
Erik 3a63a8b228 chore: Bring back next-18next.config.js (#14841)
* chore: bring back next-18next.config.js

* chore: bring back next-18next.config.js
2024-05-01 10:23:03 -03:00
Somay ChauhanandPeer Richelsen ef98105503 feat: send organization_id to intercom (#14811)
* send organization_id to intercom

* send org slug instead of org id

* fix: type errors

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-04-30 21:38:18 +00:00
306 changed files with 7384 additions and 2303 deletions
+6 -24
View File
@@ -2,29 +2,18 @@
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
Fixes # (issue)
- Fixes #XXXX (GitHub issue number)
- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)
<!-- Please provide a loom video for visual changes to speed up reviews
Loom Video: https://www.loom.com/
-->
## Requirement/Documentation
## Mandatory Tasks (DO NOT REMOVE)
<!-- Please provide all documents that are important to understand the reason of that PR. -->
- If there is a requirement document, please, share it here.
- If there is a UI/UX design document, please, share it here.
## Type of change
<!-- Please delete bullets that are not relevant. -->
- Bug fix (non-breaking change which fixes an issue)
- Chore (refactoring code, technical debt, workflow improvements)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
- Tests (Unit/Integration/E2E or any other test)
- This change requires a documentation update
- [ ] 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)
## How should this be tested?
@@ -35,10 +24,6 @@ Fixes # (issue)
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR
## Mandatory Tasks
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
## Checklist
<!-- Remove bullet points below that don't apply to you -->
@@ -46,7 +31,4 @@ Fixes # (issue)
- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my PR needs changes to the documentation
- I haven't checked if my changes generate no new warnings
- I haven't added tests that prove my fix is effective or that my feature works
- I haven't checked if new and existing unit tests pass locally with my changes
+4 -1
View File
@@ -11,11 +11,14 @@ runs:
uses: buildjet/cache@v3
id: cache-build
env:
# WARN: Don't touch this cache key. Currently github.sha refers to the latest commit in main
# and not the branch that's attempting to merge to main, which causes CI errors when merged
# to main.
# TODO: Fix this problem if intending to modify this cache key.
cache-name: prod-build
key-1: ${{ inputs.node_version }}-${{ hashFiles('yarn.lock') }}
key-2: ${{ hashFiles('apps/**/**.[jt]s', 'apps/**/**.[jt]sx', 'packages/**/**.[jt]s', 'packages/**/**.[jt]sx', '!**/node_modules') }}
key-3: ${{ github.event.pull_request.number || github.ref }}
# Ensures production-build.yml will always be fresh
key-4: ${{ github.sha }}
with:
path: |
@@ -1,4 +1,4 @@
name: Production Build
name: Production Builds
on:
workflow_call:
@@ -18,7 +18,10 @@ env:
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
@@ -33,13 +36,10 @@ env:
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
jobs:
build:
name: Build API V1
name: Build API v1
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
services:
@@ -0,0 +1,42 @@
name: Production Build
on:
workflow_call:
env:
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
jobs:
build:
name: Build API v2
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
services:
postgres:
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- run: |
export NODE_OPTIONS="--max_old_space_size=8192"
yarn workspace @calcom/api-v2 run generate-schemas
rm -rf apps/api/v2/node_modules
yarn install
yarn workspace @calcom/api-v2 run build
shell: bash
+2 -2
View File
@@ -6,7 +6,7 @@ on:
jobs:
cleanup:
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: Check out code
uses: actions/checkout@v3
@@ -21,7 +21,7 @@ jobs:
echo "Fetching list of cache key"
cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 )
## Setting this to not fail the workflow while deleting cache keys.
## Setting this to not fail the workflow while deleting cache keys.
set +e
echo "Deleting caches..."
for cacheKey in $cacheKeysForPR
+35 -34
View File
@@ -3,6 +3,41 @@ on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
e2e-app-store:
timeout-minutes: 20
@@ -59,40 +94,6 @@ jobs:
- uses: ./.github/actions/cache-build
- name: Run Tests
run: yarn e2e:app-store --shard=${{ matrix.shard }}/${{ strategy.job-total }}
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }}
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ secrets.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+35 -29
View File
@@ -3,6 +3,41 @@ on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
e2e-embed:
timeout-minutes: 20
@@ -46,35 +81,6 @@ jobs:
run: |
yarn e2e:embed-react --shard=${{ matrix.shard }}/${{ strategy.job-total }}
yarn workspace @calcom/embed-react packaged:tests
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+35 -30
View File
@@ -3,6 +3,41 @@ on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
e2e-embed:
timeout-minutes: 20
@@ -52,36 +87,6 @@ jobs:
- uses: ./.github/actions/cache-build
- name: Run Tests
run: yarn e2e:embed --shard=${{ matrix.shard }}/${{ strategy.job-total }}
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+36 -35
View File
@@ -3,11 +3,46 @@ on:
workflow_call:
env:
NODE_OPTIONS: --max-old-space-size=4096
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
e2e:
timeout-minutes: 20
name: E2E tests (${{ matrix.shard }}/${{ strategy.job-total }})
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: buildjet-8vcpu-ubuntu-2204
services:
postgres:
image: postgres:13
@@ -50,40 +85,6 @@ jobs:
- uses: ./.github/actions/cache-build
- name: Run Tests
run: yarn e2e --shard=${{ matrix.shard }}/${{ strategy.job-total }}
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
- name: Upload Test Results
if: ${{ always() }}
uses: actions/upload-artifact@v4
+40 -3
View File
@@ -7,16 +7,53 @@ on:
branches:
- main
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_MAILHOG_ENABLED: ${{ vars.E2E_TEST_MAILHOG_ENABLED }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
EMAIL_SERVER_HOST: ${{ secrets.CI_EMAIL_SERVER_HOST }}
EMAIL_SERVER_PORT: ${{ secrets.CI_EMAIL_SERVER_PORT }}
EMAIL_SERVER_USER: ${{ secrets.CI_EMAIL_SERVER_USER }}
EMAIL_SERVER_PASSWORD: ${{ secrets.CI_EMAIL_SERVER_PASSWORD}}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
jobs:
build:
name: Production build
name: Production builds
if: ${{ github.event_name == 'push' }}
uses: ./.github/workflows/production-build.yml
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
analyze:
needs: build
if: always()
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
+17 -17
View File
@@ -14,7 +14,7 @@ concurrency:
jobs:
changes:
name: Detect changes
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: buildjet-2vcpu-ubuntu-2204
permissions:
pull-requests: read
outputs:
@@ -49,22 +49,22 @@ jobs:
uses: ./.github/workflows/lint.yml
secrets: inherit
build:
name: Production build
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/production-build.yml
secrets: inherit
build-api-v1:
name: Production build
name: Production builds
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v1-production-build.yml
secrets: inherit
build-without-database:
name: Production build (without database)
build-api-v2:
name: Production builds
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/api-v2-production-build.yml
secrets: inherit
build:
name: Production builds
needs: [changes]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/production-build-without-database.yml
@@ -72,28 +72,28 @@ jobs:
e2e:
name: E2E tests
needs: [changes, lint, build]
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
name: E2E App Store tests
needs: [changes, lint, build]
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
name: E2E embeds tests
needs: [changes, lint, build]
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: E2E React embeds tests
needs: [changes, lint, build]
needs: [changes, lint, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
@@ -106,9 +106,9 @@ jobs:
secrets: inherit
required:
needs: [changes, lint, type-check, test, build, build-api-v1, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
needs: [changes, lint, type-check, test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
if: needs.changes.outputs.has-files-requiring-all-checks == 'true' && (contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled'))
+4 -9
View File
@@ -39,8 +39,8 @@ jobs:
secrets: inherit
build:
name: Production build
uses: ./.github/workflows/production-build.yml
name: Production builds
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
e2e:
@@ -67,15 +67,10 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
build-without-database:
name: Production build (without database)
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
required:
needs: [e2e, e2e-app-store, e2e-embed, e2e-embed-react, build-without-database]
needs: [changes, lint, build, e2e, e2e-app-store, e2e-embed, e2e-embed-react]
if: always()
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'skipped') || contains(needs.*.result, 'cancelled')
@@ -1,5 +1,4 @@
name: Production Build (without database)
name: Production Builds
on:
workflow_call:
@@ -37,8 +36,8 @@ env:
jobs:
build:
name: Build
runs-on: ubuntu-latest
name: Web App
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
steps:
- uses: actions/checkout@v3
-69
View File
@@ -1,69 +0,0 @@
name: Production Build
on:
workflow_call:
env:
ALLOWED_HOSTNAMES: ${{ vars.CI_ALLOWED_HOSTNAMES }}
CALENDSO_ENCRYPTION_KEY: ${{ secrets.CI_CALENDSO_ENCRYPTION_KEY }}
DAILY_API_KEY: ${{ secrets.CI_DAILY_API_KEY }}
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ secrets.CI_DATABASE_URL }}
E2E_TEST_APPLE_CALENDAR_EMAIL: ${{ secrets.E2E_TEST_APPLE_CALENDAR_EMAIL }}
E2E_TEST_APPLE_CALENDAR_PASSWORD: ${{ secrets.E2E_TEST_APPLE_CALENDAR_PASSWORD }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ secrets.E2E_TEST_CALCOM_QA_EMAIL }}
E2E_TEST_CALCOM_QA_PASSWORD: ${{ secrets.E2E_TEST_CALCOM_QA_PASSWORD }}
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ secrets.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
E2E_TEST_CALCOM_GCAL_KEYS: ${{ secrets.E2E_TEST_CALCOM_GCAL_KEYS }}
GOOGLE_API_CREDENTIALS: ${{ secrets.CI_GOOGLE_API_CREDENTIALS }}
GOOGLE_LOGIN_ENABLED: ${{ vars.CI_GOOGLE_LOGIN_ENABLED }}
NEXTAUTH_SECRET: ${{ secrets.CI_NEXTAUTH_SECRET }}
NEXTAUTH_URL: ${{ secrets.CI_NEXTAUTH_URL }}
NEXT_PUBLIC_IS_E2E: ${{ vars.CI_NEXT_PUBLIC_IS_E2E }}
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: ${{ secrets.CI_NEXT_PUBLIC_STRIPE_PUBLIC_KEY }}
NEXT_PUBLIC_WEBAPP_URL: ${{ vars.CI_NEXT_PUBLIC_WEBAPP_URL }}
NEXT_PUBLIC_WEBSITE_URL: ${{ vars.CI_NEXT_PUBLIC_WEBSITE_URL }}
PAYMENT_FEE_FIXED: ${{ vars.CI_PAYMENT_FEE_FIXED }}
PAYMENT_FEE_PERCENTAGE: ${{ vars.CI_PAYMENT_FEE_PERCENTAGE }}
SAML_ADMINS: ${{ secrets.CI_SAML_ADMINS }}
SAML_DATABASE_URL: ${{ secrets.CI_SAML_DATABASE_URL }}
STRIPE_PRIVATE_KEY: ${{ secrets.CI_STRIPE_PRIVATE_KEY }}
STRIPE_CLIENT_ID: ${{ secrets.CI_STRIPE_CLIENT_ID }}
STRIPE_WEBHOOK_SECRET: ${{ secrets.CI_STRIPE_WEBHOOK_SECRET }}
SENDGRID_API_KEY: ${{ secrets.CI_SENDGRID_API_KEY }}
SENDGRID_EMAIL: ${{ secrets.CI_SENDGRID_EMAIL }}
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
NEXT_PUBLIC_API_V2_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_URL }}
NEXT_PUBLIC_API_V2_ROOT_URL: ${{ secrets.CI_NEXT_PUBLIC_API_V2_ROOT_URL }}
NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED: ${{ vars.CI_NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED }}
jobs:
build:
name: Build
runs-on: buildjet-4vcpu-ubuntu-2204
timeout-minutes: 30
services:
postgres:
image: postgres:13
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: calendso
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
- uses: ./.github/actions/yarn-install
- uses: ./.github/actions/cache-db
- uses: ./.github/actions/cache-build
+1 -1
View File
@@ -7,7 +7,7 @@ on:
jobs:
test:
timeout-minutes: 20
runs-on: buildjet-4vcpu-ubuntu-2204
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/dangerous-git-checkout
+2 -2
View File
@@ -24,7 +24,7 @@
<p align="center">
<a href="https://go.cal.com/discord"><img src="https://img.shields.io/badge/Discord-go.cal.com%2Fdiscord-%234A154B" alt="Join Cal.com Discord"></a>
<a href="https://producthunt.com/posts/calendso"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
<a href="https://www.producthunt.com/products/cal-com"><img src="https://img.shields.io/badge/Product%20Hunt-%231%20Product%20of%20the%20Month-%23DA552E" alt="Product Hunt"></a>
<a href="https://status.cal.com"><img height="20px" src="https://betteruptime.com/status-badges/v1/monitor/a9kf.svg" alt="Uptime"></a>
<a href="https://github.com/calcom/cal.com/stargazers"><img src="https://img.shields.io/github/stars/calcom/cal.com" alt="Github Stars"></a>
<a href="https://news.ycombinator.com/item?id=34507672"><img src="https://img.shields.io/badge/Hacker%20News-%231-%23FF6600" alt="Hacker News"></a>
@@ -33,7 +33,7 @@
<a href="https://cal.com/pricing"><img src="https://img.shields.io/badge/Pricing-Free-brightgreen" alt="Pricing"></a>
<a href="https://jitsu.com?utm_source=github/calcom/cal.com"><img src="https://img.shields.io/badge/Metrics_tracked_by-JITSU-AA00FF?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAACKSURBVHgBrZDRCYAwDEQv6gCOoKO4hOCXI9QVnEZwiY5iF5GaVClaBNtioCSUvCR3tMJaxIfZgW4AGUoEPVwgPZoS0Dmgg3NBVDFNbMIsmYCak3J1jDk9iCQvsKJvkzr71N81Gj6vDT/LU2P6RhY63jcafk3YJEbgeZpiFyc/5HJKv8Ef273NSfABGbQfUZhnOSAAAAAASUVORK5CYII=" alt="Jitsu Tracked"></a>
<img src="https://api.checklyhq.com/v1/badges/checks/5e048048-1b51-47ba-9209-60607507622e?responseTime=true" alt="Checkly Availability" />
<a href="https://hub.docker.com/r/calendso/calendso"><img src="https://img.shields.io/docker/pulls/calendso/calendso"></a>
<a href="https://hub.docker.com/r/calcom/cal.com"><img src="https://img.shields.io/docker/pulls/calcom/cal.com"></a>
<a href="https://twitch.tv/calcomtv"><img src="https://img.shields.io/twitch/status/calcomtv?style=flat"></a>
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+label:%22%F0%9F%99%8B%F0%9F%8F%BB%E2%80%8D%E2%99%82%EF%B8%8Fhelp+wanted%22"><img src="https://img.shields.io/badge/Help%20Wanted-Contribute-blue"></a>
<a href="https://cal.com/figma"><img src="https://img.shields.io/badge/Figma-Design%20System-blueviolet"></a>
+1
View File
@@ -82,4 +82,5 @@ export const schemaBookingReadPublic = Booking.extend({
metadata: true,
status: true,
responses: true,
fromReschedule: true,
});
+5
View File
@@ -57,6 +57,11 @@ const swaggerHandler = withSwagger({
type: "string",
example: "Europe/London",
},
fromReschedule: {
type: "string",
nullable: true,
format: "uuid",
},
attendees: {
type: "array",
items: {
@@ -107,7 +107,7 @@ export async function patchHandler(req: NextApiRequest) {
if (IS_TEAM_BILLING_ENABLED) {
const checkoutSession = await purchaseTeamOrOrgSubscription({
teamId: _team.id,
seats: _team.members.length,
seatsUsed: _team.members.length,
userId,
pricePerSeat: null,
});
+1 -1
View File
@@ -12,7 +12,7 @@ module.exports = {
node: true,
jest: true,
},
ignorePatterns: [".eslintrc.js"],
ignorePatterns: [".eslintrc.js", "next-i18next.config.js"],
rules: {
"@typescript-eslint/interface-name-prefix": "off",
"@typescript-eslint/explicit-function-return-type": "off",
+11
View File
@@ -0,0 +1,11 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const path = require("path");
const i18nConfig = require("@calcom/config/next-i18next.config");
/** @type {import("next-i18next").UserConfig} */
const config = {
...i18nConfig,
localePath: path.resolve("../../web/public/static/locales"),
};
module.exports = config;
+1 -1
View File
@@ -13,7 +13,7 @@
"dev:build": "yarn workspace @calcom/platform-constants build && yarn workspace @calcom/platform-utils build && yarn workspace @calcom/platform-types build && yarn workspace @calcom/platform-libraries build",
"dev": "yarn dev:build && docker-compose up -d && yarn copy-swagger-module && yarn start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "SKIP_DOCS_GENERATION=1 node ./dist/apps/api/v2/src/main.js",
"start:prod": "node ./dist/apps/api/v2/src/main.js",
"test": "yarn dev:build && jest",
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
+11 -1
View File
@@ -3,6 +3,8 @@ import { AppLoggerMiddleware } from "@/middleware/app.logger.middleware";
import { RewriterMiddleware } from "@/middleware/app.rewrites.middleware";
import { JsonBodyMiddleware } from "@/middleware/body/json.body.middleware";
import { RawBodyMiddleware } from "@/middleware/body/raw.body.middleware";
import { ResponseInterceptor } from "@/middleware/request-ids/request-id.interceptor";
import { RequestIdMiddleware } from "@/middleware/request-ids/request-id.middleware";
import { AuthModule } from "@/modules/auth/auth.module";
import { EndpointsModule } from "@/modules/endpoints.module";
import { JwtModule } from "@/modules/jwt/jwt.module";
@@ -11,7 +13,7 @@ import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { RouterModule } from "@nestjs/core";
import { APP_INTERCEPTOR, RouterModule } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { ThrottlerStorageRedisService } from "nestjs-throttler-storage-redis";
@@ -52,6 +54,12 @@ import { AppController } from "./app.controller";
RouterModule.register([{ path: "/v2", module: EndpointsModule }]),
],
controllers: [AppController],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: ResponseInterceptor,
},
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
@@ -63,6 +71,8 @@ export class AppModule implements NestModule {
})
.apply(JsonBodyMiddleware)
.forRoutes("*")
.apply(RequestIdMiddleware)
.forRoutes("*")
.apply(AppLoggerMiddleware)
.forRoutes("*")
.apply(RewriterMiddleware)
@@ -150,7 +150,9 @@ export class BookingsController {
@Headers(X_CAL_CLIENT_ID) clientId?: string
): Promise<ApiResponse<unknown>> {
const oAuthClientId = clientId?.toString();
const locationUrl = body.locationUrl;
const { orgSlug, locationUrl } = body;
req.headers["x-cal-force-slug"] = orgSlug;
try {
const booking = await handleNewBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl)
@@ -99,6 +99,10 @@ export class CreateBookingInput {
@Type(() => Response)
responses!: Response;
@IsString()
@IsOptional()
orgSlug?: string;
@IsString()
@IsOptional()
locationUrl?: string;
@@ -131,6 +131,7 @@ describe("Event types Endpoints", () => {
description: "A description of the test event type.",
length: 60,
hidden: false,
disableGuests: true,
locations: [
{
type: "Online",
@@ -148,6 +149,7 @@ describe("Event types Endpoints", () => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
expect(responseBody.data).toHaveProperty("id");
expect(responseBody.data.title).toEqual(body.title);
expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
eventType = responseBody.data;
});
});
@@ -157,14 +159,19 @@ describe("Event types Endpoints", () => {
const body: UpdateEventTypeInput = {
title: newTitle,
disableGuests: false,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.send(body)
.expect(200)
.then(async () => {
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventType> = response.body;
expect(responseBody.data.title).toEqual(newTitle);
expect(responseBody.data.disableGuests).toEqual(body.disableGuests);
eventType.title = newTitle;
eventType.disableGuests = responseBody.data.disableGuests ?? false;
});
});
@@ -41,6 +41,10 @@ export class CreateEventTypeInput {
@IsArray()
locations?: EventTypeLocation[];
@IsBoolean()
@IsOptional()
disableGuests?: boolean;
// @ApiHideProperty()
// @IsOptional()
// @IsNumber()
@@ -12,5 +12,5 @@ export class GetPublicEventTypeQueryParams {
@ApiProperty({ required: false })
@IsString()
@IsOptional()
org?: string;
org?: string | null;
}
@@ -322,9 +322,9 @@ export class UpdateEventTypeInput {
// @IsOptional()
// recurringEvent?: RecurringEvent;
// @IsBoolean()
// @IsOptional()
// disableGuests?: boolean;
@IsBoolean()
@IsOptional()
disableGuests?: boolean;
// @IsBoolean()
// @IsOptional()
+6 -9
View File
@@ -26,16 +26,12 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";
import {
APPS_READ,
GOOGLE_CALENDAR_ID,
GOOGLE_CALENDAR_TYPE,
SUCCESS_STATUS,
} from "@calcom/platform-constants";
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
const CALENDAR_SCOPES = [
"https://www.googleapis.com/auth/calendar.readonly",
@@ -106,10 +102,11 @@ export class GcalController {
const oAuth2Client = await this.gcalService.getOAuthClient(this.redirectUri);
const token = await oAuth2Client.getToken(parsedCode);
const key = token.res?.data;
// Google oAuth Credentials are stored in token.tokens
const key = token.tokens;
const credential = await this.credentialRepository.createAppCredential(
GOOGLE_CALENDAR_TYPE,
key,
key as Prisma.InputJsonValue,
ownerId
);
@@ -129,7 +126,7 @@ export class GcalController {
primaryCal.id,
credential.id,
ownerId,
GOOGLE_CALENDAR_ID
GOOGLE_CALENDAR_TYPE
);
}
@@ -13,18 +13,22 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const statusCode = exception.getStatus();
const requestId = request.headers["X-Request-Id"];
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
response.status(statusCode).json({
status: ERROR_STATUS,
timestamp: new Date().toISOString(),
path: request.url,
error: { code: exception.name, message: exception.message },
error: { code: exception.name, message: exception.message, details: exception.getResponse() },
});
}
}
@@ -33,12 +33,15 @@ export class PrismaExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
this.logger.error(`PrismaError: ${error.message}`, {
error,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
status: ERROR_STATUS,
@@ -14,6 +14,7 @@ export class SentryFilter extends BaseExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
this.logger.error(`Sentry Exception Filter: ${exception?.message}`, {
exception,
@@ -21,6 +22,7 @@ export class SentryFilter extends BaseExceptionFilter {
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
// capture if client has been init
@@ -41,12 +41,15 @@ export class TRPCExceptionFilter implements ExceptionFilter {
break;
}
const requestId = request.headers["X-Request-Id"];
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
response.status(statusCode).json({
@@ -14,6 +14,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
this.logger.error(`ZodError: ${error.message}`, {
error,
@@ -21,6 +22,7 @@ export class ZodExceptionFilter implements ExceptionFilter {
headers: request.headers,
url: request.url,
method: request.method,
requestId,
});
response.status(HttpStatus.BAD_REQUEST).json({
+1 -3
View File
@@ -24,9 +24,7 @@ const run = async () => {
try {
bootstrap(app);
const port = app.get(ConfigService<AppConfig, true>).get("api.port", { infer: true });
if (process.env.SKIP_DOCS_GENERATION !== "1") {
void generateSwagger(app);
}
void generateSwagger(app);
await app.listen(port);
logger.log(`Application started on port: ${port}`);
} catch (error) {
@@ -0,0 +1,16 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common";
import { Request, Response } from "express";
@Injectable()
export class ResponseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const ctx = context.switchToHttp();
const request = ctx.getRequest<Request>();
const response = ctx.getResponse<Response>();
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
return next.handle();
}
}
@@ -0,0 +1,11 @@
import { Injectable, NestMiddleware } from "@nestjs/common";
import { Request, Response, NextFunction } from "express";
import { v4 as uuid } from "uuid";
@Injectable()
export class RequestIdMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
req.headers["X-Request-Id"] = uuid();
next();
}
}
@@ -46,7 +46,7 @@ export class BillingController {
@Get("/:teamId/check")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
@Roles(["OWNER", "ADMIN", "MEMBER"])
async checkTeamBilling(
@Param("teamId") teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
@@ -104,10 +104,19 @@ export class BillingController {
if (event.type === "customer.subscription.created" || event.type === "customer.subscription.updated") {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription.metadata?.teamId) {
return {
status: "success",
};
}
const teamId = Number.parseInt(subscription.metadata.teamId);
const plan = subscription.metadata.plan;
if (!plan || !teamId) {
throw new Error("Invalid webhook received.");
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return {
status: "success",
};
}
await this.billingService.setSubscriptionForTeam(
@@ -121,6 +130,8 @@ export class BillingController {
};
}
throw new BadRequestException(`Unhandled event type ${event.type}`);
return {
status: "success",
};
}
}
@@ -62,8 +62,8 @@ export class BillingService {
quantity: 1,
},
],
success_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
cancel_url: `${this.webAppUrl}/settings/platform/oauth-clients`,
success_url: `${this.webAppUrl}/settings/platform/`,
cancel_url: `${this.webAppUrl}/settings/platform/`,
mode: "subscription",
metadata: {
teamId: teamId.toString(),
@@ -99,22 +99,36 @@ export class BillingService {
}
async increaseUsageForTeam(teamId: number) {
// TODO - if we support multiple subscription items per team, we may need to track which plan they're
// subscribed to so we can do one less query.
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
if (!billingSubscription || !billingSubscription?.subscriptionId) {
throw new Error(`Failed to increase usage for team ${teamId}`);
}
try {
const billingSubscription = await this.billingRepository.getBillingForTeam(teamId);
if (!billingSubscription || !billingSubscription?.subscriptionId) {
this.logger.error("Team did not have stripe subscription associated to it", {
teamId,
});
return void 0;
}
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const items = stripeSubscription.items.data[0]; // first (and only) subscription item.
await this.stripeService.stripe.subscriptionItems.createUsageRecord(items.id, {
action: "increment",
quantity: 1,
timestamp: "now",
});
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const item = stripeSubscription.items.data[0];
// legacy plans are licensed, we cannot create usage records against them
if (item.price?.recurring?.usage_type === "licensed") {
return void 0;
}
await this.stripeService.stripe.subscriptionItems.createUsageRecord(item.id, {
action: "increment",
quantity: 1,
timestamp: "now",
});
} catch (error) {
// don't fail the request, log it.
this.logger.error("Failed to increase usage for team", {
teamId: teamId,
error,
});
}
}
async increaseUsageByClientId(clientId: string) {
+1
View File
@@ -1,5 +1,6 @@
export enum PlatformPlan {
STARTER = "STARTER",
ESSENTIALS = "ESSENTIALS",
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
@@ -139,6 +139,8 @@ describe("OAuth Client Users Endpoints", () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
weekStart: "Monday",
timeFormat: 24,
};
const response = await request(app.getHttpServer())
@@ -158,6 +160,9 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.email).toEqual(getOAuthUserEmail(oAuthClient.id, requestBody.email));
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
@@ -3,6 +3,8 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
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";
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/CreateOAuthClientResponse.dto";
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
@@ -10,9 +12,11 @@ import { UpdateOAuthClientInput } from "@/modules/oauth-clients/inputs/update-oa
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import {
Body,
Controller,
Query,
Get,
Post,
Patch,
@@ -32,9 +36,11 @@ import {
ApiCreatedResponse as DocsCreatedResponse,
} from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { User } from "@prisma/client";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CreateOAuthClientInput } from "@calcom/platform-types";
import { Pagination } from "@calcom/platform-types";
const AUTH_DOCUMENTATION = `⚠️ First, this endpoint requires \`Cookie: next-auth.session-token=eyJhbGciOiJ\` header. Log into Cal web app using owner of organization that was created after visiting \`/settings/organizations/new\`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.
Second, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.`;
@@ -51,6 +57,7 @@ export class OAuthClientsController {
constructor(
private readonly oauthClientRepository: OAuthClientRepository,
private readonly userRepository: UsersRepository,
private readonly teamsRepository: OrganizationsRepository
) {}
@@ -109,6 +116,24 @@ export class OAuthClientsController {
return { status: SUCCESS_STATUS, data: client };
}
@Get("/:clientId/managed-users")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientManagedUsersById(
@Param("clientId") clientId: string,
@Query() queryParams: Pagination
): Promise<GetManagedUsersOutput> {
const { offset, limit } = queryParams;
const existingManagedUsers = await this.userRepository.findManagedUsersByOAuthClientId(
clientId,
offset ?? 0,
limit ?? 50
);
return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) };
}
@Patch("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@@ -131,4 +156,17 @@ export class OAuthClientsController {
const client = await this.oauthClientRepository.deleteOAuthClient(clientId);
return { status: SUCCESS_STATUS, data: client };
}
private getResponseUser(user: User): ManagedUserOutput {
return {
id: user.id,
email: user.email,
username: user.username,
timeZone: user.timeZone,
weekStart: user.weekStart,
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
};
}
}
@@ -53,6 +53,9 @@ export class OAuthClientUsersService {
},
},
isPlatformManaged,
timeFormat: body.timeFormat,
weekStart: body.weekStart,
timeZone: body.timeZone,
})
)[0];
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
@@ -40,7 +40,7 @@ export class OAuthFlowService {
}
async getOwnerId(accessToken: string) {
const cacheKey = this._generateActKey(accessToken);
const cacheKey = this._generateOwnerIdKey(accessToken);
try {
const ownerId = await this.redisService.redis.get(cacheKey);
@@ -165,4 +165,8 @@ export class OAuthFlowService {
private _generateActKey(accessToken: string) {
return `act_${accessToken}`;
}
private _generateOwnerIdKey(accessToken: string) {
return `owner_${accessToken}`;
}
}
@@ -1,10 +1,24 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import { cityTimezonesHandler } from "@calcom/platform-libraries";
import type { CityTimezones } from "@calcom/platform-libraries";
@Injectable()
export class TimezonesService {
async getCityTimeZones() {
return cityTimezonesHandler();
private cacheKey = "cityTimezones";
constructor(private readonly redisService: RedisService) {}
async getCityTimeZones(): Promise<CityTimezones> {
const cachedTimezones = await this.redisService.redis.get(this.cacheKey);
if (!cachedTimezones) {
const timezones = await cityTimezonesHandler();
await this.redisService.redis.set(this.cacheKey, JSON.stringify(timezones), "EX", 60 * 60 * 24);
return timezones;
} else {
return JSON.parse(cachedTimezones) as CityTimezones;
}
}
}
@@ -1,9 +1,10 @@
import { RedisModule } from "@/modules/redis/redis.module";
import { TimezonesController } from "@/modules/timezones/controllers/timezones.controller";
import { TimezonesService } from "@/modules/timezones/services/timezones.service";
import { Module } from "@nestjs/common";
@Module({
imports: [],
imports: [RedisModule],
providers: [TimezonesService],
controllers: [TimezonesController],
exports: [TimezonesService],
@@ -1,8 +1,8 @@
import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format";
import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start";
import { ApiProperty } from "@nestjs/swagger";
import { IsNumber, IsOptional, IsTimeZone, IsString, Validate } from "class-validator";
import { IsOptional, IsTimeZone, IsString } from "class-validator";
export type WeekDay = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
export type TimeFormat = 12 | 24;
export class CreateManagedUserInput {
@IsString()
@ApiProperty({ example: "alice@example.com" })
@@ -12,17 +12,17 @@ export class CreateManagedUserInput {
@IsOptional()
name?: string;
@IsNumber()
@IsOptional()
@Validate(IsTimeFormat)
@ApiProperty({ example: 12 })
timeFormat?: number;
@ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
timeFormat?: TimeFormat;
@IsString()
@IsOptional()
@Validate(IsWeekStart)
@ApiProperty({ example: "Sunday" })
weekStart?: string;
@IsString()
@ApiProperty({
example: "Monday",
enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
})
weekStart?: WeekDay;
@IsTimeZone()
@IsOptional()
@@ -1,6 +1,6 @@
import { IsTimeFormat } from "@/modules/users/inputs/validators/is-time-format";
import { IsWeekStart } from "@/modules/users/inputs/validators/is-week-start";
import { IsNumber, IsOptional, IsString, IsTimeZone, Validate } from "class-validator";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiProperty } from "@nestjs/swagger";
import { IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator";
export class UpdateManagedUserInput {
@IsString()
@@ -11,19 +11,21 @@ export class UpdateManagedUserInput {
@IsOptional()
name?: string;
@IsNumber()
@IsOptional()
@Validate(IsTimeFormat)
timeFormat?: number;
@ApiProperty({ example: 12, enum: [12, 24], description: "Must be 12 or 24" })
timeFormat?: TimeFormat;
@IsNumber()
@IsOptional()
defaultScheduleId?: number;
@IsString()
@IsOptional()
@Validate(IsWeekStart)
weekStart?: string;
@IsString()
@ApiProperty({
example: "Monday",
enum: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
})
weekStart?: WeekDay;
@IsTimeZone()
@IsOptional()
@@ -1,12 +0,0 @@
import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator";
@ValidatorConstraint({ name: "isTimeFormat", async: false })
export class IsTimeFormat implements ValidatorConstraintInterface {
validate(timeFormat: number) {
return timeFormat === 12 || timeFormat === 24;
}
defaultMessage() {
return "timeFormat must be a number either 12 or 24";
}
}
@@ -1,23 +0,0 @@
import { ValidatorConstraint, ValidatorConstraintInterface } from "class-validator";
@ValidatorConstraint({ name: "isWeekStart", async: false })
export class IsWeekStart implements ValidatorConstraintInterface {
validate(weekStart: string) {
if (!weekStart) return false;
const lowerCaseWeekStart = weekStart.toLowerCase();
return (
lowerCaseWeekStart === "monday" ||
lowerCaseWeekStart === "tuesday" ||
lowerCaseWeekStart === "wednesday" ||
lowerCaseWeekStart === "thursday" ||
lowerCaseWeekStart === "friday" ||
lowerCaseWeekStart === "saturday" ||
lowerCaseWeekStart === "sunday"
);
}
defaultMessage() {
return "weekStart must be a string either Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, or Sunday";
}
}
@@ -157,7 +157,7 @@ export class UsersRepository {
formatInput(userInput: CreateManagedUserInput | UpdateManagedUserInput) {
if (userInput.weekStart) {
userInput.weekStart = capitalize(userInput.weekStart);
userInput.weekStart = userInput.weekStart;
}
if (userInput.timeZone) {
+80 -8
View File
@@ -404,6 +404,38 @@
]
}
},
"/v2/oauth-clients/{clientId}/managed-users": {
"get": {
"operationId": "OAuthClientsController_getOAuthClientManagedUsersById",
"summary": "",
"description": "⚠️ First, this endpoint requires `Cookie: next-auth.session-token=eyJhbGciOiJ` header. Log into Cal web app using owner of organization that was created after visiting `/settings/organizations/new`, refresh swagger docs, and the cookie will be added to requests automatically to pass the NextAuthGuard.\nSecond, make sure that the logged in user has organizationId set to pass the OrganizationRolesGuard guard.",
"parameters": [
{
"name": "clientId",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetManagedUsersOutput"
}
}
}
}
},
"tags": [
"OAuth - development only"
]
}
},
"/v2/oauth/{clientId}/authorize": {
"post": {
"operationId": "OAuthFlowController_authorize",
@@ -722,6 +754,7 @@
"required": false,
"in": "query",
"schema": {
"nullable": true,
"type": "string"
}
}
@@ -1650,11 +1683,25 @@
},
"timeFormat": {
"type": "number",
"example": 12
"example": 12,
"enum": [
12,
24
],
"description": "Must be 12 or 24"
},
"weekStart": {
"type": "string",
"example": "Sunday"
"example": "Monday",
"enum": [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
]
},
"timeZone": {
"type": "string",
@@ -1730,21 +1777,37 @@
"UpdateManagedUserInput": {
"type": "object",
"properties": {
"timeFormat": {
"type": "number",
"example": 12,
"enum": [
12,
24
],
"description": "Must be 12 or 24"
},
"weekStart": {
"type": "string",
"example": "Monday",
"enum": [
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
"Sunday"
]
},
"email": {
"type": "string"
},
"name": {
"type": "string"
},
"timeFormat": {
"type": "number"
},
"defaultScheduleId": {
"type": "number"
},
"weekStart": {
"type": "string"
},
"timeZone": {
"type": "string"
}
@@ -2036,6 +2099,9 @@
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
},
"disableGuests": {
"type": "boolean"
}
},
"required": [
@@ -2718,6 +2784,9 @@
"items": {
"$ref": "#/components/schemas/EventTypeLocation"
}
},
"disableGuests": {
"type": "boolean"
}
}
},
@@ -4226,6 +4295,9 @@
"responses": {
"$ref": "#/components/schemas/Response"
},
"orgSlug": {
"type": "string"
},
"locationUrl": {
"type": "string"
}
+2
View File
@@ -8,6 +8,8 @@ const env: Partial<Omit<Environment, "NODE_ENV">> = {
JWT_SECRET: "XF+Hws3A5g2eyWA5uGYYVJ74X+wrCWJ8oWo6kAfU6O8=",
LOG_LEVEL: "trace",
REDIS_URL: "redis://localhost:9199",
STRIPE_API_KEY: "sk_test_51J4",
STRIPE_WEBHOOK_SECRET: "whsec_51J4",
};
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
+9
View File
@@ -1,5 +1,14 @@
# @calcom/web
## 4.0.8
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.0
- @calcom/embed-react@1.5.0
- @calcom/embed-snippet@1.3.0
## 3.9.9
### Patch Changes
+1 -1
View File
@@ -85,7 +85,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
`}</style>
</head>
<body
className="dark:bg-darkgray-50 todesktop:!bg-transparent bg-subtle antialiased"
className="dark:bg-darkgray-50 bg-subtle antialiased"
style={
isEmbed
? {
@@ -321,6 +321,20 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
type="text"
{...formMethods.register("successRedirectUrl")}
/>
<div className="mt-4">
<Controller
name="forwardParamsSuccessRedirect"
render={({ field: { value, onChange } }) => (
<CheckboxField
description={t("forward_params_redirect")}
disabled={successRedirectUrlLocked.disabled}
onChange={(e) => onChange(e)}
checked={value}
/>
)}
/>
</div>
<div
className={classNames(
"p-1 text-sm text-orange-600",
@@ -8,6 +8,7 @@ import { Controller, useFormContext } from "react-hook-form";
import type { SingleValue } from "react-select";
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
import { getDefinedBufferTimes } from "@calcom/features/eventtypes/lib/getDefinedBufferTimes";
import type { FormValues } from "@calcom/features/eventtypes/lib/types";
import { classNames } from "@calcom/lib";
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
@@ -172,7 +173,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
label: t("event_buffer_default"),
value: 0,
},
...[5, 10, 15, 20, 30, 45, 60, 90, 120].map((minutes) => ({
...getDefinedBufferTimes().map((minutes) => ({
label: `${minutes} ${t("minutes")}`,
value: minutes,
})),
@@ -122,10 +122,21 @@ export const EventWebhooksTab = ({ eventType }: Pick<EventTypeSetupProps, "event
{webhooks.length ? (
<>
<div className="border-subtle mb-2 rounded-md border p-8">
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("add_webhook_description", { appName: APP_NAME })}
</p>
<div className="flex justify-between">
<div>
<div className="text-default text-sm font-semibold">{t("webhooks")}</div>
<p className="text-subtle max-w-[280px] break-words text-sm sm:max-w-[500px]">
{t("add_webhook_description", { appName: APP_NAME })}
</p>
</div>
{isChildrenManagedEventType && !isManagedEventType ? (
<Button StartIcon="lock" color="secondary" disabled>
{t("locked_by_team_admin")}
</Button>
) : (
<NewWebhookButton />
)}
</div>
<div className="border-subtle my-8 rounded-md border">
{webhooks.map((webhook, index) => {
@@ -0,0 +1,33 @@
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import { OAuthClientsDropdown } from "@components/settings/platform/dashboard/oauth-client-dropdown";
type ManagedUserHeaderProps = {
oauthClients: PlatformOAuthClient[];
initialClientName: string;
handleChange: (clientId: string, clientName: string) => void;
};
export const ManagedUserHeader = ({
oauthClients,
initialClientName,
handleChange,
}: ManagedUserHeaderProps) => {
return (
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
Managed Users
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
See all the managed users created by your OAuth client.
</p>
</div>
<OAuthClientsDropdown
oauthClients={oauthClients}
initialClientName={initialClientName}
handleChange={handleChange}
/>
</div>
);
};
@@ -0,0 +1,39 @@
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
import { ManagedUserHeader } from "@components/settings/platform/dashboard/managed-user-header";
import { ManagedUserTable } from "@components/settings/platform/dashboard/managed-user-table";
type ManagedUserListProps = {
oauthClients: PlatformOAuthClient[];
managedUsers?: ManagedUser[];
initialClientName: string;
initialClientId: string;
isManagedUserLoading: boolean;
handleChange: (clientId: string, clientName: string) => void;
};
export const ManagedUserList = ({
initialClientName,
initialClientId,
oauthClients,
managedUsers,
isManagedUserLoading,
handleChange,
}: ManagedUserListProps) => {
return (
<div>
<ManagedUserHeader
oauthClients={oauthClients}
initialClientName={initialClientName}
handleChange={handleChange}
/>
<ManagedUserTable
managedUsers={managedUsers}
isManagedUserLoading={isManagedUserLoading}
initialClientId={initialClientId}
/>
</div>
);
};
@@ -0,0 +1,60 @@
import { EmptyScreen } from "@calcom/ui";
import type { ManagedUser } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
type ManagedUserTableProps = {
managedUsers?: ManagedUser[];
isManagedUserLoading: boolean;
initialClientId: string;
};
export const ManagedUserTable = ({
managedUsers,
isManagedUserLoading,
initialClientId,
}: ManagedUserTableProps) => {
const showUsers = !isManagedUserLoading && managedUsers?.length;
return (
<div>
{showUsers ? (
<>
<table className="w-[100%] rounded-lg">
<colgroup className="border-subtle overflow-hidden rounded-b-lg border border-b-0" span={3} />
<tr>
<td className="border-subtle border px-4 py-3 md:text-center">Id</td>
<td className="border-subtle border px-4 py-3 md:text-center">Username</td>
<td className="border-subtle border px-4 py-3 md:text-center">Email</td>
</tr>
{managedUsers.map((user) => {
return (
<tr key={user.id} className="">
<td className="border-subtle overflow-hidden border px-4 py-3 md:text-center">{user.id}</td>
<td className="border-subtle border px-4 py-3 md:text-center">{user.username}</td>
<td className="border-subtle overflow-hidden border px-4 py-3 md:overflow-auto md:text-center">
{user.email}
</td>
</tr>
);
})}
</table>
</>
) : (
<EmptyScreen
limitWidth={false}
headline={
initialClientId == undefined
? "OAuth client is missing. You need to create an OAuth client first in order to create a managed user."
: `OAuth client ${initialClientId} does not have a managed user present.`
}
description={
initialClientId == undefined
? "Refer to the Platform Docs from the sidebar in order to create an OAuth client."
: "Refer to the Platform Docs from the sidebar in order to create a managed user."
}
className="items-center border"
/>
)}
</div>
);
};
@@ -0,0 +1,50 @@
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import {
Button,
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
} from "@calcom/ui";
type OAuthClientsDropdownProps = {
oauthClients: PlatformOAuthClient[];
initialClientName: string;
handleChange: (clientId: string, clientName: string) => void;
};
export const OAuthClientsDropdown = ({
oauthClients,
initialClientName,
handleChange,
}: OAuthClientsDropdownProps) => {
return (
<div>
{Array.isArray(oauthClients) && oauthClients.length > 0 ? (
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button color="secondary">{initialClientName}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{oauthClients.map((client) => {
return (
<div key={client.id}>
{initialClientName !== client.name ? (
<DropdownMenuItem className="outline-none">
<DropdownItem type="button" onClick={() => handleChange(client.id, client.name)}>
{client.name}
</DropdownItem>
</DropdownMenuItem>
) : (
<></>
)}
</div>
);
})}
</DropdownMenuContent>
</Dropdown>
) : null}
</div>
);
};
@@ -0,0 +1,81 @@
import { useRouter } from "next/navigation";
import type { PlatformOAuthClient } from "@calcom/prisma/client";
import { EmptyScreen, Button } from "@calcom/ui";
import { OAuthClientCard } from "@components/settings/platform/oauth-clients/OAuthClientCard";
type OAuthClientsListProps = {
oauthClients: PlatformOAuthClient[];
isDeleting: boolean;
handleDelete: (id: string) => Promise<void>;
};
export const OAuthClientsList = ({ oauthClients, isDeleting, handleDelete }: OAuthClientsListProps) => {
return (
<div className="mb-10">
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
OAuth Clients
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
Connect your platform to cal.com with OAuth
</p>
</div>
<div>
<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />
</div>
</div>
{Array.isArray(oauthClients) && oauthClients.length ? (
<>
<div className="border-subtle rounded-b-lg border border-t-0">
{oauthClients.map((client, index) => {
return (
<OAuthClientCard
name={client.name}
redirectUris={client.redirectUris}
bookingRedirectUri={client.bookingRedirectUri}
bookingRescheduleRedirectUri={client.bookingRescheduleRedirectUri}
bookingCancelRedirectUri={client.bookingCancelRedirectUri}
permissions={client.permissions}
key={index}
lastItem={oauthClients.length === index + 1}
id={client.id}
secret={client.secret}
isLoading={isDeleting}
onDelete={handleDelete}
areEmailsEnabled={client.areEmailsEnabled}
/>
);
})}
</div>
</>
) : (
<EmptyScreen
headline="Create your first OAuth client"
description="OAuth clients facilitate access to Cal.com on behalf of users"
Icon="plus"
className=""
buttonRaw={<NewOAuthClientButton redirectLink="/settings/platform/oauth-clients/create" />}
/>
)}
</div>
);
};
const NewOAuthClientButton = ({ redirectLink, label }: { redirectLink: string; label?: string }) => {
const router = useRouter();
return (
<Button
onClick={(e) => {
e.preventDefault();
router.push(redirectLink);
}}
color="secondary"
StartIcon="plus">
{!!label ? label : "Add"}
</Button>
);
};
@@ -0,0 +1,14 @@
import { useCheckTeamBilling } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import useMeQuery from "@lib/hooks/useMeQuery";
export const useGetUserAttributes = () => {
const { data: user, isLoading: isUserLoading } = useMeQuery();
const { data: userBillingData, isFetching: isUserBillingDataLoading } = useCheckTeamBilling(
user?.organizationId
);
const isPlatformUser = user?.organization.isPlatform;
const isPaidUser = userBillingData?.valid;
const userOrgId = user?.organizationId;
return { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId };
};
@@ -6,7 +6,7 @@ import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
import type { Avatar } from "@calcom/prisma/client";
import { Button, Icon, showToast } from "@calcom/ui";
import { hasPermission } from "../../../../../../../packages/platform/utils/permissions";
import { hasPermission } from "../../../../../../packages/platform/utils/permissions";
type OAuthClientCardProps = {
name: string;
@@ -115,7 +115,7 @@ export const OAuthClientCard = ({
</div>
<div className="border-subtle flex text-sm">
<span className="font-semibold">Permissions: </span>
<div className="flex">{clientPermissions}</div>
{permissions ? <div className="flex">{clientPermissions}</div> : <>&nbsp;Disabled</>}
</div>
<div className="flex gap-1 text-sm">
<span className="font-semibold">Redirect uris: </span>
@@ -145,7 +145,7 @@ export const OAuthClientCard = ({
className="bg-subtle hover:bg-emphasis text-white"
loading={isLoading}
disabled={isLoading}
onClick={() => router.push(`/settings/organizations/platform/oauth-clients/create?clientId=${id}`)}>
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>
Edit
</Button>
<Button
@@ -1,33 +1,18 @@
import { useRouter } from "next/navigation";
import type { FC } from "react";
import React, { useState, useCallback, useEffect } from "react";
import { useState, useCallback } from "react";
import { useForm, useFieldArray } from "react-hook-form";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
import { showToast } from "@calcom/ui";
import { Meta, Button, TextField, Label, Tooltip } from "@calcom/ui";
import { TextField, Tooltip, Button, Label } from "@calcom/ui";
import { useOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients";
import {
useCreateOAuthClient,
useUpdateOAuthClient,
} from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient";
type OAuthClientFormProps = {
defaultValues?: Partial<FormValues>;
isPending?: boolean;
isFormDisabled?: boolean;
onSubmit: (data: FormValues) => void;
};
import {
hasAppsReadPermission,
hasAppsWritePermission,
hasBookingReadPermission,
hasBookingWritePermission,
hasEventTypeReadPermission,
hasEventTypeWritePermission,
hasProfileReadPermission,
hasProfileWritePermission,
hasScheduleReadPermission,
hasScheduleWritePermission,
} from "../../../../../../../packages/platform/utils/permissions";
type FormValues = {
export type FormValues = {
name: string;
logo?: string;
permissions: number;
@@ -50,45 +35,20 @@ type FormValues = {
areEmailsEnabled?: boolean;
};
export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
export const OAuthClientForm = ({
defaultValues,
isPending,
isFormDisabled,
onSubmit,
}: OAuthClientFormProps) => {
const { t } = useLocale();
const router = useRouter();
const { data, isFetched, isError, refetch } = useOAuthClient(clientId);
const { register, control, handleSubmit, setValue } = useForm<FormValues>({
defaultValues: {
redirectUris: [{ uri: "" }],
},
defaultValues: { redirectUris: [{ uri: "" }], ...defaultValues },
});
const { fields, append, remove } = useFieldArray({
control,
name: "redirectUris",
});
useEffect(() => {
if (isFetched && data && !isError) {
setValue("name", data.name);
data.bookingRedirectUri && setValue("bookingRedirectUri", data.bookingRedirectUri);
data.bookingCancelRedirectUri && setValue("bookingCancelRedirectUri", data.bookingCancelRedirectUri);
data.bookingRescheduleRedirectUri &&
setValue("bookingRescheduleRedirectUri", data.bookingRescheduleRedirectUri);
setValue("areEmailsEnabled", data?.areEmailsEnabled);
data.redirectUris?.forEach?.((uri: string, index: number) => {
index === 0 && setValue(`redirectUris.${index}.uri`, uri);
index !== 0 && append({ uri });
});
if (!data.permissions) return;
if (hasAppsReadPermission(data.permissions)) setValue("appsRead", true);
if (hasAppsWritePermission(data.permissions)) setValue("appsWrite", true);
if (hasBookingReadPermission(data.permissions)) setValue("bookingRead", true);
if (hasBookingWritePermission(data.permissions)) setValue("bookingWrite", true);
if (hasEventTypeReadPermission(data.permissions)) setValue("eventTypeRead", true);
if (hasEventTypeWritePermission(data.permissions)) setValue("eventTypeWrite", true);
if (hasProfileReadPermission(data.permissions)) setValue("profileRead", true);
if (hasProfileWritePermission(data.permissions)) setValue("profileWrite", true);
if (hasScheduleReadPermission(data.permissions)) setValue("scheduleRead", true);
if (hasScheduleWritePermission(data.permissions)) setValue("scheduleWrite", true);
}
}, [isFetched, data]);
const disabledForm = Boolean(clientId && !isFetched && isError);
const [isSelectAllPermissionsChecked, setIsSelectAllPermissionsChecked] = useState(false);
@@ -104,67 +64,6 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
setIsSelectAllPermissionsChecked((preValue) => !preValue);
}, [isSelectAllPermissionsChecked, setValue]);
const { mutateAsync: save, isPending: isSaving } = useCreateOAuthClient({
onSuccess: () => {
showToast("OAuth client created successfully", "success");
refetch();
router.push("/settings/organizations/platform/oauth-clients");
},
onError: () => {
showToast("Internal server error, please try again later", "error");
},
});
const { mutateAsync: update, isPending: isUpdating } = useUpdateOAuthClient({
onSuccess: () => {
showToast("OAuth client updated successfully", "success");
refetch();
router.push("/settings/organizations/platform/oauth-clients");
},
onError: () => {
showToast("Internal server error, please try again later", "error");
},
clientId,
});
const onSubmit = (data: FormValues) => {
let userPermissions = 0;
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
const read = PERMISSIONS_GROUPED_MAP[entity].read;
const write = PERMISSIONS_GROUPED_MAP[entity].write;
if (data[`${entityKey}Read`]) userPermissions |= read;
if (data[`${entityKey}Write`]) userPermissions |= write;
});
if (clientId) {
// don't update permissions if client is already created
update({
name: data.name,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
} else {
save({
name: data.name,
permissions: userPermissions,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
}
};
const isPending = isSaving || isUpdating;
const permissionsCheckboxes = Object.keys(PERMISSIONS_GROUPED_MAP).map((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const permissionKey = PERMISSIONS_GROUPED_MAP[entity].key;
@@ -180,7 +79,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
id={`${permissionKey}Read`}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
disabled={Boolean(clientId)}
disabled={!!defaultValues}
/>
<label htmlFor={`${permissionKey}Read`} className="cursor-pointer text-sm">
Read
@@ -192,7 +91,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
id={`${permissionKey}Write`}
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
disabled={Boolean(clientId)}
disabled={!!defaultValues}
/>
<label htmlFor={`${permissionKey}Write`} className="cursor-pointer text-sm">
Write
@@ -205,16 +104,11 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
return (
<div>
<Meta
title={t("oauth_form_title") + (clientId ? " - Update" : "")}
description={t("oauth_form_description") + (clientId ? " - Update" : "")}
borderInShellHeader={true}
/>
<form
className="border-subtle rounded-b-lg border border-t-0 px-4 pb-8 pt-2"
onSubmit={handleSubmit(onSubmit)}>
<div className="mt-6">
<TextField disabled={disabledForm} required={true} label="Client name" {...register("name")} />
<TextField disabled={isFormDisabled} required={true} label="Client name" {...register("name")} />
</div>
<div className="mt-6">
<Label>Redirect uris</Label>
@@ -227,7 +121,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
required={index === 0}
className="w-[100%]"
label=""
disabled={disabledForm}
disabled={isFormDisabled}
{...register(`redirectUris.${index}.uri` as const)}
/>
</div>
@@ -239,10 +133,10 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
variant="icon"
StartIcon="plus"
className="text-default mx-2 mb-2"
disabled={isFormDisabled}
onClick={() => {
append({ uri: "" });
}}
disabled={disabledForm}
/>
{index > 0 && (
<Button
@@ -252,10 +146,10 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
variant="icon"
StartIcon="trash"
className="text-default mx-2 mb-2"
disabled={isFormDisabled}
onClick={() => {
remove(index);
}}
disabled={disabledForm}
/>
)}
</div>
@@ -300,7 +194,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
label="Booking redirect uri"
className="w-[100%]"
{...register("bookingRedirectUri")}
disabled={disabledForm}
disabled={isFormDisabled}
/>
</Tooltip>
</div>
@@ -311,7 +205,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
label="Booking cancel redirect uri"
className="w-[100%]"
{...register("bookingCancelRedirectUri")}
disabled={disabledForm}
disabled={isFormDisabled}
/>
</Tooltip>
</div>
@@ -322,7 +216,7 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
label="Booking reschedule redirect uri"
className="w-[100%]"
{...register("bookingRescheduleRedirectUri")}
disabled={disabledForm}
disabled={isFormDisabled}
/>
</Tooltip>
</div>
@@ -332,27 +226,23 @@ export const OAuthClientForm: FC<{ clientId?: string }> = ({ clientId }) => {
id="areEmailsEnabled"
className="bg-default border-default h-4 w-4 shrink-0 cursor-pointer rounded-[4px] border ring-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed"
type="checkbox"
disabled={disabledForm}
disabled={isFormDisabled}
/>
<label htmlFor="areEmailsEnabled" className="cursor-pointer px-2 text-base font-semibold">
Enable emails
</label>
</div>
<div className="mt-6">
<div className="flex justify-between">
<Tooltip side="right" content="Permissions once set cannot be modified">
<h1 className="text-base font-semibold underline">Permissions</h1>
</Tooltip>
<Button type="button" onClick={selectAllPermissions} disabled={disabledForm || Boolean(clientId)}>
<h1 className="text-base font-semibold underline">Permissions</h1>
<Button type="button" onClick={selectAllPermissions} disabled={!!defaultValues || isFormDisabled}>
{!isSelectAllPermissionsChecked ? "Select all" : "Discard all"}
</Button>
</div>
<div>{permissionsCheckboxes}</div>
</div>
<Button className="mt-6" type="submit" loading={isPending} disabled={disabledForm}>
{clientId ? "Update" : "Submit"}
<Button className="mt-6" type="submit" loading={isPending}>
{defaultValues ? "Update" : "Submit"}
</Button>
</form>
</div>
@@ -0,0 +1,57 @@
type IndividualPlatformPlan = {
plan: string;
description: string;
pricing?: number;
includes: string[];
};
// if pricing or plans change in future modify this
export const platformPlans: IndividualPlatformPlan[] = [
{
plan: "Starter",
description:
"Perfect for just getting started with community support and access to hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 99,
includes: [
"Up to 100 bookings a month",
"Community Support",
"Cal Atoms (React Library)",
"Platform APIs",
"Admin APIs",
],
},
{
plan: "Essentials",
description:
"Your essential package with sophisticated support, hosted platform APIs, Cal.com Atoms (React components) and more.",
pricing: 299,
includes: [
"Up to 500 bookings a month. $0,60 overage beyond",
"Everything in Starter",
"Cal Atoms (React Library)",
"User Management and Analytics",
"Technical Account Manager and Onboarding Support",
],
},
{
plan: "Scale",
description:
"The best all-in-one plan to scale your company. Everything you need to provide scheduling for the masses, without breaking things.",
pricing: 2499,
includes: [
"Up to 5000 bookings a month. $0.50 overage beyond",
"Everything in Essentials",
"Credential import from other platforms",
"Compliance Check SOC2, HIPAA",
"One-on-one developer calls",
"Help with Credentials Verification (Zoom, Google App Store)",
"Expedited features and integrations",
"SLA (99.999% uptime)",
],
},
{
plan: "Enterprise",
description: "Everything in Scale with generous volume discounts beyond 50,000 bookings a month.",
includes: ["Beyond 50,000 bookings a month", "Everything in Scale", "Up to 50% discount on overages"],
},
];
@@ -0,0 +1,54 @@
import { Button } from "@calcom/ui";
type PlatformBillingCardProps = {
plan: string;
description: string;
pricing?: number;
includes: string[];
isLoading?: boolean;
handleSubscribe?: () => void;
};
export const PlatformBillingCard = ({
plan,
description,
pricing,
includes,
isLoading,
handleSubscribe,
}: PlatformBillingCardProps) => {
return (
<div className="border-subtle w-auto rounded-md border p-5 lg:w-[350px]">
<div className="pb-5">
<h1 className="pb-3 text-xl font-semibold">{plan}</h1>
<p className="pb-5 text-base">{description}</p>
<h1 className="text-3xl font-semibold">
{pricing && (
<>
US${pricing} <span className="text-sm">per month</span>
</>
)}
</h1>
</div>
<div>
<Button
loading={isLoading}
onClick={handleSubscribe}
className="flex w-[100%] items-center justify-center">
{pricing ? "Subscribe" : "Schedule a time"}
</Button>
</div>
<div className="mt-5">
<p>This includes:</p>
{includes.map((feature) => {
return (
<div key={feature} className="my-2 flex">
<div className="pr-2">&bull;</div>
<div>{feature}</div>
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,53 @@
import { useRouter } from "next/navigation";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { showToast } from "@calcom/ui";
import { useSubscribeTeamToStripe } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import { platformPlans } from "@components/settings/platform/platformUtils";
import { PlatformBillingCard } from "@components/settings/platform/pricing/billing-card";
type PlatformPricingProps = { teamId?: number | null };
export const PlatformPricing = ({ teamId }: PlatformPricingProps) => {
const router = useRouter();
const { mutateAsync, isPending } = useSubscribeTeamToStripe({
onSuccess: (redirectUrl: string) => {
router.push(redirectUrl);
},
onError: () => {
showToast(ErrorCode.UnableToSubscribeToThePlatform, "error");
},
teamId,
});
return (
<div className="flex h-auto flex-col items-center justify-center px-5 py-5 md:px-10 md:py-0 lg:h-[100vh]">
<div className="mb-5 text-center text-2xl font-semibold">
<h1>Subscribe to Platform</h1>
</div>
<div className="flex flex-col flex-wrap gap-x-0 gap-y-5 md:flex-row md:gap-x-5 md:gap-y-5 lg:flex-nowrap">
{platformPlans.map((plan) => {
return (
<div key={plan.plan}>
<PlatformBillingCard
plan={plan.plan}
description={plan.description}
pricing={plan.pricing}
includes={plan.includes}
isLoading={isPending}
handleSubscribe={() => {
!!teamId &&
(plan.plan === "Enterprise"
? router.push("https://i.cal.com/sales/exploration")
: mutateAsync({ plan: plan.plan.toLocaleUpperCase() }));
}}
/>
</div>
);
})}
</div>
</div>
);
};
@@ -3,6 +3,17 @@ import { useQuery } from "@tanstack/react-query";
import type { ApiSuccessResponse } from "@calcom/platform-types";
import type { PlatformOAuthClient } from "@calcom/prisma/client";
export type ManagedUser = {
id: number;
email: string;
username: string | null;
timeZone: string;
weekStart: string;
createdDate: Date;
timeFormat: number | null;
defaultScheduleId: number | null;
};
export const useOAuthClients = () => {
const query = useQuery<ApiSuccessResponse<PlatformOAuthClient[]>>({
queryKey: ["oauth-clients"],
@@ -52,3 +63,21 @@ export const useOAuthClient = (clientId?: string) => {
refetch,
};
};
export const useGetOAuthClientManagedUsers = (clientId: string) => {
const {
isLoading,
error,
data: response,
refetch,
} = useQuery<ApiSuccessResponse<ManagedUser[]>>({
queryKey: ["oauth-client-managed-users", clientId],
queryFn: () => {
return fetch(`/api/v2/oauth-clients/${clientId}/managed-users`, {
method: "get",
headers: { "Content-type": "application/json" },
}).then((res) => res.json());
},
});
return { isLoading, error, data: response?.data, refetch };
};
@@ -1,7 +1,12 @@
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, CreateOAuthClientInput, DeleteOAuthClientInput } from "@calcom/platform-types";
import type {
ApiResponse,
CreateOAuthClientInput,
DeleteOAuthClientInput,
SubscribeTeamInput,
} from "@calcom/platform-types";
import type { OAuthClient } from "@calcom/prisma/client";
interface IPersistOAuthClient {
@@ -113,3 +118,59 @@ export const useDeleteOAuthClient = (
return mutation;
};
export const useCheckTeamBilling = (teamId?: number | null) => {
const QUERY_KEY = "check-team-billing";
const isTeamBilledAlready = useQuery({
queryKey: [QUERY_KEY, teamId],
queryFn: async () => {
const response = await fetch(`/api/v2/billing/${teamId}/check`, {
method: "get",
headers: { "Content-type": "application/json" },
});
const data = await response.json();
return data.data;
},
enabled: !!teamId,
});
return isTeamBilledAlready;
};
export const useSubscribeTeamToStripe = (
{
onSuccess,
onError,
teamId,
}: { teamId?: number | null; onSuccess: (redirectUrl: string) => void; onError: () => void } = {
onSuccess: () => {
return;
},
onError: () => {
return;
},
}
) => {
const mutation = useMutation<ApiResponse<{ action: string; url: string }>, unknown, SubscribeTeamInput>({
mutationFn: (data) => {
return fetch(`/api/v2/billing/${teamId}/subscribe`, {
method: "post",
headers: { "Content-type": "application/json" },
body: JSON.stringify(data),
}).then((res) => res?.json());
},
onSuccess: (data) => {
if (data.status === SUCCESS_STATUS) {
onSuccess?.(data.data?.url);
} else {
onError?.();
}
},
onError: () => {
onError?.();
},
});
return mutation;
};
@@ -413,6 +413,10 @@ export default function Success(props: PageProps) {
imageSrc={`${bookingInfo.user.avatarUrl}`}
/>
)}
{giphyImage && !needsConfirmation && !isCancelled && (
// eslint-disable-next-line @next/next/no-img-element
<img src={giphyImage} className="w-full rounded-lg" alt="Gif from Giphy" />
)}
<div
className={classNames(
"mx-auto flex h-12 w-12 items-center justify-center rounded-full",
@@ -422,10 +426,6 @@ export default function Success(props: PageProps) {
!giphyImage && !isCancelled && needsConfirmation ? "bg-subtle" : "",
isCancelled ? "bg-error" : ""
)}>
{giphyImage && !needsConfirmation && !isCancelled && (
// eslint-disable-next-line @next/next/no-img-element
<img src={giphyImage} alt="Gif from Giphy" />
)}
{!giphyImage && !needsConfirmation && !isCancelled && (
<Icon name="check" className="h-5 w-5 text-green-600 dark:text-green-400" />
)}
@@ -1,6 +1,8 @@
import type { GetServerSidePropsContext } from "next";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { asStringOrThrow } from "@lib/asStringOrNull";
import type { inferSSRProps } from "@lib/types/inferSSRProps";
@@ -40,6 +42,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const { eventType } = await ssr.viewer.eventTypes.get.fetch({ id: eventTypeId });
return eventType;
} catch (e: unknown) {
logger.error(safeStringify(e));
// reject, user has no access to this event type.
return null;
}
@@ -265,6 +265,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
metadata,
hosts: eventType.hosts,
successRedirectUrl: eventType.successRedirectUrl || "",
forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect,
users: eventType.users,
useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail,
secondaryEmailId: eventType?.secondaryEmailId || -1,
@@ -104,11 +104,13 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
if (isDynamicGroup) {
const destinationUrl = `/${usernameList.join("+")}/dynamic`;
const originalQueryString = new URLSearchParams(context.query as Record<string, string>).toString();
const destinationWithQuery = `${destinationUrl}?${originalQueryString}`;
log.debug(`Dynamic group detected, redirecting to ${destinationUrl}`);
return {
redirect: {
permanent: false,
destination: destinationUrl,
destination: destinationWithQuery,
},
} as const;
}
@@ -1,13 +1,17 @@
import type { DehydratedState } from "@tanstack/react-query";
import { type GetServerSidePropsContext } from "next";
import type { Session } from "next-auth";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import type { getPublicEvent } from "@calcom/features/eventtypes/lib/getPublicEvent";
import { getUsernameList } from "@calcom/lib/defaultEvents";
import { UserRepository } from "@calcom/lib/server/repository/user";
import slugify from "@calcom/lib/slugify";
import prisma from "@calcom/prisma";
import { RedirectType } from "@calcom/prisma/client";
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
@@ -16,6 +20,74 @@ import { type EmbedProps } from "@lib/withEmbedSsr";
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
type Props = {
eventData: Pick<
NonNullable<Awaited<ReturnType<typeof getPublicEvent>>>,
"id" | "length" | "metadata" | "entity"
>;
booking?: GetBookingType;
rescheduleUid: string | null;
bookingUid: string | null;
user: string;
slug: string;
trpcState: DehydratedState;
isBrandingHidden: boolean;
isSEOIndexable: boolean | null;
themeBasis: null | string;
orgBannerUrl: null;
};
async function processReschedule({
props,
rescheduleUid,
session,
}: {
props: Props;
session: Session | null;
rescheduleUid: string | string[] | undefined;
}) {
if (!rescheduleUid) return;
const booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
// if no booking found, no eventTypeId (dynamic) or it matches this eventData - return void (success).
if (booking === null || !booking.eventTypeId || booking?.eventTypeId === props.eventData?.id) {
props.booking = booking;
props.rescheduleUid = Array.isArray(rescheduleUid) ? rescheduleUid[0] : rescheduleUid;
return;
}
// handle redirect response
const redirectEventTypeTarget = await prisma.eventType.findUnique({
where: {
id: booking.eventTypeId,
},
select: {
slug: true,
},
});
if (!redirectEventTypeTarget) {
return {
notFound: true,
} as const;
}
return {
redirect: {
permanent: false,
destination: redirectEventTypeTarget.slug,
},
};
}
async function processSeatedEvent({
props,
bookingUid,
}: {
props: Props;
bookingUid: string | string[] | undefined;
}) {
if (!bookingUid) return;
props.booking = await getBookingForSeatedEvent(`${bookingUid}`);
props.bookingUid = Array.isArray(bookingUid) ? bookingUid[0] : bookingUid;
}
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
const session = await getServerSession(context);
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
@@ -51,13 +123,6 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
} as const;
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
} else if (bookingUid) {
booking = await getBookingForSeatedEvent(`${bookingUid}`);
}
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we c an show a 404 otherwise.
const eventData = await ssr.viewer.public.event.fetch({
@@ -73,27 +138,38 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
} as const;
}
return {
props: {
eventData: {
entity: eventData.entity,
length: eventData.length,
metadata: {
...eventData.metadata,
multipleDuration: [15, 30, 60],
},
const props: Props = {
eventData: {
id: eventData.id,
entity: eventData.entity,
length: eventData.length,
metadata: {
...eventData.metadata,
multipleDuration: [15, 30, 60],
},
booking,
user: usernames.join("+"),
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: false,
isSEOIndexable: true,
themeBasis: null,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
orgBannerUrl: null,
},
user: usernames.join("+"),
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: false,
isSEOIndexable: true,
themeBasis: null,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: null,
orgBannerUrl: null,
};
if (rescheduleUid) {
const processRescheduleResult = await processReschedule({ props, rescheduleUid, session });
if (processRescheduleResult) {
return processRescheduleResult;
}
} else if (bookingUid) {
await processSeatedEvent({ props, bookingUid });
}
return {
props,
};
}
@@ -131,13 +207,6 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
} as const;
}
let booking: GetBookingType | null = null;
if (rescheduleUid) {
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
} else if (bookingUid) {
booking = await getBookingForSeatedEvent(`${bookingUid}`);
}
const org = isValidOrgDomain ? currentOrgDomain : null;
// We use this to both prefetch the query on the server,
// as well as to check if the event exist, so we can show a 404 otherwise.
@@ -154,24 +223,35 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
} as const;
}
return {
props: {
booking,
eventData: {
entity: eventData.entity,
length: eventData.length,
metadata: eventData.metadata,
},
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
isSEOIndexable: user?.allowSEOIndexing,
themeBasis: username,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
orgBannerUrl: eventData?.owner?.profile?.organization?.bannerUrl ?? null,
const props: Props = {
eventData: {
id: eventData.id,
entity: eventData.entity,
length: eventData.length,
metadata: eventData.metadata,
},
user: username,
slug,
trpcState: ssr.dehydrate(),
isBrandingHidden: user?.hideBranding,
isSEOIndexable: user?.allowSEOIndexing,
themeBasis: username,
bookingUid: bookingUid ? `${bookingUid}` : null,
rescheduleUid: null,
orgBannerUrl: eventData?.owner?.profile?.organization?.bannerUrl ?? null,
};
if (rescheduleUid) {
const processRescheduleResult = await processReschedule({ props, rescheduleUid, session });
if (processRescheduleResult) {
return processRescheduleResult;
}
} else if (bookingUid) {
await processSeatedEvent({ props, bookingUid });
}
return {
props,
};
}
@@ -50,7 +50,7 @@ export default function JoinCall(props: PageProps) {
customTrayButtons: {
transcription: {
label: "Cal.ai",
tooltip: "Enable transcription powered by AI",
tooltip: "Toggle real time transcription powered by AI",
iconPath: `${WEBAPP_URL}/sparkles.svg`,
iconPathDarkMode: `${WEBAPP_URL}/sparkles.svg`,
},
@@ -101,12 +101,12 @@ export default function JoinCall(props: PageProps) {
/>
) : (
<img
className="fixed z-10 hidden sm:inline-block"
className="fixed z-10 inline-block h-4"
src={`${WEBSITE_URL}/cal-logo-word-dark.svg`}
alt="Logo"
style={{
top: 32,
left: 32,
top: 9,
left: "calc(50% - 45px)",
}}
/>
)}
+5
View File
@@ -537,6 +537,11 @@ const nextConfig = {
destination: "/apps/installed/calendar",
permanent: true,
},
{
source: "/settings/organizations/platform/:path*",
destination: "/settings/platform",
permanent: true,
},
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
...(process.env.NODE_ENV === "development" &&
// Safer to enable the redirect only when the user is opting to test out organizations
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "4.0.5",
"version": "4.0.9",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -65,6 +65,7 @@
"@tremor/react": "^2.0.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.21.0",
"@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1",
"@vercel/og": "^0.5.0",
+1 -1
View File
@@ -88,7 +88,7 @@ class MyDocument extends Document<Props> {
</Head>
<body
className="dark:bg-darkgray-50 todesktop:!bg-transparent bg-subtle antialiased"
className="dark:bg-darkgray-50 bg-subtle antialiased"
style={
isEmbed
? {
+9 -34
View File
@@ -110,43 +110,18 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
teamId: null,
},
],
createdAt: {
gte: dayjs(firstDateOfMonth).toISOString(),
lte: dayjs(new Date()).toISOString(),
},
};
const promisesResult = await Promise.all([
EventsInsights.getCreatedEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
EventsInsights.getCompletedEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
EventsInsights.getRescheduledEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
EventsInsights.getCancelledEventsInTimeRange(
{
start: dayjs(firstDateOfMonth),
end: dayjs(new Date()),
},
whereConditional
),
]);
const countGroupedByStatus = await EventsInsights.countGroupedByStatus(whereConditional);
EventData["Created"] = promisesResult[0];
EventData["Completed"] = promisesResult[1];
EventData["Rescheduled"] = promisesResult[2];
EventData["Cancelled"] = promisesResult[3];
EventData["Created"] = countGroupedByStatus["_all"];
EventData["Completed"] = countGroupedByStatus["completed"];
EventData["Rescheduled"] = countGroupedByStatus["rescheduled"];
EventData["Cancelled"] = countGroupedByStatus["cancelled"];
// Most Booked Event Type
const bookingWhere: Prisma.BookingTimeStatusWhereInput = {
+1 -1
View File
@@ -94,8 +94,8 @@ export default function InsightsPage() {
<MostBookedTeamMembersTable />
<LeastBookedTeamMembersTable />
</div>
<RecentFeedbackTable />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<RecentFeedbackTable />
<HighestNoShowHostTable />
<HighestRatedMembersTable />
<LowestRatedMembersTable />
@@ -1,8 +1,10 @@
"use client";
import { signIn } from "next-auth/react";
import { useRef } from "react";
import { useSearchParams } from "next/navigation";
import { useRef, useEffect } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Meta, TextField } from "@calcom/ui";
@@ -12,6 +14,19 @@ import { getLayout } from "@components/auth/layouts/AdminLayout";
function AdminView() {
const { t } = useLocale();
const usernameRef = useRef<HTMLInputElement>(null);
const searchParams = useSearchParams();
const username = searchParams?.get("username")?.toLowerCase();
useEffect(() => {
if (username) {
const enteredUsername = username.toLowerCase();
signIn("impersonation-auth", {
username: enteredUsername,
callbackUrl: `${WEBAPP_URL}/event-types`,
});
}
}, [username]);
return (
<>
@@ -21,7 +36,10 @@ function AdminView() {
onSubmit={(e) => {
e.preventDefault();
const enteredUsername = usernameRef.current?.value.toLowerCase();
signIn("impersonation-auth", { username: enteredUsername });
signIn("impersonation-auth", {
username: enteredUsername,
callbackUrl: `${WEBAPP_URL}/event-types`,
});
}}>
<div className="flex items-center space-x-2 rtl:space-x-reverse">
<TextField
+10 -4
View File
@@ -124,6 +124,15 @@ const ProfileView = () => {
}
},
});
const unlinkConnectedAccountMutation = trpc.viewer.unlinkConnectedAccount.useMutation({
onSuccess: async (res) => {
showToast(t(res.message), "success");
utils.viewer.me.invalidate();
},
onError: (e) => {
showToast(t(e.message), "error");
},
});
const addSecondaryEmailMutation = trpc.viewer.addSecondaryEmail.useMutation({
onSuccess: (res) => {
@@ -439,10 +448,7 @@ const ProfileView = () => {
<Button
color="primary"
onClick={() => {
updateProfileMutation.mutate({
...tempFormValues,
unlinkConnectedAccount: true,
});
unlinkConnectedAccountMutation.mutate();
setShowAccountDisconnectWarning(false);
}}>
{t("confirm")}
@@ -0,0 +1,9 @@
import AdminAPIView from "@calcom/features/ee/organizations/pages/settings/admin-api";
import type { CalPageWrapper } from "@components/PageWrapper";
import PageWrapper from "@components/PageWrapper";
const Page = AdminAPIView as CalPageWrapper;
Page.PageWrapper = PageWrapper;
export default Page;
@@ -1,22 +0,0 @@
import React from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import PageWrapper from "@components/PageWrapper";
import { OAuthClientForm } from "@components/settings/organizations/platform/oauth-clients/OAuthClientForm";
export const CreateOAuthClient = () => {
const searchParams = useCompatSearchParams();
const clientId = searchParams?.get("clientId") || "";
return (
<div>
<OAuthClientForm clientId={clientId} />
</div>
);
};
CreateOAuthClient.getLayout = getLayout;
CreateOAuthClient.PageWrapper = PageWrapper;
export default CreateOAuthClient;
@@ -1,103 +0,0 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import React from "react";
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { EmptyScreen, showToast } from "@calcom/ui";
import { Meta, Button } from "@calcom/ui";
import { Spinner } from "@calcom/ui/components/icon/Spinner";
import { useOAuthClients } from "@lib/hooks/settings/organizations/platform/oauth-clients/useOAuthClients";
import { useDeleteOAuthClient } from "@lib/hooks/settings/organizations/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { OAuthClientCard } from "@components/settings/organizations/platform/oauth-clients/OAuthClientCard";
const queryClient = new QueryClient();
export const OAuthClients = () => {
const { data, isLoading, refetch: refetchClients } = useOAuthClients();
const { mutateAsync, isPending: isDeleting } = useDeleteOAuthClient({
onSuccess: () => {
showToast("OAuth client deleted successfully", "success");
refetchClients();
},
});
const handleDelete = async (id: string) => {
await mutateAsync({ id: id });
};
const NewOAuthClientButton = () => {
const router = useRouter();
return (
<Button
onClick={(e) => {
e.preventDefault();
router.push("/settings/organizations/platform/oauth-clients/create");
}}
color="secondary"
StartIcon="plus">
Add
</Button>
);
};
if (isLoading) {
return <Spinner className="mx-auto mt-12 h-10 w-10" />;
}
return (
<QueryClientProvider client={queryClient}>
<div>
<Meta
title="OAuth Clients"
description="Connect your platform to cal.com with oAuth"
CTA={<NewOAuthClientButton />}
borderInShellHeader={true}
/>
<div>
{Array.isArray(data) && data.length ? (
<>
<div className="border-subtle rounded-b-lg border border-t-0">
{data.map((client, index) => {
return (
<OAuthClientCard
name={client.name}
redirectUris={client.redirectUris}
bookingRedirectUri={client.bookingRedirectUri}
bookingRescheduleRedirectUri={client.bookingRescheduleRedirectUri}
bookingCancelRedirectUri={client.bookingCancelRedirectUri}
permissions={client.permissions}
key={index}
lastItem={data.length === index + 1}
id={client.id}
secret={client.secret}
isLoading={isDeleting}
onDelete={handleDelete}
areEmailsEnabled={client.areEmailsEnabled}
/>
);
})}
</div>
</>
) : (
<EmptyScreen
headline="Create your first OAuth client"
description="OAuth clients facilitate access to Cal.com on behalf of users"
Icon="plus"
className="rounded-b-lg rounded-t-none border-t-0"
buttonRaw={<NewOAuthClientButton />}
/>
)}
</div>
</div>
</QueryClientProvider>
);
};
OAuthClients.getLayout = getLayout;
OAuthClients.PageWrapper = PageWrapper;
export default OAuthClients;
+105
View File
@@ -0,0 +1,105 @@
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import Shell from "@calcom/features/shell/Shell";
import { showToast } from "@calcom/ui";
import {
useOAuthClients,
useGetOAuthClientManagedUsers,
} from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
import { useDeleteOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { ManagedUserList } from "@components/settings/platform/dashboard/managed-user-list";
import { OAuthClientsList } from "@components/settings/platform/dashboard/oauth-clients-list";
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
import { PlatformPricing } from "@components/settings/platform/pricing/platform-pricing";
const queryClient = new QueryClient();
export default function Platform() {
const [initialClientId, setInitialClientId] = useState("");
const [initialClientName, setInitialClientName] = useState("");
const { data, isLoading: isOAuthClientLoading, refetch: refetchClients } = useOAuthClients();
const {
isLoading: isManagedUserLoading,
data: managedUserData,
refetch: refetchManagedUsers,
} = useGetOAuthClientManagedUsers(initialClientId);
const { isUserLoading, isUserBillingDataLoading, isPlatformUser, isPaidUser, userBillingData, userOrgId } =
useGetUserAttributes();
const { mutateAsync, isPending: isDeleting } = useDeleteOAuthClient({
onSuccess: () => {
showToast("OAuth client deleted successfully", "success");
refetchClients();
refetchManagedUsers();
},
});
const handleDelete = async (id: string) => {
await mutateAsync({ id: id });
};
useEffect(() => {
setInitialClientId(data[0]?.id);
setInitialClientName(data[0]?.name);
}, [data]);
if (isUserLoading || isOAuthClientLoading) return <div className="m-5">Loading...</div>;
if (isUserBillingDataLoading && !userBillingData) {
return <div className="m-5">Loading...</div>;
}
if (isPlatformUser && !isPaidUser) return <PlatformPricing teamId={userOrgId} />;
if (isPlatformUser) {
return (
<QueryClientProvider client={queryClient}>
<div>
<Shell
heading="Platform"
title="Platform"
hideHeadingOnMobile
withoutMain={false}
subtitle="Manage everything related to platform."
isPlatformUser={true}>
<OAuthClientsList oauthClients={data} isDeleting={isDeleting} handleDelete={handleDelete} />
<ManagedUserList
oauthClients={data}
managedUsers={managedUserData}
isManagedUserLoading={isManagedUserLoading}
initialClientName={initialClientName}
initialClientId={initialClientId}
handleChange={(id: string, name: string) => {
setInitialClientId(id);
setInitialClientName(name);
refetchManagedUsers();
}}
/>
</Shell>
</div>
</QueryClientProvider>
);
}
return (
<div>
<Shell
// we want to hide org banner and have different sidebar tabs for platform clients
// hence we pass isPlatformUser boolean as prop
isPlatformUser={true}
hideHeadingOnMobile
withoutMain={false}
SidebarContainer={<></>}>
You are not subscribed to a Platform plan.
</Shell>
</div>
);
}
Platform.PageWrapper = PageWrapper;
@@ -0,0 +1,45 @@
"use client";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { CreateANewPlatformForm } from "@calcom/features/ee/platform/components/index";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { WizardLayout, Meta, WizardLayoutAppDir } from "@calcom/ui";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
const CreateNewOrganizationPage = () => {
const { t } = useLocale();
return (
<LicenseRequired>
<Meta
title={t("set_up_your_platform_organization")}
description={t("platform_organization_description")}
/>
<CreateANewPlatformForm />
</LicenseRequired>
);
};
const LayoutWrapper = (page: React.ReactElement) => {
return (
<WizardLayout currentStep={1} maxSteps={1}>
{page}
</WizardLayout>
);
};
export const LayoutWrapperAppDir = (page: React.ReactElement) => {
return (
<WizardLayoutAppDir currentStep={1} maxSteps={2}>
{page}
</WizardLayoutAppDir>
);
};
CreateNewOrganizationPage.getLayout = LayoutWrapper;
CreateNewOrganizationPage.PageWrapper = PageWrapper;
export default CreateNewOrganizationPage;
export { getServerSideProps };
@@ -0,0 +1,133 @@
import { useParams } from "next/navigation";
import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants";
import { showToast } from "@calcom/ui";
import { useOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/useOAuthClients";
import { useUpdateOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
import type { FormValues } from "@components/settings/platform/oauth-clients/oauth-client-form";
import { OAuthClientForm as EditOAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
import {
hasAppsReadPermission,
hasAppsWritePermission,
hasBookingReadPermission,
hasBookingWritePermission,
hasEventTypeReadPermission,
hasEventTypeWritePermission,
hasProfileReadPermission,
hasProfileWritePermission,
hasScheduleReadPermission,
hasScheduleWritePermission,
} from "../../../../../../../../packages/platform/utils/permissions";
export default function EditOAuthClient() {
const router = useRouter();
const params = useParams<{ clientId: string }>();
const clientId = params?.clientId || "";
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
const { data, isFetched, isFetching, isError, refetch } = useOAuthClient(clientId);
const { mutateAsync: update, isPending: isUpdating } = useUpdateOAuthClient({
onSuccess: () => {
showToast("OAuth client updated successfully", "success");
refetch();
router.push("/settings/platform/");
},
onError: () => {
showToast(ErrorCode.UpdatingOauthClientError, "error");
},
clientId,
});
const onSubmit = (data: FormValues) => {
let userPermissions = 0;
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
const read = PERMISSIONS_GROUPED_MAP[entity].read;
const write = PERMISSIONS_GROUPED_MAP[entity].write;
if (data[`${entityKey}Read`]) userPermissions |= read;
if (data[`${entityKey}Write`]) userPermissions |= write;
});
update({
name: data.name,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
};
if (isUserLoading) return <div className="m-5">Loading...</div>;
if (isPlatformUser && isPaidUser) {
return (
<div>
<Shell title="OAuth client updation form" isPlatformUser={true}>
<div className="m-2 md:mx-14 md:mx-5">
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
OAuth client updation form
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
This is the form to edit an existing OAuth client
</p>
</div>
</div>
{(!Boolean(clientId) || (isFetched && !data)) && <p>OAuth Client not found.</p>}
{isFetched && !!data && (
<EditOAuthClientForm
defaultValues={{
name: data?.name ?? "",
areEmailsEnabled: data.areEmailsEnabled ?? false,
redirectUris: data?.redirectUris?.map((uri) => ({ uri })) ?? [{ uri: "" }],
bookingRedirectUri: data?.bookingRedirectUri ?? "",
bookingCancelRedirectUri: data?.bookingCancelRedirectUri ?? "",
bookingRescheduleRedirectUri: data?.bookingRescheduleRedirectUri ?? "",
appsRead: hasAppsReadPermission(data?.permissions),
appsWrite: hasAppsWritePermission(data?.permissions),
bookingRead: hasBookingReadPermission(data?.permissions),
bookingWrite: hasBookingWritePermission(data?.permissions),
eventTypeRead: hasEventTypeReadPermission(data?.permissions),
eventTypeWrite: hasEventTypeWritePermission(data?.permissions),
profileRead: hasProfileReadPermission(data?.permissions),
profileWrite: hasProfileWritePermission(data?.permissions),
scheduleRead: hasScheduleReadPermission(data?.permissions),
scheduleWrite: hasScheduleWritePermission(data?.permissions),
}}
onSubmit={onSubmit}
isPending={isUpdating}
/>
)}
{isFetching && <p>Loading...</p>}
{isError && <p>Something went wrong.</p>}
</div>
</Shell>
</div>
);
}
return (
<div>
<Shell isPlatformUser={true} hideHeadingOnMobile withoutMain={false} SidebarContainer={<></>}>
You are not subscribed to a Platform plan.
</Shell>
</div>
);
}
EditOAuthClient.PageWrapper = PageWrapper;
@@ -0,0 +1,91 @@
import { useRouter } from "next/navigation";
import Shell from "@calcom/features/shell/Shell";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { PERMISSIONS_GROUPED_MAP } from "@calcom/platform-constants/permissions";
import { showToast } from "@calcom/ui";
import { useCreateOAuthClient } from "@lib/hooks/settings/platform/oauth-clients/usePersistOAuthClient";
import PageWrapper from "@components/PageWrapper";
import { useGetUserAttributes } from "@components/settings/platform/hooks/useGetUserAttributes";
import type { FormValues } from "@components/settings/platform/oauth-clients/oauth-client-form";
import { OAuthClientForm } from "@components/settings/platform/oauth-clients/oauth-client-form";
export default function CreateOAuthClient() {
const searchParams = useCompatSearchParams();
const router = useRouter();
const clientId = searchParams?.get("clientId") || "";
const { isUserLoading, isPlatformUser, isPaidUser } = useGetUserAttributes();
const { mutateAsync: save, isPending: isSaving } = useCreateOAuthClient({
onSuccess: () => {
showToast("OAuth client created successfully", "success");
router.push("/settings/platform/");
},
onError: () => {
showToast(ErrorCode.CreatingOauthClientError, "error");
},
});
const onSubmit = (data: FormValues) => {
let userPermissions = 0;
const userRedirectUris = data.redirectUris.map((uri) => uri.uri).filter((uri) => !!uri);
Object.keys(PERMISSIONS_GROUPED_MAP).forEach((key) => {
const entity = key as keyof typeof PERMISSIONS_GROUPED_MAP;
const entityKey = PERMISSIONS_GROUPED_MAP[entity].key;
const read = PERMISSIONS_GROUPED_MAP[entity].read;
const write = PERMISSIONS_GROUPED_MAP[entity].write;
if (data[`${entityKey}Read`]) userPermissions |= read;
if (data[`${entityKey}Write`]) userPermissions |= write;
});
save({
name: data.name,
permissions: userPermissions,
// logo: data.logo,
redirectUris: userRedirectUris,
bookingRedirectUri: data.bookingRedirectUri,
bookingCancelRedirectUri: data.bookingCancelRedirectUri,
bookingRescheduleRedirectUri: data.bookingRescheduleRedirectUri,
areEmailsEnabled: data.areEmailsEnabled,
});
};
if (isUserLoading) return <div className="m-5">Loading...</div>;
if (isPlatformUser && isPaidUser) {
return (
<div>
<Shell title={`OAuth client ${!!clientId ? "updation" : "creation"} form`} isPlatformUser={true}>
<div className="m-2 md:mx-14 md:mx-5">
<div className="border-subtle mx-auto block justify-between rounded-t-lg border px-4 py-6 sm:flex sm:px-6">
<div className="flex w-full flex-col">
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
OAuth client creation form
</h1>
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">
This is the form to create a new OAuth client
</p>
</div>
</div>
<OAuthClientForm isPending={isSaving} onSubmit={onSubmit} />
</div>
</Shell>
</div>
);
}
return (
<div>
<Shell isPlatformUser={true} hideHeadingOnMobile withoutMain={false} SidebarContainer={<></>}>
You are not subscribed to a Platform plan.
</Shell>
</div>
);
}
CreateOAuthClient.PageWrapper = PageWrapper;
+3
View File
@@ -22,6 +22,9 @@ test.describe("Availablity", () => {
test("Date Overrides", async ({ page }) => {
await page.getByTestId("schedules").first().click();
await page.locator('[data-testid="Sunday-switch"]').first().click();
await page.locator('[data-testid="Saturday-switch"]').first().click();
await page.getByTestId("add-override").click();
await page.locator('[id="modal-title"]').waitFor();
await page.getByTestId("incrementMonth").click();
+15
View File
@@ -126,6 +126,21 @@ testBothFutureAndLegacyRoutes.describe("pro user", () => {
});
});
test("it redirects when a rescheduleUid does not match the current event type", async ({
page,
users,
bookings,
}) => {
const [pro] = users.get();
const [eventType] = pro.eventTypes;
const bookingFixture = await bookings.create(pro.id, pro.username, eventType.id);
// open the wrong eventType (rescheduleUid created for /30min event)
await page.goto(`${pro.username}/${pro.eventTypes[1].slug}?rescheduleUid=${bookingFixture.uid}`);
await expect(page).toHaveURL(new RegExp(`${pro.username}/${eventType.slug}`));
});
test("Can cancel the recently created booking and rebook the same timeslot", async ({
page,
users,
@@ -62,6 +62,36 @@ test("dynamic booking", async ({ page, users }) => {
});
});
test("dynamic booking info prefilled by query params", async ({ page, users }) => {
const pro = await users.create();
await pro.apiLogin();
let duration = 15;
const free = await users.create({ username: "free.example" });
await page.goto(`/${pro.username}+${free.username}?duration=${duration}`);
await page.waitForLoadState("networkidle");
const badgeByDurationTestId = (duration: number) => `multiple-choice-${duration}mins`;
let badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
let activeState = await badgeLocator.getAttribute("data-active");
expect(activeState).toEqual("true");
duration = 30;
await page.goto(`/${pro.username}+${free.username}?duration=${duration}`);
badgeLocator = await page.getByTestId(badgeByDurationTestId(duration));
activeState = await badgeLocator.getAttribute("data-active");
expect(activeState).toEqual("true");
// Check another badge just to ensure its not selected
badgeLocator = await page.getByTestId(badgeByDurationTestId(15));
activeState = await badgeLocator.getAttribute("data-active");
expect(activeState).toEqual("false");
});
test.describe("Organization:", () => {
test.afterEach(({ orgs, users }) => {
orgs.deleteAll();
+2 -1
View File
@@ -29,7 +29,8 @@ test.describe("Users can impersonate", async () => {
await page.getByTestId("impersonation-submit").click();
// // Wait for sign in to complete
await page.waitForURL("/settings/my-account/profile");
await page.waitForURL("/event-types");
await page.goto("/settings/profile");
const stopImpersonatingButton = page.getByTestId("stop-impersonating-button");
@@ -2,7 +2,6 @@ import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { getOrgUsernameFromEmail } from "@calcom/features/auth/signup/utils/getOrgUsernameFromEmail";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
import { test } from "../lib/fixtures";
@@ -14,6 +13,7 @@ import {
testName,
} from "../lib/testUtils";
import { expectExistingUserToBeInvitedToOrganization } from "../team/expects";
import { gotoPathAndExpectRedirectToOrgDomain } from "./lib/gotoPathAndExpectRedirectToOrgDomain";
import { acceptTeamOrOrgInvite, inviteExistingUserToOrganization } from "./lib/inviteUser";
test.describe("Bookings", () => {
@@ -380,11 +380,11 @@ test.describe("Bookings", () => {
await test.step("Booking through old link redirects to new link on org domain", async () => {
const event = await userOutsideOrganization.getFirstEventAsOwner();
await expectRedirectToOrgDomain({
await gotoPathAndExpectRedirectToOrgDomain({
page,
org,
eventSlug: `/${usernameOutsideOrg}/${event.slug}`,
expectedEventSlug: `/${usernameInOrg}/${event.slug}`,
path: `/${usernameOutsideOrg}/${event.slug}`,
expectedPath: `/${usernameInOrg}/${event.slug}`,
});
// As the redirection correctly happens, the booking would work too which we have verified in previous step. But we can't test that with org domain as that domain doesn't exist.
});
@@ -463,39 +463,3 @@ async function expectPageToBeNotFound({ page, url }: { page: Page; url: string }
await page.goto(`${url}`);
await expect(page.locator(`text=${NotFoundPageTextAppDir}`)).toBeVisible();
}
async function expectRedirectToOrgDomain({
page,
org,
eventSlug,
expectedEventSlug,
}: {
page: Page;
org: { slug: string | null };
eventSlug: string;
expectedEventSlug: string;
}) {
if (!org.slug) {
throw new Error("Org slug is not defined");
}
page.goto(eventSlug).catch((e) => {
console.log("Expected navigation error to happen");
});
const orgSlug = org.slug;
const orgRedirectUrl = await new Promise(async (resolve) => {
page.on("request", (request) => {
if (request.isNavigationRequest()) {
const requestedUrl = request.url();
console.log("Requested navigation to", requestedUrl);
// Resolve on redirection to org domain
if (requestedUrl.includes(orgSlug)) {
resolve(requestedUrl);
}
}
});
});
expect(orgRedirectUrl).toContain(`${getOrgFullOrigin(org.slug)}${expectedEventSlug}`);
}
@@ -0,0 +1,40 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
export async function gotoPathAndExpectRedirectToOrgDomain({
page,
org,
path,
expectedPath,
}: {
page: Page;
org: { slug: string | null };
path: string;
expectedPath: string;
}) {
if (!org.slug) {
throw new Error("Org slug is not defined");
}
page.goto(path).catch((e) => {
console.log("Expected navigation error to happen");
});
const orgSlug = org.slug;
const orgRedirectUrl = await new Promise(async (resolve) => {
page.on("request", (request) => {
if (request.isNavigationRequest()) {
const requestedUrl = request.url();
console.log("Requested navigation to", requestedUrl);
// Resolve on redirection to org domain
if (requestedUrl.includes(orgSlug)) {
resolve(requestedUrl);
}
}
});
});
expect(orgRedirectUrl).toContain(`${getOrgFullOrigin(org.slug)}${expectedPath}`);
}
@@ -1,11 +1,94 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { JSDOM } from "jsdom";
import type { Messages } from "mailhog";
import path from "path";
import { uuid } from "short-uuid";
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
import type { createEmailsFixture } from "../fixtures/emails";
import { test } from "../lib/fixtures";
import { fillStripeTestCheckout } from "../lib/testUtils";
import { getEmailsReceivedByUser } from "../lib/testUtils";
import { gotoPathAndExpectRedirectToOrgDomain } from "./lib/gotoPathAndExpectRedirectToOrgDomain";
async function expectEmailWithSubject(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string,
subject: string
) {
if (!emails) return null;
// eslint-disable-next-line playwright/no-wait-for-timeout
await page.waitForTimeout(2000);
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
const allEmails = (receivedEmails as Messages).items;
const email = allEmails.find((email) => email.subject === subject);
if (!email) {
throw new Error(`Email with subject ${subject} not found`);
}
const dom = new JSDOM(email.html);
return dom;
}
export async function expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
orgSlug: string;
}) {
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
const document = dom?.window?.document;
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
return dom;
}
async function expectOrganizationCreationEmailToBeSentWithLinks({
page,
emails,
userEmail,
oldUsername,
newUsername,
orgSlug,
}: {
page: Page;
emails: ReturnType<typeof createEmailsFixture>;
userEmail: string;
oldUsername: string;
newUsername: string;
orgSlug: string;
}) {
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail,
orgSlug,
});
const document = dom?.window.document;
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
if (!links) {
throw new Error(`data-testid="organization-link-info doesn't have links`);
}
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
}
export async function expectEmailVerificationEmailToBeSent(
page: Page,
emails: ReturnType<typeof createEmailsFixture>,
userEmail: string
) {
const subject = "Cal.com: Verify your account";
return expectEmailWithSubject(page, emails, userEmail, subject);
}
test.afterAll(({ users, orgs }) => {
users.deleteAll();
@@ -20,26 +103,33 @@ function capitalize(text: string) {
}
test.describe("Organization", () => {
test("Admin should be able to create an org for a target user", async ({ page, users, emails }) => {
test("Admin should be able to create an org where an existing user is made an owner", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const stringUUID = uuid();
const orgOwnerUsername = `owner-${stringUUID}`;
const orgOwnerUsernamePrefix = "owner";
const targetOrgEmail = users.trackEmail({
username: orgOwnerUsername,
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsernamePrefix,
domain: `example.com`,
});
const orgOwnerUser = await users.create({
username: orgOwnerUsername,
email: targetOrgEmail,
username: orgOwnerUsernamePrefix,
email: orgOwnerEmail,
role: "ADMIN",
});
const orgName = capitalize(`${orgOwnerUsername}`);
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
const orgName = capitalize(`${orgOwnerUser.username}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
await page.goto("/settings/organizations/new");
await page.waitForLoadState("networkidle");
@@ -49,17 +139,16 @@ test.describe("Organization", () => {
await expect(page.locator(".text-red-700")).toHaveCount(3);
// Happy path
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
// Since we are admin fill in this infomation instead of deriving it
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgOwnerUsername);
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
// Fill in seat infomation
await page.locator("input[name=seats]").fill("30");
await page.locator("input[name=pricePerSeat]").fill("30");
await page.locator("button[type=submit]").click();
await page.waitForLoadState("networkidle");
await expectOrganizationCreationEmailToBeSentWithLinks({
page,
emails,
userEmail: orgOwnerEmail,
oldUsername: orgOwnerUsernameOutsideOrg || "",
newUsername: orgOwnerUsernameInOrg,
orgSlug,
});
await test.step("About the organization", async () => {
@@ -149,6 +238,56 @@ test.describe("Organization", () => {
await expect(upgradeButtonHidden).toBeHidden();
});
// Verify that the owner's old username redirect is properly set
await gotoPathAndExpectRedirectToOrgDomain({
page,
org: {
slug: orgSlug,
},
path: `/${orgOwnerUsernameOutsideOrg}`,
expectedPath: `/${orgOwnerUsernameInOrg}`,
});
});
test("Admin should be able to create an org where the owner doesn't exist yet", async ({
page,
users,
emails,
}) => {
const appLevelAdmin = await users.create({
role: "ADMIN",
});
await appLevelAdmin.apiLogin();
const orgOwnerUsername = `owner`;
const orgName = capitalize(`${orgOwnerUsername}`);
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
const orgOwnerEmail = users.trackEmail({
username: orgOwnerUsername,
domain: `example.com`,
});
await page.goto("/settings/organizations/new");
await page.waitForLoadState("networkidle");
await test.step("Basic info", async () => {
// Check required fields
await page.locator("button[type=submit]").click();
await expect(page.locator(".text-red-700")).toHaveCount(3);
// Happy path
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
});
const dom = await expectOrganizationCreationEmailToBeSent({
page,
emails,
userEmail: orgOwnerEmail,
orgSlug,
});
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
});
test("User can create and upgrade a org", async ({ page, users, emails }) => {
@@ -426,3 +565,24 @@ test.describe("Organization", () => {
});
});
});
async function fillAndSubmitFirstStepAsAdmin(
page: Page,
targetOrgEmail: string,
orgName: string,
orgSlug: string
) {
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
// Since we are admin fill in this infomation instead of deriving it
await page.locator("input[name=name]").fill(orgName);
await page.locator("input[name=slug]").fill(orgSlug);
// Fill in seat infomation
await page.locator("input[name=seats]").fill("30");
await page.locator("input[name=pricePerSeat]").fill("30");
await Promise.all([
page.waitForResponse("**/api/trpc/organizations/create**"),
page.locator("button[type=submit]").click(),
]);
}
+132 -2
View File
@@ -2,6 +2,7 @@ import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { v4 as uuidv4 } from "uuid";
import dayjs from "@calcom/dayjs";
import prisma from "@calcom/prisma";
import { BookingStatus } from "@calcom/prisma/client";
@@ -427,7 +428,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
await expect(page.getByTestId("success-page")).toBeVisible();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: booking?.uid } })!;
@@ -494,7 +495,7 @@ test.describe("BOOKING_RESCHEDULED", async () => {
await page.locator('[data-testid="confirm-reschedule-button"]').click();
await expect(page).toHaveURL(/.*booking/);
await expect(page.getByTestId("success-page")).toBeVisible();
const newBooking = await prisma.booking.findFirst({
where: {
@@ -543,6 +544,131 @@ test.describe("BOOKING_RESCHEDULED", async () => {
});
});
test.describe("MEETING_ENDED, MEETING_STARTED", async () => {
test("should create/remove scheduledWebhookTriggers for existing bookings", async ({
page,
users,
bookings,
}, _testInfo) => {
const user = await users.create();
await user.apiLogin();
const tomorrow = dayjs().add(1, "day");
const [eventType] = user.eventTypes;
bookings.create(user.id, user.name, eventType.id);
bookings.create(user.id, user.name, eventType.id, { startTime: dayjs().add(2, "day").toDate() });
//create a new webhook with meeting ended trigger here
await page.goto("/settings/developer/webhooks");
// --- add webhook
await page.click('[data-testid="new_webhook"]');
await page.fill('[name="subscriberUrl"]', "https://www.example.com");
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
const scheduledTriggers = await prisma.webhookScheduledTriggers.findMany({
where: {
webhook: {
userId: user.id,
},
},
select: {
payload: true,
webhook: {
select: {
userId: true,
id: true,
subscriberUrl: true,
},
},
startAfter: true,
},
});
const existingUserBookings = await prisma.booking.findMany({
where: {
userId: user.id,
startTime: {
gt: new Date(),
},
},
});
const meetingStartedTriggers = scheduledTriggers.filter((trigger) =>
trigger.payload.includes("MEETING_STARTED")
);
const meetingEndedTriggers = scheduledTriggers.filter((trigger) =>
trigger.payload.includes("MEETING_ENDED")
);
expect(meetingStartedTriggers.length).toBe(existingUserBookings.length);
expect(meetingEndedTriggers.length).toBe(existingUserBookings.length);
expect(meetingStartedTriggers.map((trigger) => trigger.startAfter)).toEqual(
expect.arrayContaining(existingUserBookings.map((booking) => booking.startTime))
);
expect(meetingEndedTriggers.map((trigger) => trigger.startAfter)).toEqual(
expect.arrayContaining(existingUserBookings.map((booking) => booking.endTime))
);
page.reload();
// edit webhook and remove trigger meeting ended trigger
await page.click('[data-testid="webhook-edit-button"]');
await page.getByRole("button", { name: "Remove Meeting Ended" }).click();
await Promise.all([
page.click("[type=submit]"),
page.waitForURL((url) => url.pathname.endsWith("/settings/developer/webhooks")),
]);
const scheduledTriggersAfterRemovingTrigger = await prisma.webhookScheduledTriggers.findMany({
where: {
webhook: {
userId: user.id,
},
},
});
const newMeetingStartedTriggers = scheduledTriggersAfterRemovingTrigger.filter((trigger) =>
trigger.payload.includes("MEETING_STARTED")
);
const newMeetingEndedTriggers = scheduledTriggersAfterRemovingTrigger.filter((trigger) =>
trigger.payload.includes("MEETING_ENDED")
);
expect(newMeetingStartedTriggers.length).toBe(existingUserBookings.length);
expect(newMeetingEndedTriggers.length).toBe(0);
// disable webhook
await page.click('[data-testid="webhook-switch"]');
await page.waitForLoadState("networkidle");
const scheduledTriggersAfterDisabling = await prisma.webhookScheduledTriggers.findMany({
where: {
webhook: {
userId: user.id,
},
},
select: {
payload: true,
webhook: {
select: {
userId: true,
},
},
startAfter: true,
},
});
expect(scheduledTriggersAfterDisabling.length).toBe(0);
});
});
test.describe("FORM_SUBMITTED", async () => {
test("on submitting user form, triggers user webhook", async ({ page, users, routingForms }) => {
const webhookReceiver = createHttpServer();
@@ -578,6 +704,8 @@ test.describe("FORM_SUBMITTED", async () => {
],
});
await page.waitForLoadState("networkidle");
await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");
@@ -637,6 +765,8 @@ test.describe("FORM_SUBMITTED", async () => {
],
});
await page.waitForLoadState("networkidle");
await gotoRoutingLink({ page, formId: form.id });
const fieldName = "name";
await page.fill(`[data-testid="form-field-${fieldName}"]`, "John Doe");

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