Compare commits

..
194 Commits
Author SHA1 Message Date
Keith Williams 81386d4ec0 chore: v4.6.23 (#17620)
release.yml / release (push) Canceled after 0s
2024-11-13 14:40:22 -03:00
Eunjae LeeandKeith Williams 204e411585 fix: make actions column sticky in members list (#17554)
* fix: make actions column sticky in members list

* clean up import

* change type definition

* remove old export

* include tanstack.d.ts

* fix declaration file

* move type def to packages/types/tanstack-table.d.ts

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-11-13 16:36:01 +00:00
Chiranjeev VishnoiandAnik Dhabal Babu 1ec1554cb5 change telly url (#17618)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-13 16:21:53 +00:00
Udit Takkar 241579eba6 refactor: change title (#17623)
* chore: change title

* chore: translation
2024-11-13 16:19:58 +00:00
Udit Takkar 8ccad29ab6 fix: skip triggering webhook in case of duplicate email (#17612)
* fix: skip sending webhook in case of duplicate response

* fix: type err

* fix: tests

* refactor: only check email

* fix: type err

* chore: changed it to 60 min

* fix: use lowercase
2024-11-13 15:50:26 +00:00
74b4d7a94a chore: Automated API v2 OpenAPI spec generation (#17046)
* chore: Automated API v2 OpenAPI spec generation

* chore: update docs

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-11-13 15:28:25 +00:00
5c6e60b726 fix: fetch next batch if event types are empty and cursor exists (#17109)
* fix: fetch next batch if event types are empty and cursor exists

* refactor: improve

* fix: add isFetchingForFirstTime

* chore: add number type

* fix: type err

* chore: add logs

* fix: type err

* fix: conflict

* fix: bug

* fix: type err

* chore: try changing type

* chore: add nul

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

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* chore: use log.info

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Bailey Pumfleet <bailey@pumfleet.co.uk>
2024-11-13 14:37:03 +00:00
Alex van Andel 18c7c3c940 fix: Refactor publicPageUrl to use getBookerBaseUrlSync like elsewhere (#17607) 2024-11-13 08:55:01 -05:00
fbd113b976 feat: update translations via @replexica (#17619)
* feat: update translations via @replexica

* feat: update translations via @replexica

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-13 13:53:51 +00:00
Udit Takkar c7f11c6795 fix: mark guest are no show (#17588)
* fix: mark guest are no show

* chore: missing import

* nit

* fix: type err
2024-11-13 19:03:55 +05:30
Peer Richelsen 84fb9bb371 chore: update Instant Meeting Error Message (#17617)
* Update common.json

* small copy changes
2024-11-13 13:08:05 +00:00
Lauris Skraucis 1aac70f643 feat: v2 confirm / decline booking endpoints (#17578)
* feat: v2 confirm / decline booking endpoints

* chore: bump platform libraries

* fix: type export

* tests: accepting and declining a booking

* refactor: sent emails abstract renderEmail with getHtml that is mocked in test
2024-11-13 11:20:43 +02:00
Lauris Skraucis 0dde8c1db9 feat: v2 multiple length event types and their bookings (#17598)
* feat: input lengthInMinutesOptions

* Revert "feat: input lengthInMinutesOptions"

This reverts commit f23fd8a3861097f0972cc6210b47f25511d85183.

* feat: handle lengthInMinutesOptions input

* feat: handle lengthInMinutesOptions event output

* fix: remove it.only

* feat: variable length bookings
2024-11-13 11:13:11 +02:00
Ankit YadavandOmar López 0da3a9fa11 fix: #17602 (#17604)
Co-authored-by: Omar López <zomars@me.com>
2024-11-12 20:20:38 +00:00
Alex van Andel eb8f5bd194 chore: Release v4.6.22 (#17600)
release.yml / release (push) Canceled after 0s
2024-11-12 18:18:37 +00:00
Morgan 82901fee94 fix: apiv2 updateNewTeamMemberEventTypes silently (#17555)
* fix: apiv2 updateNewTeamMemberEventTypes silently

* fixup! fix: apiv2 updateNewTeamMemberEventTypes silently
2024-11-12 09:32:21 -03:00
Udit Takkar 4e0e3a47f5 perf: routing forms loading time (#17518)
* perf: routing forms loading time

* chore: add TODO

* chore

* chore: add type

* chore: use Promise.all

* fix: type err

* fix: fetch id

* refactor: don't fetch team

* chore: add name
2024-11-12 17:32:42 +05:30
Syed Ali Shahbaz dc400d80d0 fix: split firstname lastname support, phone support & timezone specific startTime in success redirect params (#17593)
* init

* fixes

* fix type
2024-11-12 11:16:41 +00:00
Morgan 6889592d4f feat: add free platform plan (#17581)
* feat: add free platform plan

* chore: handle payment success / fail overdue

* chore: refactor and add tests
2024-11-12 11:14:51 +02:00
Benny Joo 95b833bc1c fix: avatars are not showing in preview image (#17592) 2024-11-12 07:57:06 +00:00
b06683458a feat: update translations via @replexica (#17585)
Co-authored-by: Replexica <support@replexica.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-11-11 22:20:17 -03:00
Alex van Andel 01d764c6d3 chore: Release v4.6.21 (#17586)
release.yml / release (push) Canceled after 0s
2024-11-12 01:09:55 +00:00
Joe Au-YeungandAlex van Andel 49333addc2 feat: Salesforce RR skip based on a user on a lookup field on an account (#17526)
* move types to types file

* Create salesforce routing form components

* Save salesforce data to routing form

* Fixes

* Add event type service

* Change commenting

* Pass data from routing form to CRM

* Init Salesforce routing form booking form handler

* Salesforce find user associated with lookup field

* Add looking up the contact owner

* If salesforce is disabled then don't change the Salesforce option

* Small refactor

* Refactor getting event type app metadata

* Refactor eventType service

* Type fix

* Clean up

* Add translations

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>

---------

Co-authored-by: Alex van Andel <emrysal@users.noreply.github.com>
2024-11-12 01:09:30 +00:00
Anik Dhabal Babu 6c40d29745 fix: incorrect booking title for diff users (#17448)
* fix: incorrect booking title for diff users

* Update common.json

* update
2024-11-11 19:10:45 +00:00
Anik Dhabal BabuandUdit Takkar fc5ad53a6d fix: hidden phone booking field should never need validation (#17564)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-11 15:06:38 +00:00
Anik Dhabal Babu 4d38c6d0c7 fix: Form link in Reroute Dialog gives 404 for members that aren't admin/owner of team (#17537)
* fix: Form link in Reroute Dialog gives 404 for members that aren't admin/owner of team

* NIT
2024-11-11 20:25:08 +05:30
Anik Dhabal Babu cd15335eed chore: plain text plugin for Editor (#17134)
* chore: plain text plugin for Editor

* fix build error
2024-11-11 20:23:12 +05:30
Anik Dhabal Babu aaa75100d4 chore: Routing form - Add submitted_at and Sort entries with latest on top (#17491)
* chore: Routing form - Add submitted_at and sort rows by this timestamp in reporting

* fix e2e

* fix e2e

* Update basic.e2e.ts

* Update basic.e2e.ts

* Update
2024-11-11 19:57:57 +05:30
Anik Dhabal Babu ae9063804d fix: Users details leak for team booking when the team is private (#17403)
* fix: host details leaking

* type error

* update

* update

* Update getPublicEvent.ts

* Update getPublicEvent.ts
2024-11-11 19:55:42 +05:30
Calcom BotandReplexica 30f9d9b980 feat: update translations via @replexica (#17577)
Co-authored-by: Replexica <support@replexica.com>
2024-11-11 13:57:50 +00:00
Anik Dhabal Babu 42a264380a fix (#17548) 2024-11-11 09:37:14 +00:00
Hariom BalharaandUdit Takkar 62aaba32cf fix: Routing Form - Small Improvements (#17538)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-11-11 14:14:39 +05:30
Amit Sharmaandsean-brydon aaf630f01f feat: Delete attribute modal (#17557)
* feat: Delete attribute modal

* move Modal file

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2024-11-11 08:33:19 +00:00
Rajiv Sahal 838e8b9e89 fix: iframe issue for atoms documentation (#17570)
* fix syntax for code examples

* update iframe styling
2024-11-11 08:28:02 +00:00
Kunall Banerjee ce373c475c fix: incorrect capitalization in company names (#17573) 2024-11-10 08:42:29 +00:00
Omar López ed662b7956 chore: v4.6.20 (#17571)
release.yml / release (push) Canceled after 0s
2024-11-09 16:48:45 -07:00
Rajiv Sahal 2e49110ce6 fix: docs loom iframe fixes (#17569) 2024-11-09 08:41:55 +00:00
Rajiv Sahal fc35f9de85 fix: atoms docs (#17556)
* fixup

* resolve merge conflicts

* fixup fixup

* trying some fixups
2024-11-09 07:39:48 +00:00
Joe Au-Yeung 9c67ee7375 fix: pass upId to JWT if logging in through SAML (#17566)
* pass `upId` to JWT if logging in through SAML

* Correct change
2024-11-08 22:43:56 +00:00
Syed Ali Shahbaz a1477dc7e1 feat: adds attendee and host names, and guest emails in forwarded params (#17530)
* adds attendee and host names in forwarded params

* address feedback

* add guest emails
2024-11-08 22:33:22 +05:30
25d39b4273 feat: api v2 reassign (#17460)
* adding input types and method to controller/service

* add output types

* add outputs and inputs

* feat: reassign automatically

* feat: reassign to user

* e2e test for reassign

* update platform libraries + experimental modules for jest

* fix test

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-11-08 12:42:59 +00:00
sean-brydon 4976e24e12 fix: move team availability search to serverside (#17549)
* move search to severside

* transform zod stirng to lowercase
2024-11-08 15:32:08 +05:30
Rajiv Sahal 5ae6e1e983 fix: loom iframe for atoms (#17550) 2024-11-08 09:37:29 +00:00
Omar López 6080ce0c85 v4.6.19 (#17524)
release.yml / release (push) Canceled after 0s
2024-11-07 13:24:04 -07:00
Morgan 79f0754bf7 chore: add sentry performance tracking api-v2 (#17519)
* chore: add sentry performance tracking api-v2

* fixup! chore: add sentry performance tracking api-v2
2024-11-07 09:38:05 -03:00
Vijay 540bd478dc fix: issues in 'updateNewTeamMemberEventTypes' while adding new team member. (#17513)
* fix due to error in zod.parse while adding new member

* updated zod parsing

* fix the linking with parent

* update fixture to accomodate assignAllTeamMembers for team and org scenarios

* added e2e test

* nit
2024-11-07 10:33:25 +00:00
9873e373dc feat: reset RR every month (#17472)
* only fetch bookings of this month for weighted rr

* test set up

* only get bookings of current month

* reverts test setup

* first changes for weight adjustments

* reset booking count + adjust calibration

* depreciate weightAdjustment

* get only bookings created this month

* fix typo

* make sure createdAt for hosts is correct

* use earliest possibel date as fallback

* add missing createdAt date to tests

* fix typo

* clean up changes in tests

* fix typo

* change end date to current date

* fix: Fall back to empty host array when no hosts are found

* fix: Restructure code a little

* fixed test, incorrectly used now outdated var

* perf: remove Dayjs from getLuckyUser

* Refactor getHostsWithCalibration for optimised performance, intentionally break test as findMany is always an array

* Better mock for host.findMany

* Remove team-event-types.test.ts, move to appropriate package

* TypeScript cannot auto-infer that an array is non-empty when assigning to a var

* fix: Type Fixes and DistributionMethod enum add

* Optimise tests

* Added test to show that bookings made before a newHost was added affect the lucky user result

* Throw error when the usersWithHighestPriority is empty, which should never happen

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2024-11-07 10:24:23 +00:00
Morgan 52093862ce fix: booker timezone from preference instead of session (#17520)
* fix: booker timezone from preference instead of session

* fixup! fix: booker timezone from preference instead of session
2024-11-07 09:42:38 +00:00
Rajiv Sahal e101f35b4f fix: event types atom docs (#17528)
* fix syntax for code examples

* fixup
2024-11-07 07:59:29 +00:00
Benny Joo 54cc2dc094 chore: cache meeting OG images for 1 day (#17525) 2024-11-07 00:19:20 +00:00
Alex van Andel 88e57d48fe chore: Release v4.6.18 (#17521)
release.yml / release (push) Canceled after 0s
2024-11-06 20:39:39 +00:00
Morgan 6619701c88 fix: event-type atom tabs controlled by prop (#17480)
* fix: event-type atom tabs controlled by prop

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

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

* type fixes to util.ts

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

* add updateAvatar method to UserRepository

* refactor googlecalendar callback

* add methods to CredentialRepository and SelectedCalendarRepository

* refactor

* remove not needed code

* refactor

* make credential repository and selected calendar repository more generic

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

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

* Type fix

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

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

* Update settings layout app dir

---------

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

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

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

* Start FE component

* Rename and add new enums

* Add new schema for write on record options

* Add options on the frontend

* Backend write to custom fields

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

* Add translations

* fix: type err

---------

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

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

* images for event type atom

* update images for destination and selected calendar atoms

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

* add i18n for some strings in widget

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

* add more margin

* add downloadAsCsv util

* integrate download button to routing forms report

* refactor

* address comment

* escape comma multiselect items

* fix

* preserve new lines and wrapping

* refactor

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

* update payment form page

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

* Fix typing issues

* Fixed API v1 build

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

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

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

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

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

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

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

* remove pages router and use app router for platform

* add billing page and plans page

* fix metadata for settings/platform/billing

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

* updating lock

* move to init posthog client - pageview component

* adding identify in shell

* removing test event captures

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

---------

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

* chore

* chore: add more fields

* add host email

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

* add sanitizeValue util

* write logic

* refactor

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

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

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

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

* a lot more tests aroundthis

* improve test cases

* change reddis cache key to take into account teamID

* add start/endtime to template

* fix text in email

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

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

* Support attribute query fallback

* Refactor

* Add tests and cleanup SingleFofrm

* small text fixes

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

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

* Abstract useRoutes out

* Update e2e

* Dont define Page component again and again

---------

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

* refactor: lazy loading people filters and OOO

* chore: type errr

* chore: type errr

* fallback to username if no name is set

---------

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

* fix

* refactor to make it more readable

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

* fix w-full issue in dialog

* fix types

---------

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

* cleanup

* update platform libraries

* fix import paths

* add function to get user payment info

* fixup

* resolve merge conflicts

* fixup fixup

* resolve merge conflicts

* fixup

* update payments page

* add props for event payment success and failure

* update props and fixup

* bump platform libraries

* fixup! bump platform libraries

* fixup! fixup! bump platform libraries

* resolve merge conflicts

* fix: add isActive to paymentsTab

---------

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

* add tests

---------

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

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

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

* add team app credentials method

* add handler to get all user admin teams

* update stripe logic to handle teams

* enable teamId for stripe connect

* update stripe module

* implement PR feedback

* update stripe module

* add method to get credential by team id

* handle check for teamId

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

* hide badge for platform user

* update typing

* add logic to handle team stripe integration

* bump platform libraries

* bump platform libraries

---------

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

* Just to build

* perf: Add Sentry perf tracing

* Removed the extra integrations tracing config

* Got spans working

* Now only tracing individual endpoints

* Removed check for booking event type

* Fixed types

* Added back for spans

* Consolidate sentry imports

* Added more traces

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

* Add backend logic

* Add message

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

* order in alpha and prio order

* loading state

* add flex shrink so status symbol doesnt move

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

* a11y indexing tab group

* useInViewObserver hook and move to lib

* use correct intersection target

* fix type errors

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

* remove testBothFutureAndLegacyRoutes for settings-admin e2e test

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

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

* Revert changes on main merge

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

* Update common.json

---------

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

* improve searchbar

* remove unused code

* add workflow vars

* change requested

* requested changes

* changes

* type-fix??

* type-fix??

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

* fix: Clarify Popover exports

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

* chore: Add icon barrel file re-export

* Fix all calcom/ui imports of its own barrel

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

* Some type fixeS

* Linking fixes

* Rename sheet.tsx to Sheet.tsx

Done through UI, console does NOT like this.

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

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

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

* Update event.ts

* add test

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

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

* feat: atom support google-meet

* feat: unknown booking field return type

* tests

* bump platform libraries

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

* refactor apps-view

* revert unneeded changes

* remove withoutMain

---------

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

* chore: message

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

* Add boolean for no accountId

* Remove debug artifact

---------

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

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

* perf: optimize the query

* perf: make it a separate query

---------

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

* raw query

* fix inside wrong val

* fix timeline view entirely

* fix avg event duration

* fix timeline

* type-fix

* fixes

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

* refactor: rename TransformedLocationsSchema to InternalLocationsSchema

* feat: add missing locations in internal schema

* refactor: explicit input locations only for input

* refactor: explicit output locations only for output

* fix: v2

* fix: atoms

* test: internal to api locations transformation

* e2e tests

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

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

* add logic for confirmation modal on unavailable host

* fix type error

* fix type error

* more type fixes

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

* Update Button.tsx

* Update Components.tsx

* Update Button.tsx

---------

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

* Write to custom fields on booking

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

* improve reassign email

* fixes

* Minor copy fixup

---------

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

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

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

* update

* final update

* refactor

* update

---------

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

* Update Header.tsx

* Revert "Update Header.tsx"

This reverts commit b12515c8ce2f652adbf441d7488b877a50c8b539.

---------

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

* update

* Update
2024-10-23 09:24:06 +05:30
578 changed files with 20589 additions and 6434 deletions
+14 -6
View File
@@ -16,7 +16,7 @@
# - Acquire a commercial license to remove these terms by visiting: cal.com/sales
#
# To enable enterprise-only features please add your envrionment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
# To enable enterprise-only features please add your environment variable to the .env file then make your way to /auth/setup to select your license and follow instructions.
CALCOM_LICENSE_KEY=
# Signature token for the Cal.com License API (used for self-hosted integrations)
# We will give you a token when we provide you with a license key this ensure you and only you can communicate with the Cal.com License API for your license key
@@ -102,6 +102,11 @@ NEXT_PUBLIC_INTERCOM_APP_ID=
# Secret to enable Intercom Identity Verification
INTERCOM_SECRET=
# Posthog Config
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
# Zendesk Config
NEXT_PUBLIC_ZENDESK_KEY=
@@ -128,7 +133,7 @@ GOOGLE_API_CREDENTIALS=
# Inbox to send user feedback
SEND_FEEDBACK_EMAIL=
# Sengrid
# Sendgrid
# Used for email reminders in workflows and internal sync services
SENDGRID_API_KEY=
SENDGRID_EMAIL=
@@ -137,6 +142,9 @@ NEXT_PUBLIC_SENDGRID_SENDER_NAME=
# Sentry
# Used for capturing exceptions and logging messages
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_DEBUG=
SENTRY_MAX_SPANS=
SENTRY_TRACES_SAMPLE_RATE=
# Formbricks Experience Management Integration
NEXT_PUBLIC_FORMBRICKS_HOST_URL=https://app.formbricks.com
@@ -262,7 +270,7 @@ ORGANIZATIONS_ENABLED=
NEXT_PUBLIC_ORGANIZATIONS_MIN_SELF_SERVE_SEATS=30
NEXT_PUBLIC_ORGANIZATIONS_SELF_SERVE_PRICE=3700 # $37.00 per seat
# This variable should only be set to 1 or true if you want to autolink external provider sing-ups with
# This variable should only be set to 1 or true if you want to autolink external provider sign-ups with
# existing organizations based on email domain address
ORGANIZATIONS_AUTOLINK=
@@ -334,9 +342,6 @@ AB_TEST_BUCKET_PROBABILITY=50
# whether we redirect to the future/event-types from event-types or not
APP_ROUTER_EVENT_TYPES_ENABLED=0
APP_ROUTER_SETTINGS_DEVELOPER_ENABLED=0
APP_ROUTER_SETTINGS_ADMIN_ENABLED=0
APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED=0
APP_ROUTER_SETTINGS_PLATFORM_ENABLED=0
APP_ROUTER_SETTINGS_ORG_ENABLED=0
APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED=0
APP_ROUTER_APPS_SLUG_ENABLED=0
@@ -385,6 +390,9 @@ UNKEY_ROOT_KEY=
# https://retellai.com
RETELL_AI_KEY=
# Used for the huddle01 integration
HUDDLE01_API_TOKEN=
# Used to disallow emails as being added as guests on bookings
BLACKLISTED_GUEST_EMAILS=
@@ -83,11 +83,12 @@ jobs:
**/.turbo/**
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
- name: Log Cache Hit
if: steps.cache-api-v1-build.outputs.cache-hit == 'true'
run: echo "Cache hit for API v1 build. Skipping build."
- name: Run build
if: steps.cache-api-v1-build.outputs.cache-hit != 'true'
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-api-v1-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for API v1 build. Skipping build."
else
yarn turbo run build --filter=@calcom/api...
fi
yarn turbo run build --filter=@calcom/api...
shell: bash
+10 -9
View File
@@ -49,14 +49,15 @@ jobs:
path: |
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
- name: Log Cache Hit
if: steps.cache-api-v2-build.outputs.cache-hit == 'true'
run: echo "Cache hit for API v2 build. Skipping build."
- name: Run build
if: steps.cache-api-v2-build.outputs.cache-hit != 'true'
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-api-v2-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for API v2 build. Skipping build."
else
yarn workspace @calcom/api-v2 run generate-schemas
rm -rf apps/api/v2/node_modules
yarn install
yarn workspace @calcom/api-v2 run build
fi
yarn workspace @calcom/api-v2 run generate-schemas
rm -rf apps/api/v2/node_modules
yarn install
yarn workspace @calcom/api-v2 run build
shell: bash
+10 -9
View File
@@ -5,7 +5,7 @@ on:
jobs:
build:
name: Build atoms
name: Build Atoms
permissions:
contents: read
runs-on: buildjet-4vcpu-ubuntu-2204
@@ -28,13 +28,14 @@ jobs:
path: |
**/dist/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
- run: |
- name: Log Cache Hit
if: steps.cache-atoms-build.outputs.cache-hit == 'true'
run: echo "Cache hit for Atoms build. Skipping build."
- name: Run build
if: steps.cache-atoms-build.outputs.cache-hit != 'true'
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-atoms-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for atoms build. Skipping build."
else
rm -rf packages/platform/atoms/node_modules
yarn install
yarn workspace @calcom/atoms run build
fi
rm -rf packages/platform/atoms/node_modules
yarn install
yarn workspace @calcom/atoms run build
shell: bash
+9 -8
View File
@@ -26,16 +26,17 @@ jobs:
path: |
**/docs/**
key: ${{ runner.os }}-${{ env.cache-name }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}-${{ env.key-4 }}
# Log cache hit
- name: Log Cache Hit
if: steps.cache-docs-build.outputs.cache-hit == 'true'
run: echo "Cache hit for Docs build. Skipping build."
- name: Run build
if: steps.cache-docs-build.outputs.cache-hit != 'true'
working-directory: docs
run: |
export NODE_OPTIONS="--max_old_space_size=8192"
if [ ${{ steps.cache-docs-build.outputs.cache-hit }} == 'true' ]; then
echo "Cache hit for Docs build. Skipping build."
else
npm install -g mintlify
mintlify dev &
sleep 5 # Let it run for 5 seconds
kill $!
fi
npm install -g mintlify
mintlify dev &
sleep 5 # Let it run for 5 seconds
kill $!
shell: bash
@@ -199,6 +199,7 @@ async function handler(req: NextApiRequest) {
eventTypeId,
userId,
returnDateOverrides: true,
bypassBusyCalendarTimes: false,
});
const team = await prisma.team.findUnique({
where: { id: teamId },
@@ -235,6 +236,7 @@ async function handler(req: NextApiRequest) {
dateTo,
eventTypeId,
returnDateOverrides: true,
bypassBusyCalendarTimes: false,
}),
};
});
+4 -2
View File
@@ -19,7 +19,7 @@
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192' jest --ci --forceExit --config ./jest-e2e.json",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
@@ -28,7 +28,7 @@
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.51",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.60",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -45,7 +45,9 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "6.2.1",
"@sentry/nestjs": "^8.37.1",
"@sentry/node": "^8.8.0",
"@sentry/profiling-node": "^8.37.1",
"body-parser": "^1.20.2",
"bull": "^4.12.4",
"class-transformer": "^0.5.1",
+7 -1
View File
@@ -17,13 +17,15 @@ import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis"
import { BullModule } from "@nestjs/bull";
import { MiddlewareConsumer, Module, NestModule, RequestMethod } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD, APP_INTERCEPTOR } from "@nestjs/core";
import { APP_GUARD, APP_INTERCEPTOR, APP_FILTER } from "@nestjs/core";
import { seconds, ThrottlerModule } from "@nestjs/throttler";
import { SentryModule, SentryGlobalFilter } from "@sentry/nestjs/setup";
import { AppController } from "./app.controller";
@Module({
imports: [
SentryModule.forRoot(),
ConfigModule.forRoot({
ignoreEnvFile: true,
isGlobal: true,
@@ -59,6 +61,10 @@ import { AppController } from "./app.controller";
],
controllers: [AppController],
providers: [
{
provide: APP_FILTER,
useClass: SentryGlobalFilter,
},
{
provide: ThrottlerStorageRedisService,
useFactory: (redisService: RedisService) => {
-4
View File
@@ -7,7 +7,6 @@ import type { ValidationError } from "@nestjs/common";
import { BadRequestException, ValidationPipe, VersioningType } from "@nestjs/common";
import { BaseExceptionFilter, HttpAdapterHost } from "@nestjs/core";
import type { NestExpressApplication } from "@nestjs/platform-express";
import * as Sentry from "@sentry/node";
import * as cookieParser from "cookie-parser";
import { Request } from "express";
import helmet from "helmet";
@@ -71,9 +70,6 @@ export const bootstrap = (app: NestExpressApplication): NestExpressApplication =
// Exception filters, new filters go at the bottom, keep the order
const { httpAdapter } = app.get(HttpAdapterHost);
if (process.env.SENTRY_DSN) {
Sentry.setupNestErrorHandler(app, new BaseExceptionFilter(httpAdapter));
}
app.useGlobalFilters(new PrismaExceptionFilter());
app.useGlobalFilters(new ZodExceptionFilter());
app.useGlobalFilters(new HttpExceptionFilter());
@@ -89,7 +89,7 @@ const DEFAULT_PLATFORM_PARAMS = {
@UseGuards(PermissionsGuard)
@DocsExcludeController(true)
export class BookingsController_2024_04_15 {
private readonly logger = new Logger("BookingsController");
private readonly logger = new Logger("BookingsController_2024_04_15");
constructor(
private readonly oAuthFlowService: OAuthFlowService,
@@ -111,8 +111,9 @@ export class BookingsController_2024_04_15 {
@Query() queryParams: GetBookingsInput_2024_04_15
): Promise<GetBookingsOutput_2024_04_15> {
const { filters, cursor, limit } = queryParams;
const bookingListingByStatus = filters?.status ?? Status_2024_04_15["upcoming"];
const bookings = await getAllUserBookings({
bookingListingByStatus: [filters.status],
bookingListingByStatus: [bookingListingByStatus],
skip: cursor ?? 0,
take: limit ?? 10,
filters,
@@ -355,11 +356,19 @@ export class BookingsController_2024_04_15 {
oAuthClientId?: string,
platformBookingLocation?: string
): Promise<NextApiRequest & { userId?: number } & OAuthRequestParams> {
const requestId = req.get("X-Request-Id");
const clone = { ...req };
const userId = (await this.getOwnerId(req)) ?? -1;
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
this.logger.log(`createNextApiBookingRequest_2024_04_15`, {
requestId,
ownerId: userId,
platformBookingLocation,
oAuthClientId,
...oAuthParams,
});
Object.assign(clone, { userId, ...oAuthParams, platformBookingLocation });
clone.body = { ...clone.body, noEmail: !oAuthParams.arePlatformEmailsEnabled };
return clone as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
@@ -375,6 +384,14 @@ export class BookingsController_2024_04_15 {
const oAuthParams = oAuthClientId
? await this.getOAuthClientsParams(oAuthClientId)
: DEFAULT_PLATFORM_PARAMS;
const requestId = req.get("X-Request-Id");
this.logger.log(`createNextApiRecurringBookingRequest_2024_04_15`, {
requestId,
ownerId: userId,
platformBookingLocation,
oAuthClientId,
...oAuthParams,
});
Object.assign(clone, {
userId,
...oAuthParams,
@@ -7,6 +7,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
import { ApiKeyRepository } from "@/modules/api-key/api-key-repository";
import { BillingModule } from "@/modules/billing/billing.module";
import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -27,6 +28,7 @@ import { Module } from "@nestjs/common";
OutputBookingsService_2024_08_13,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
ApiKeyRepository,
],
controllers: [BookingsController_2024_08_13],
@@ -56,6 +56,17 @@ export class BookingsRepository_2024_08_13 {
});
}
async getByUidWithUser(bookingUid: string) {
return this.dbRead.prisma.booking.findUnique({
where: {
uid: bookingUid,
},
include: {
user: true,
},
});
}
async getByIdWithAttendeesAndUserAndEvent(id: number) {
return this.dbRead.prisma.booking.findUnique({
where: {
@@ -2,6 +2,7 @@ import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.gua
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { VERSION_2024_08_13_VALUE } from "@/lib/api-versions";
@@ -9,6 +10,7 @@ import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
@@ -46,12 +48,12 @@ import {
CreateBookingInputPipe,
CreateBookingInput,
GetBookingsInput_2024_08_13,
RescheduleBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
ReassignToUserBookingInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
CreateBookingInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
DeclineBookingInput_2024_08_13,
} from "@calcom/platform-types";
@Controller({
@@ -66,7 +68,7 @@ import {
required: true,
})
export class BookingsController_2024_08_13 {
private readonly logger = new Logger("BookingsController");
private readonly logger = new Logger("BookingsController_2024_08_13");
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
@@ -227,4 +229,104 @@ export class BookingsController_2024_08_13 {
data: booking,
};
}
@Post("/:bookingUid/reassign")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Automatically reassign booking to a new host" })
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: UserWithProfile
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBooking(bookingUid, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/reassign/:userId")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Reassign a booking to a specific user" })
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@Param("userId") userId: number,
@GetUser("id") reassignedById: number,
@Body() body: ReassignToUserBookingInput_2024_08_13
): Promise<ReassignBookingOutput_2024_08_13> {
const booking = await this.bookingsService.reassignBookingToUser(
bookingUid,
userId,
reassignedById,
body
);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/confirm")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Confirm booking that requires a confirmation" })
async confirmBooking(
@Param("bookingUid") bookingUid: string,
@GetUser() user: UserWithProfile
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.confirmBooking(bookingUid, user);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
@Post("/:bookingUid/decline")
@HttpCode(HttpStatus.OK)
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader({
name: "Authorization",
description:
"value must be `Bearer <token>` where `<token>` either managed user access token or api key prefixed with cal_",
required: true,
})
@ApiOperation({ summary: "Decline booking that requires a confirmation" })
async declineBooking(
@Param("bookingUid") bookingUid: string,
@Body() body: DeclineBookingInput_2024_08_13,
@GetUser() user: UserWithProfile
): Promise<GetBookingOutput_2024_08_13> {
const booking = await this.bookingsService.declineBooking(bookingUid, user, body.reason);
return {
status: SUCCESS_STATUS,
data: booking,
};
}
}
@@ -0,0 +1,262 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
AttendeeScheduledEmail,
OrganizerScheduledEmail,
AttendeeDeclinedEmail,
} from "@calcom/platform-libraries";
import { BookingOutput_2024_08_13, GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
import { Booking, PlatformOAuthClient, Team } from "@calcom/prisma/client";
jest.spyOn(AttendeeScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(OrganizerScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
jest.spyOn(AttendeeDeclinedEmail.prototype as any, "getHtml").mockImplementation(async function () {
return "<html><body>Mocked Email Content</body></html>";
});
describe("Bookings Endpoints 2024-08-13", () => {
describe("Bookings confirmation", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let eventTypeId: number;
const eventTypeSlug = "peer-coding";
let createdBooking1: Booking;
let createdBooking2: Booking;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const event = await eventTypesRepositoryFixture.create(
{ title: "peer coding", slug: eventTypeSlug, length: 60 },
user.id
);
eventTypeId = event.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
it("should confirm a booking", async () => {
const status = "PENDING";
createdBooking1 = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
title: "peer coding",
uid: "peer-coding-one",
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
status,
});
expect(createdBooking1.status).toEqual("PENDING");
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking1.uid}/confirm`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.uid).toBeDefined();
expect(data.status).toEqual("accepted");
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
expect(dbBooking?.status).toEqual("ACCEPTED");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should decline a booking", async () => {
const status = "PENDING";
createdBooking2 = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 10, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 11, 0, 0)),
title: "peer coding",
uid: "peer-coding-two",
eventType: {
connect: {
id: eventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
status,
});
expect(createdBooking2.status).toEqual("PENDING");
return request(app.getHttpServer())
.post(`/v2/bookings/${createdBooking2.uid}/decline`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.uid).toBeDefined();
expect(data.status).toEqual("rejected");
const dbBooking = await bookingsRepositoryFixture.getByUid(data.uid);
expect(dbBooking?.status).toEqual("REJECTED");
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,337 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { Booking, User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Reassign bookings", () => {
let app: INestApplication;
let organization: Team;
let team: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const teamUserEmail = "orgUser1team1@api.com";
const teamUserEmail2 = "orgUser2team1@api.com";
let teamUser1: User;
let teamUser2: User;
let teamRoundRobinEventTypeId: number;
let roundRobinBooking: Booking;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization team bookings" });
team = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
});
oAuthClient = await createOAuthClient(organization.id);
teamUser1 = await userRepositoryFixture.create({
email: teamUserEmail,
locale: "it",
name: "orgUser1team1",
});
teamUser2 = await userRepositoryFixture.create({
email: teamUserEmail2,
locale: "it",
name: "orgUser2team1",
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(teamUser1.id, userSchedule);
await schedulesService.createUserSchedule(teamUser2.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${teamUser1.id}`,
username: teamUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${teamUser2.id}`,
username: teamUserEmail2,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
team: { connect: { id: team.id } },
user: { connect: { id: teamUser1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
team: { connect: { id: team.id } },
user: { connect: { id: teamUser2.id } },
accepted: true,
});
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team.id },
},
users: {
connect: [{ id: teamUser1.id }, { id: teamUser2.id }],
},
title: "Round Robin Event Type",
slug: "round-robin-event-type",
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
});
teamRoundRobinEventTypeId = team1EventType.id;
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser1.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: teamUser2.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
roundRobinBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: teamUser1.id,
},
},
startTime: new Date(Date.UTC(2050, 0, 7, 13, 0, 0)),
endTime: new Date(Date.UTC(2050, 0, 7, 14, 0, 0)),
title: "round robin coding lets goo",
uid: "round-robin-coding",
eventType: {
connect: {
id: teamRoundRobinEventTypeId,
},
},
location: "via 10, rome, italy",
customInputs: {},
metadata: {},
responses: {
name: "Bob",
email: "bob@gmail.com",
},
attendees: {
create: {
email: "bob@gmail.com",
name: "Bob",
locale: "it",
timeZone: "Europe/Rome",
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should reassign round robin booking", async () => {
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(booking?.userId).toEqual(teamUser1.id);
return request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
expect(data.reassignedTo).toEqual({
id: teamUser2.id,
name: teamUser2.name,
email: teamUser2.email,
});
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(reassigned?.userId).toEqual(teamUser2.id);
});
});
it("should reassign round robin booking to a specific user", async () => {
const booking = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(booking?.userId).toEqual(teamUser2.id);
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: teamRoundRobinEventTypeId,
attendee: {
name: "alice",
email: "alice@gmail.com",
timeZone: "Europe/Madrid",
language: "es",
},
meetingUrl: "https://meet.google.com/abc-def-ghi",
};
return request(app.getHttpServer())
.post(`/v2/bookings/${roundRobinBooking.uid}/reassign/${teamUser1.id}`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: ReassignBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: ReassignBookingOutput_2024_08_13["data"] = responseBody.data;
expect(data.bookingUid).toEqual(roundRobinBooking.uid);
expect(data.reassignedTo).toEqual({
id: teamUser1.id,
name: teamUser1.name,
email: teamUser1.email,
});
const reassigned = await bookingsRepositoryFixture.getByUid(roundRobinBooking.uid);
expect(reassigned?.userId).toEqual(teamUser1.id);
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(teamUser1.email);
await userRepositoryFixture.deleteByEmail(teamUserEmail2);
await bookingsRepositoryFixture.deleteAllBookings(teamUser1.id, teamUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(teamUser2.id, teamUser2.email);
await app.close();
});
});
});
@@ -642,4 +642,374 @@ describe("Bookings Endpoints 2024-08-13", () => {
await app.close();
});
});
describe("Recurring seated bookings creation", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "seated-bookings-controller-e2e@api.com";
let user: User;
const seatedTventTypeSlug = "peer-coding-seated";
const recurringSeatedTventTypeSlug = "peer-coding-recurring-seated";
let seatedEventTypeId: number;
let recurringSeatedEventTypeId: number;
const maxRecurrenceCount = 3;
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
const emailAttendeeOne = "mr_proper_first@gmail.com";
const nameAttendeeOne = "Mr Proper First";
const emailAttendeeTwo = "mr_proper_second@gmail.com";
const nameAttendeeTwo = "Mr Proper Second";
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const seatedEvent = await eventTypesRepositoryFixture.create(
{
title: "peer coding",
slug: seatedTventTypeSlug,
length: 60,
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
},
user.id
);
seatedEventTypeId = seatedEvent.id;
const recurringSeatedEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
{
title: "peer coding recurring",
slug: recurringSeatedTventTypeSlug,
length: 60,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
seatsPerTimeSlot: 5,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
},
user.id
);
recurringSeatedEventTypeId = recurringSeatedEvent.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should book a recurring event type with seats for the first time", async () => {
const recurrenceCount = 2;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: recurringSeatedEventTypeId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(recurrenceCount);
const firstBooking = data[0];
expect(firstBooking.seatUid).toBeDefined();
const seatUid = firstBooking.seatUid;
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(body.start);
expect(firstBooking.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(firstBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(firstBooking.attendees.length).toEqual(1);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.seatUid).toBeDefined();
const secondSeatUid = secondBooking.seatUid;
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
expect(secondBooking.start).toEqual(expectedStart);
expect(secondBooking.end).toEqual(
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(secondBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(secondBooking.attendees.length).toEqual(1);
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
},
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
createdRecurringSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should book a recurring event type with seats for the second time", async () => {
const recurrenceCount = 2;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: recurringSeatedEventTypeId,
attendee: {
name: nameAttendeeTwo,
email: emailAttendeeTwo,
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
recurrenceCount,
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateRecurringSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateRecurringSeatedBooking(responseBody.data)) {
const data: CreateRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
expect(data.length).toEqual(recurrenceCount);
const firstBooking = data[0];
expect(firstBooking.seatUid).toBeDefined();
const seatUid = firstBooking.seatUid;
expect(firstBooking.id).toBeDefined();
expect(firstBooking.uid).toBeDefined();
expect(firstBooking.hosts[0].id).toEqual(user.id);
expect(firstBooking.status).toEqual("accepted");
expect(firstBooking.start).toEqual(body.start);
expect(firstBooking.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(firstBooking.duration).toEqual(60);
expect(firstBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(firstBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(firstBooking.attendees.length).toEqual(2);
expect(firstBooking.attendees[0]).toEqual({
name: nameAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: createdRecurringSeatedBooking[0].seatUid,
bookingFieldsResponses: {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
},
});
expect(firstBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
},
});
expect(firstBooking.location).toEqual(body.location);
expect(firstBooking.absentHost).toEqual(false);
const secondBooking = data[1];
expect(secondBooking.seatUid).toBeDefined();
const secondSeatUid = secondBooking.seatUid;
expect(secondBooking.id).toBeDefined();
expect(secondBooking.uid).toBeDefined();
expect(secondBooking.hosts[0].id).toEqual(user.id);
expect(secondBooking.status).toEqual("accepted");
const expectedStart = DateTime.fromISO(body.start, { zone: "utc" }).plus({ weeks: 1 }).toISO();
expect(secondBooking.start).toEqual(expectedStart);
expect(secondBooking.end).toEqual(
DateTime.fromISO(expectedStart!, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(secondBooking.duration).toEqual(60);
expect(secondBooking.eventTypeId).toEqual(recurringSeatedEventTypeId);
expect(secondBooking.eventType).toEqual({
id: recurringSeatedEventTypeId,
slug: recurringSeatedTventTypeSlug,
});
expect(secondBooking.attendees.length).toEqual(2);
expect(secondBooking.attendees[0]).toEqual({
name: nameAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: createdRecurringSeatedBooking[1].seatUid,
bookingFieldsResponses: {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
},
});
expect(secondBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
},
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
createdRecurringSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
function responseDataIsCreateRecurringSeatedBooking(
data: any
): data is CreateRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
function responseDataIsGetSeatedBooking(data: any): data is GetSeatedBookingOutput_2024_08_13 {
return data?.attendees?.every((attendee: any) => attendee?.hasOwnProperty("seatUid"));
}
function responseDataIsGetRecurringSeatedBooking(
data: any
): data is GetRecurringSeatedBookingOutput_2024_08_13[] {
return Array.isArray(data);
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
});
@@ -0,0 +1,251 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { EventType, User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("User bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
const userEmail = "bookings-controller-e2e@api.com";
let user: User;
let variableLengthEventType: EventType;
const variableLengthEventTypeSlug = "variable-length-event";
let normalEventType: EventType;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await teamRepositoryFixture.create({ name: "organization bookings" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
variableLengthEventType = await eventTypesRepositoryFixture.create(
{
title: "variable length event",
slug: variableLengthEventTypeSlug,
length: 15,
metadata: { multipleDuration: [15, 30, 60] },
},
user.id
);
normalEventType = await eventTypesRepositoryFixture.create(
{ title: "normal event", slug: "normal-event", length: 15 },
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
});
describe("create bookings", () => {
it("should not be able to specify length of booking for non variable length event type", async () => {
const lengthInMinutes = 30;
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: normalEventType.id,
lengthInMinutes,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should create a booking with default length", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 10, 0, 0)).toISOString(),
eventTypeId: variableLengthEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
new Date(Date.UTC(2030, 0, 8, 10, variableLengthEventType.length, 0)).toISOString()
);
expect(data.duration).toEqual(variableLengthEventType.length);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
it("should create a booking with specified length that is not default length", async () => {
const lengthInMinutes = 30;
const body: CreateBookingInput_2024_08_13 = {
lengthInMinutes,
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: variableLengthEventType.id,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "https://meet.google.com/abc-def-ghi",
bookingFieldsResponses: {
customField: "customValue",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 11, lengthInMinutes, 0)).toISOString());
expect(data.duration).toEqual(lengthInMinutes);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
);
}
});
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(user.email);
await bookingsRepositoryFixture.deleteAllBookings(user.id, user.email);
await app.close();
});
});
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
});
@@ -0,0 +1,21 @@
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { ReassignBookingOutput_2024_08_13 as ReassignBookingOutputData_2024_08_13 } from "@calcom/platform-types";
export class ReassignBookingOutput_2024_08_13 {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
oneOf: [{ $ref: getSchemaPath(ReassignBookingOutputData_2024_08_13) }],
description:
"Booking data, which can be either a ReassignAutoBookingOutput object or a ReassignManualBookingOutput object",
})
@ValidateNested()
@Type(() => Object)
data!: ReassignBookingOutputData_2024_08_13;
}
@@ -3,7 +3,10 @@ import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servic
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
@@ -14,7 +17,10 @@ import {
getAllUserBookings,
handleInstantMeeting,
handleCancelBooking,
roundRobinReassignment,
roundRobinManualReassignment,
handleMarkNoShow,
confirmBookingHandler,
} from "@calcom/platform-libraries";
import {
CreateBookingInput_2024_08_13,
@@ -22,8 +28,8 @@ import {
CreateRecurringBookingInput_2024_08_13,
GetBookingsInput_2024_08_13,
CreateInstantBookingInput_2024_08_13,
CancelBookingInput_2024_08_13,
MarkAbsentBookingInput_2024_08_13,
ReassignToUserBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
@@ -46,9 +52,12 @@ export class BookingsService_2024_08_13 {
private readonly inputService: InputBookingsService_2024_08_13,
private readonly outputService: OutputBookingsService_2024_08_13,
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly bookingSeatRepository: BookingSeatRepository,
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly prismaReadService: PrismaReadService,
private readonly billingService: BillingService
private readonly billingService: BillingService,
private readonly usersService: UsersService,
private readonly usersRepository: UsersRepository
) {}
async createBooking(request: Request, body: CreateBookingInput) {
@@ -107,7 +116,7 @@ export class BookingsService_2024_08_13 {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
const bookings = await handleNewRecurringBooking(bookingRequest);
return this.outputService.getOutputCreateRecurringSeatedBookings(
bookings.map((booking) => ({ id: booking.id || 0, seatUid: booking.seatReferenceUid || "" }))
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
);
}
@@ -282,6 +291,20 @@ export class BookingsService_2024_08_13 {
}
async cancelBooking(request: Request, bookingUid: string, body: CancelBookingInput) {
if (this.inputService.isCancelSeatedBody(body)) {
const seat = await this.bookingSeatRepository.getByReferenceUid(body.seatUid);
if (!seat) {
throw new BadRequestException(
"Invalid seatUid: this seat does not exist or has already been cancelled."
);
}
if (seat && bookingUid !== seat.booking.uid) {
throw new BadRequestException("Invalid seatUid: this seat does not belong to this booking.");
}
}
const bookingRequest = await this.inputService.createCancelBookingRequest(request, bookingUid, body);
await handleCancelBooking(bookingRequest);
return this.getBooking(bookingUid);
@@ -342,4 +365,95 @@ export class BookingsService_2024_08_13 {
fromReschedule: oldBookingUid,
});
}
async reassignBooking(bookingUid: string, requestUser: UserWithProfile) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const profile = this.usersService.getUserMainProfile(requestUser);
await roundRobinReassignment({
bookingId: booking.id,
orgId: profile?.organizationId || null,
});
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!reassigned) {
throw new NotFoundException(`Reassigned booking with uid=${bookingUid} was not found in the database`);
}
return this.outputService.getOutputReassignedBooking(reassigned);
}
async reassignBookingToUser(
bookingUid: string,
newUserId: number,
reassignedById: number,
body: ReassignToUserBookingInput_2024_08_13
) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
const user = await this.usersRepository.findByIdWithProfile(newUserId);
if (!user) {
throw new NotFoundException(`User with id=${newUserId} was not found in the database`);
}
const profile = this.usersService.getUserMainProfile(user);
const reassigned = await roundRobinManualReassignment({
bookingId: booking.id,
newUserId,
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
});
return this.outputService.getOutputReassignedBooking(reassigned);
}
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
await confirmBookingHandler({
ctx: {
user: requestUser,
},
input: {
bookingId: booking.id,
confirmed: true,
recurringEventId: booking.recurringEventId,
},
});
return this.getBooking(bookingUid);
}
async declineBooking(bookingUid: string, requestUser: UserWithProfile, reason?: string) {
const booking = await this.bookingsRepository.getByUid(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid=${bookingUid} was not found in the database`);
}
await confirmBookingHandler({
ctx: {
user: requestUser,
},
input: {
bookingId: booking.id,
confirmed: false,
recurringEventId: booking.recurringEventId,
reason,
},
});
return this.getBooking(bookingUid);
}
}
@@ -19,6 +19,7 @@ import { v4 as uuidv4 } from "uuid";
import { z } from "zod";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { EventTypeMetaDataSchema } from "@calcom/platform-libraries";
import {
CancelBookingInput,
CancelBookingInput_2024_08_13,
@@ -32,6 +33,7 @@ import {
RescheduleBookingInput_2024_08_13,
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { EventType } from "@calcom/prisma/client";
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
@@ -91,6 +93,7 @@ export class InputBookingsService_2024_08_13 {
): Promise<BookingRequest> {
const bodyTransformed = await this.transformInputCreateBooking(body);
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
const requestId = request.get("X-Request-Id");
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
@@ -99,6 +102,13 @@ export class InputBookingsService_2024_08_13 {
: DEFAULT_PLATFORM_PARAMS;
const location = request.body.location || request.body.meetingUrl;
this.logger.log(`createBookingRequest_2024_04_15`, {
requestId,
ownerId: userId,
location,
oAuthClientId,
...oAuthParams,
});
Object.assign(newRequest, { userId, ...oAuthParams, platformBookingLocation: location });
newRequest.body = { ...bodyTransformed, noEmail: !oAuthParams.arePlatformEmailsEnabled };
@@ -115,10 +125,13 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
const startTime = DateTime.fromISO(inputBooking.start, { zone: "utc" }).setZone(
inputBooking.attendee.timeZone
);
const endTime = startTime.plus({ minutes: eventType.length });
const endTime = startTime.plus({ minutes: lengthInMinutes });
return {
start: startTime.toISO(),
@@ -142,6 +155,25 @@ export class InputBookingsService_2024_08_13 {
};
}
validateBookingLengthInMinutes(inputBooking: CreateBookingInput_2024_08_13, eventType: EventType) {
const eventTypeMetadata = EventTypeMetaDataSchema.parse(eventType.metadata);
if (inputBooking.lengthInMinutes && !eventTypeMetadata?.multipleDuration) {
throw new BadRequestException(
"Can't specify 'lengthInMinutes' because event type does not have multiple possible lengths. Please, remove the 'lengthInMinutes' field from the request."
);
}
if (
inputBooking.lengthInMinutes &&
!eventTypeMetadata?.multipleDuration?.includes(inputBooking.lengthInMinutes)
) {
throw new BadRequestException(
`Provided 'lengthInMinutes' is not one of the possible lengths for the event type. The possible lengths are: ${eventTypeMetadata?.multipleDuration?.join(
", "
)}`
);
}
}
async createRecurringBookingRequest(
request: Request,
body: CreateRecurringBookingInput_2024_08_13
@@ -10,6 +10,7 @@ import {
CreateSeatedBookingOutput_2024_08_13,
GetRecurringSeatedBookingOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
ReassignBookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
SeatedAttendee,
} from "@calcom/platform-types";
@@ -50,6 +51,8 @@ type DatabaseBooking = Booking & {
}[];
} & { user: { id: number; name: string | null; email: string } | null };
type BookingWithUser = Booking & { user: { id: number; name: string | null; email: string } | null };
@Injectable()
export class OutputBookingsService_2024_08_13 {
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
@@ -230,14 +233,14 @@ export class OutputBookingsService_2024_08_13 {
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
}
async getOutputCreateRecurringSeatedBookings(bookings: { id: number; seatUid: string }[]) {
async getOutputCreateRecurringSeatedBookings(bookings: { uid: string; seatUid: string }[]) {
const transformed = [];
for (const booking of bookings) {
const databaseBooking =
await this.bookingsRepository.getByIdWithAttendeesWithBookingSeatAndUserAndEvent(booking.id);
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
if (!databaseBooking) {
throw new Error(`Booking with id=${booking.id} was not found in the database`);
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
transformed.push(this.getOutputCreateRecurringSeatedBooking(databaseBooking, booking.seatUid));
}
@@ -308,4 +311,17 @@ export class OutputBookingsService_2024_08_13 {
return parsed;
}
getOutputReassignedBooking(
databaseBooking: Pick<BookingWithUser, "uid" | "user">
): ReassignBookingOutput_2024_08_13 {
return {
bookingUid: databaseBooking.uid,
reassignedTo: {
id: databaseBooking?.user?.id || 0,
name: databaseBooking?.user?.name || "unknown",
email: databaseBooking?.user?.email || "unknown",
},
};
}
}
@@ -212,6 +212,7 @@ describe("Event types Endpoints", () => {
slug: "coding-class",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
lengthInMinutesOptions: [30, 60, 90],
locations: [
{
type: "integration",
@@ -300,6 +301,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.description).toEqual(body.description);
expect(createdEventType.lengthInMinutes).toEqual(body.lengthInMinutes);
expect(createdEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
expect(createdEventType.locations).toEqual(body.locations);
expect(createdEventType.ownerId).toEqual(user.id);
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
@@ -360,6 +362,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
@@ -665,6 +668,7 @@ describe("Event types Endpoints", () => {
const body: UpdateEventTypeInput_2024_06_14 = {
title: newTitle,
scheduleId: secondSchedule.id,
lengthInMinutesOptions: [15, 30],
bookingFields: [
nameBookingField,
{
@@ -733,6 +737,7 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.id).toEqual(eventType.id);
expect(updatedEventType.title).toEqual(newTitle);
expect(updatedEventType.lengthInMinutesOptions).toEqual(body.lengthInMinutesOptions);
expect(updatedEventType.description).toEqual(eventType.description);
expect(updatedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(updatedEventType.locations).toEqual(eventType.locations);
@@ -772,6 +777,7 @@ describe("Event types Endpoints", () => {
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
eventType.lengthInMinutesOptions = updatedEventType.lengthInMinutesOptions;
eventType.bookingLimitsCount = updatedEventType.bookingLimitsCount;
eventType.onlyShowFirstAvailableSlot = updatedEventType.onlyShowFirstAvailableSlot;
eventType.bookingLimitsDuration = updatedEventType.bookingLimitsDuration;
@@ -819,6 +825,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.title).toEqual(eventType.title);
expect(fetchedEventType.description).toEqual(eventType.description);
expect(fetchedEventType.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType.locations).toEqual(eventType.locations);
expect(fetchedEventType.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType.ownerId).toEqual(user.id);
@@ -857,6 +864,7 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields).toEqual(eventType.bookingFields);
expect(fetchedEventType?.ownerId).toEqual(user.id);
@@ -915,7 +923,7 @@ describe("Event types Endpoints", () => {
});
});
describe("Handle legacy event-types booking fields", () => {
describe("Handle event-types booking fields", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
@@ -940,6 +948,7 @@ describe("Event types Endpoints", () => {
{ isDefault: true, required: false, slug: "notes", type: "textarea" },
{ isDefault: true, required: false, slug: "guests", type: "multiemail" },
{ isDefault: true, required: false, slug: "rescheduleReason", type: "textarea" },
{ isDefault: true, type: "phone", slug: "attendeePhoneNumber", required: false },
];
beforeAll(async () => {
@@ -1092,6 +1101,21 @@ describe("Event types Endpoints", () => {
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
{
name: "attendeePhoneNumber",
type: "phone",
hidden: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "phone_number",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
@@ -1207,6 +1231,21 @@ describe("Event types Endpoints", () => {
defaultLabel: "reason_for_reschedule",
defaultPlaceholder: "reschedule_placeholder",
},
{
name: "attendeePhoneNumber",
type: "phone",
hidden: true,
sources: [
{
id: "default",
type: "default",
label: "Default",
},
],
editable: "system-but-optional",
required: false,
defaultLabel: "phone_number",
},
],
};
const legacyEventType = await eventTypesRepositoryFixture.create(legacyEventTypeInput, user.id);
@@ -1234,6 +1273,204 @@ describe("Event types Endpoints", () => {
});
});
it("should return event type with unknown bookingField", async () => {
const unknownSystemField = {
name: "unknown-whatever",
type: "unknown-whatever",
label: "your team",
sources: [
{
id: "user",
type: "user",
label: "User",
fieldRequired: true,
},
],
editable: "user",
required: true,
placeholder: "FC Barcelona",
};
const eventTypeInput = {
title: "unknown field event type two",
description: "unknown field event type description two",
length: 40,
hidden: false,
slug: "unknown-field-type-two",
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [unknownSystemField],
};
const eventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
return request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([
{
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(unknownSystemField),
},
]);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
try {
await eventTypesRepositoryFixture.delete(legacyEventTypeId1);
await eventTypesRepositoryFixture.delete(legacyEventTypeId2);
} catch (e) {
// Event type might have been deleted by the test
}
try {
await userRepositoryFixture.delete(user.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
describe("Handle event-types locations", () => {
let app: INestApplication;
let oAuthClient: PlatformOAuthClient;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
const userEmail = "locations-event-types-test-e2e@api.com";
const name = "bob-the-locations-builder";
const username = name;
let user: User;
let legacyEventTypeId1: number;
let legacyEventTypeId2: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
name,
username,
});
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["redirect-uri"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
it("should return integration location with link and credentialId", async () => {
const eventTypeInput = {
title: "event type discord",
description: "event type description",
length: 40,
hidden: false,
slug: "discord-event-type",
locations: [
{
type: "integrations:discord_video",
link: "https://discord.com/users/100",
credentialId: 100,
},
],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [],
};
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
legacyEventTypeId1 = legacyEventType.id;
return request(app.getHttpServer())
.get(`/api/v2/event-types/${legacyEventTypeId1}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.locations).toEqual([
{
type: "integration",
integration: "discord-video",
link: eventTypeInput.locations[0].link,
credentialId: eventTypeInput.locations[0].credentialId,
},
]);
});
});
it("should return unsupported location", async () => {
const eventTypeInput = {
title: "event type not existing",
description: "event type description",
length: 40,
hidden: false,
slug: "not-existing-event-type",
locations: [
{
type: "this-type-does-not-exist",
},
],
schedulingType: SchedulingType.ROUND_ROBIN,
bookingFields: [],
};
const legacyEventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
legacyEventTypeId1 = legacyEventType.id;
return request(app.getHttpServer())
.get(`/api/v2/event-types/${legacyEventTypeId1}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.locations).toEqual([
{ type: "unknown", location: JSON.stringify(eventTypeInput.locations[0]) },
]);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -104,6 +104,7 @@ export class InputEventTypesService_2024_06_14 {
const {
lengthInMinutes,
lengthInMinutesOptions,
locations,
bookingFields,
bookingLimitsCount,
@@ -135,6 +136,7 @@ export class InputEventTypesService_2024_06_14 {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
requiresConfirmationWillBlockSlot:
@@ -152,6 +154,7 @@ export class InputEventTypesService_2024_06_14 {
async transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14, eventTypeId: number) {
const {
lengthInMinutes,
lengthInMinutesOptions,
locations,
bookingFields,
bookingLimitsCount,
@@ -191,6 +194,7 @@ export class InputEventTypesService_2024_06_14 {
bookerLayouts: this.transformInputBookerLayouts(bookerLayouts),
requiresConfirmationThreshold:
confirmationPolicyTransformed?.requiresConfirmationThreshold ?? undefined,
multipleDuration: lengthInMinutesOptions,
},
recurringEvent: recurrence ? this.transformInputRecurrignEvent(recurrence) : undefined,
requiresConfirmation: confirmationPolicyTransformed?.requiresConfirmation ?? undefined,
@@ -7,8 +7,7 @@ import {
transformLocationsInternalToApi,
transformBookingFieldsInternalToApi,
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
InternalLocationSchema,
SystemField,
CustomField,
parseBookingLimit,
@@ -20,12 +19,16 @@ import {
transformEventTypeColorsInternalToApi,
parseEventTypeColor,
transformSeatsInternalToApi,
InternalLocation,
BookingFieldSchema,
} from "@calcom/platform-libraries";
import {
TransformFutureBookingsLimitSchema_2024_06_14,
BookerLayoutsTransformedSchema,
NoticeThresholdTransformedSchema,
EventTypeOutput_2024_06_14,
OutputUnknownLocation_2024_06_14,
OutputUnknownBookingField_2024_06_14,
} from "@calcom/platform-types";
type EventTypeRelations = {
@@ -118,7 +121,7 @@ export class OutputEventTypesService_2024_06_14 {
const locations = this.transformLocations(databaseEventType.locations);
const customName = databaseEventType?.eventName ?? undefined;
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(BookingFieldsSchema.parse(databaseEventType.bookingFields))
? this.transformBookingFields(databaseEventType.bookingFields)
: [];
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
@@ -150,6 +153,7 @@ export class OutputEventTypesService_2024_06_14 {
id,
ownerId,
lengthInMinutes: length,
lengthInMinutesOptions: metadata.multipleDuration,
title,
slug,
description: description || "",
@@ -190,7 +194,20 @@ export class OutputEventTypesService_2024_06_14 {
transformLocations(locations: any) {
if (!locations) return [];
return transformLocationsInternalToApi(TransformedLocationsSchema.parse(locations));
const knownLocations: InternalLocation[] = [];
const unknownLocations: OutputUnknownLocation_2024_06_14[] = [];
for (const location of locations) {
const result = InternalLocationSchema.safeParse(location);
if (result.success) {
knownLocations.push(result.data);
} else {
unknownLocations.push({ type: "unknown", location: JSON.stringify(location) });
}
}
return [...transformLocationsInternalToApi(knownLocations), ...unknownLocations];
}
transformDestinationCalendar(destinationCalendar?: DestinationCalendar | null) {
@@ -201,10 +218,26 @@ export class OutputEventTypesService_2024_06_14 {
};
}
transformBookingFields(bookingFields: (SystemField | CustomField)[] | null) {
transformBookingFields(bookingFields: any) {
if (!bookingFields) return [];
return transformBookingFieldsInternalToApi(bookingFields);
const knownBookingFields: (SystemField | CustomField)[] = [];
const unknownBookingFields: OutputUnknownBookingField_2024_06_14[] = [];
for (const bookingField of bookingFields) {
const result = BookingFieldSchema.safeParse(bookingField);
if (result.success) {
knownBookingFields.push(result.data);
} else {
unknownBookingFields.push({
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(bookingField),
});
}
}
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
}
transformRecurringEvent(recurringEvent: any) {
+2
View File
@@ -10,6 +10,8 @@ export type Environment = {
DATABASE_URL: string;
JWT_SECRET: string;
SENTRY_DSN: string;
SENTRY_TRACES_SAMPLE_RATE?: number;
SENTRY_PROFILES_SAMPLE_RATE?: number;
LOG_LEVEL: keyof typeof logLevels;
REDIS_URL: string;
STRIPE_API_KEY: string;
+7 -4
View File
@@ -1,12 +1,15 @@
import { getEnv } from "@/env";
import * as Sentry from "@sentry/node";
import * as Sentry from "@sentry/nestjs";
import { nodeProfilingIntegration } from "@sentry/profiling-node";
if (process.env.SENTRY_DSN) {
// Ensure to call this before requiring any other modules!
Sentry.init({
dsn: getEnv("SENTRY_DSN"),
// Add Performance Monitoring by setting tracesSampleRate
// We recommend adjusting this value in production
// todo: Evaluate? tracesSampleRate: 1.0
integrations: [nodeProfilingIntegration()],
// Performance Monitoring
tracesSampleRate: getEnv("SENTRY_TRACES_SAMPLE_RATE") ?? 1.0, // Capture 100% of the transactions
// Set sampling rate for profiling - this is relative to tracesSampleRate
profilesSampleRate: getEnv("SENTRY_PROFILES_SAMPLE_RATE") ?? 1.0,
});
}
+12 -4
View File
@@ -1,4 +1,5 @@
import type { AppConfig } from "@/config/type";
import { getEnv } from "@/env";
import { Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
@@ -107,13 +108,20 @@ async function generateSwagger(app: NestExpressApplication<Server>) {
const document = SwaggerModule.createDocument(app, config);
document.paths = groupAndSortPathsByFirstTag(document.paths);
const outputFile = "./swagger/documentation.json";
const swaggerOutputFile = "./swagger/documentation.json";
const docsOutputFile = "../../../docs/api-reference/v2/openapi.json";
const stringifiedContents = JSON.stringify(document, null, 2);
if (fs.existsSync(outputFile)) {
fs.unlinkSync(outputFile);
if (fs.existsSync(swaggerOutputFile)) {
fs.unlinkSync(swaggerOutputFile);
}
fs.writeFileSync(outputFile, JSON.stringify(document, null, 2), { encoding: "utf8" });
fs.writeFileSync(swaggerOutputFile, stringifiedContents, { encoding: "utf8" });
if (fs.existsSync(docsOutputFile) && getEnv("NODE_ENV") === "development") {
fs.unlinkSync(docsOutputFile);
fs.writeFileSync(docsOutputFile, stringifiedContents, { encoding: "utf8" });
}
if (!process.env.DOCS_URL) {
SwaggerModule.setup("docs", app, document, {
@@ -21,19 +21,15 @@ export class ResponseInterceptor implements NestInterceptor {
const { statusCode } = response;
const responseTime = Date.now() - startTime;
// Log response in JSON format
this.logger.log(
JSON.stringify({
requestId,
method,
url,
statusCode,
responseTime,
responseBody: data,
timestamp: new Date().toISOString(),
message: "Outgoing Response",
})
);
this.logger.log("Outgoing Response", {
requestId,
method,
url,
statusCode,
responseTime,
responseBody: data,
timestamp: new Date().toISOString(),
});
})
);
}
@@ -11,17 +11,14 @@ export class RequestIdMiddleware implements NestMiddleware {
req.headers["X-Request-Id"] = requestId;
const { method, headers, body: requestBody, baseUrl } = req;
this.logger.log(
JSON.stringify({
requestId,
method,
url: baseUrl,
headers,
requestBody,
timestamp: new Date().toISOString(),
message: "Incoming Request",
})
);
this.logger.log("Incoming Request", {
requestId,
method,
url: baseUrl,
headers,
requestBody,
timestamp: new Date().toISOString(),
});
next();
}
@@ -21,4 +21,15 @@ export class AppsRepository {
},
});
}
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
type: type,
key: key,
teamId: teamId,
appId: appId,
},
});
}
}
@@ -1,5 +1,6 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@@ -17,6 +18,7 @@ import { Module } from "@nestjs/common";
MembershipsRepository,
CredentialsRepository,
UsersRepository,
AtomsRepository,
UsersService,
SchedulesRepository_2024_06_11,
],
@@ -0,0 +1,17 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { paymentDataSelect } from "@calcom/platform-libraries";
@Injectable()
export class AtomsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getRawPayment(uid: string) {
return await this.dbRead.prisma.payment.findFirst({
where: { uid },
select: paymentDataSelect,
});
}
}
@@ -72,6 +72,18 @@ export class AtomsController {
};
}
@Get("payment/:uid")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getUserPaymentInfoById(@Param("uid") uid: string): Promise<ApiResponse<unknown>> {
const data = await this.eventTypesService.getUserPaymentInfo(uid);
return {
status: SUCCESS_STATUS,
data,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -1,4 +1,5 @@
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
@@ -17,6 +18,7 @@ import {
getEnabledAppsFromCredentials,
getAppFromSlug,
MembershipRole,
EventTypeMetaDataSchema,
} from "@calcom/platform-libraries";
import type {
App,
@@ -27,6 +29,7 @@ import type {
TDependencyData,
CredentialPayload,
} from "@calcom/platform-libraries";
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma/client";
type EnabledAppType = App & {
@@ -40,6 +43,7 @@ export class EventTypesAtomService {
constructor(
private readonly membershipsRepository: MembershipsRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly atomsRepository: AtomsRepository,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly usersService: UsersService,
private readonly dbWrite: PrismaWriteService,
@@ -274,4 +278,43 @@ export class EventTypesAtomService {
);
return apps[0];
}
async getUserPaymentInfo(uid: string) {
const rawPayment = await this.atomsRepository.getRawPayment(uid);
if (!rawPayment) throw new NotFoundException(`Payment with uid ${uid} not found`);
const { data, booking: _booking, ...restPayment } = rawPayment;
const payment = {
...restPayment,
data: data as Record<string, unknown>,
};
if (!_booking) throw new NotFoundException(`Booking with uid ${uid} not found`);
const { startTime, endTime, eventType, ...restBooking } = _booking;
const booking = {
...restBooking,
startTime: startTime.toString(),
endTime: endTime.toString(),
};
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
if (eventType.users.length === 0 && !!!eventType.team)
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
const [user] = eventType?.users.length
? eventType.users
: [{ name: null, theme: null, hideBranding: null, username: null }];
const profile = {
name: eventType.team?.name || user?.name || null,
theme: (!eventType.team?.name && user?.theme) || null,
hideBranding: eventType.team?.hideBranding || user?.hideBranding || null,
};
return {
user,
eventType: {
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),
},
booking,
payment,
clientSecret: getClientSecretFromPayment(payment),
profile,
};
}
}
@@ -46,9 +46,9 @@ export class BillingProcessor {
return;
}
const stripeSubscription = await this.stripeService.stripe.subscriptions.retrieve(
billingSubscription.subscriptionId
);
const stripeSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(billingSubscription.subscriptionId);
if (!stripeSubscription?.id) {
this.logger.error(`Failed to retrieve stripe subscription (${billingSubscription.subscriptionId})`, {
teamId,
@@ -69,7 +69,7 @@ export class BillingProcessor {
return;
}
await this.stripeService.stripe.subscriptionItems.createUsageRecord(meteredItem.id, {
await this.stripeService.getStripe().subscriptionItems.createUsageRecord(meteredItem.id, {
action: "increment",
quantity: 1,
timestamp: "now",
@@ -1,10 +1,11 @@
import { PlatformPlan } from "@/modules/billing/types";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Injectable, Logger } from "@nestjs/common";
@Injectable()
export class BillingRepository {
private readonly logger = new Logger("BillingRepository");
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
getBillingForTeam = (teamId: number) =>
@@ -30,6 +31,35 @@ export class BillingRepository {
billingCycleEnd: billingEnd,
subscriptionId,
plan: plan.toString(),
overdue: false,
},
});
}
async updateBillingOverdue(subId: string, cusId: string, overdue: boolean) {
try {
return this.dbWrite.prisma.platformBilling.update({
where: {
subscriptionId: subId,
customerId: cusId,
},
data: {
overdue,
},
});
} catch (err) {
this.logger.error("Could not update billing overdue", {
subId,
cusId,
err,
});
}
}
async deleteBilling(id: number) {
return this.dbWrite.prisma.platformBilling.delete({
where: {
id,
},
});
}
@@ -0,0 +1,211 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeService } from "@/modules/stripe/stripe.service";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { UserWithProfile } from "@/modules/users/users.repository";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import Stripe from "stripe";
import * as request from "supertest";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { Team, PlatformOAuthClient, PlatformBilling } from "@calcom/prisma/client";
describe("Platform Billing Controller (e2e)", () => {
let app: INestApplication;
const userEmail = "platform-billing-controller-e2e@api.com";
let user: UserWithProfile;
let billing: PlatformBilling;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let organization: Team;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
organization = await organizationsRepositoryFixture.create({ name: "platform billing" });
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
team: { connect: { id: organization.id } },
user: { connect: { id: user.id } },
accepted: true,
});
billing = await platformBillingRepositoryFixture.create(organization.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
userRepositoryFixture.deleteByEmail(user.email);
await app.close();
});
it("/billing/webhook (POST) should set billing free plan for org", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "checkout.session.completed",
data: {
object: {
metadata: {
teamId: organization.id,
plan: "FREE",
},
mode: "subscription",
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.plan).toEqual("FREE");
});
});
it("/billing/webhook (POST) failed payment should set billing free plan to overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "invoice.payment_failed",
data: {
object: {
customer: billing?.customerId,
subscription: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.overdue).toEqual(true);
});
});
it("/billing/webhook (POST) success payment should set billing free plan to not overdue", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "invoice.payment_succeeded",
data: {
object: {
customer: billing?.customerId,
subscription: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing?.overdue).toEqual(false);
});
});
it("/billing/webhook (POST) should delete subscription", () => {
jest.spyOn(StripeService.prototype, "getStripe").mockImplementation(
() =>
({
webhooks: {
constructEventAsync: async () => {
return {
type: "customer.subscription.deleted",
data: {
object: {
metadata: {
teamId: organization.id,
plan: "FREE",
},
id: billing?.subscriptionId,
},
},
};
},
},
} as unknown as Stripe)
);
return request(app.getHttpServer())
.post("/v2/billing/webhook")
.expect(200)
.then(async (res) => {
const billing = await platformBillingRepositoryFixture.get(organization.id);
expect(billing).toBeNull();
});
});
});
@@ -102,13 +102,26 @@ export class BillingController {
@Req() request: Request,
@Headers("stripe-signature") stripeSignature: string
): Promise<ApiResponse> {
const event = await this.billingService.stripeService.stripe.webhooks.constructEventAsync(
request.body,
stripeSignature,
this.stripeWhSecret
);
const event = await this.billingService.stripeService
.getStripe()
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
await this.billingService.createOrUpdateStripeSubscription(event);
switch (event.type) {
case "checkout.session.completed":
await this.billingService.handleStripeCheckoutEvents(event);
break;
case "customer.subscription.deleted":
await this.billingService.handleStripeSubscriptionDeleted(event);
break;
case "invoice.payment_failed":
await this.billingService.handleStripePaymentFailed(event);
break;
case "invoice.payment_succeeded":
await this.billingService.handleStripePaymentSuccess(event);
break;
default:
break;
}
return {
status: "success",
@@ -64,7 +64,7 @@ export class BillingService implements OnModuleDestroy {
}
async redirectToSubscribeCheckout(teamId: number, plan: PlatformPlan, customerId?: string) {
const { url } = await this.stripeService.stripe.checkout.sessions.create({
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
customer: customerId,
line_items: [
{
@@ -101,7 +101,7 @@ export class BillingService implements OnModuleDestroy {
const teamWithBilling = await this.teamsRepository.findByIdIncludeBilling(teamId);
const customerId = teamWithBilling?.platformBilling?.customerId;
const { url } = await this.stripeService.stripe.checkout.sessions.create({
const { url } = await this.stripeService.getStripe().checkout.sessions.create({
customer: customerId,
success_url: `${this.webAppUrl}/settings/platform/`,
cancel_url: `${this.webAppUrl}/settings/platform/plans`,
@@ -131,41 +131,92 @@ export class BillingService implements OnModuleDestroy {
);
}
async createOrUpdateStripeSubscription(event: Stripe.Event) {
if (event.type === "checkout.session.completed") {
const subscription = event.data.object as Stripe.Checkout.Session;
if (!subscription.metadata?.teamId) {
return {
status: "success",
};
}
const teamId = Number.parseInt(subscription.metadata.teamId);
const plan = subscription.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return {
status: "success",
};
}
if (subscription.mode === "subscription") {
await this.setSubscriptionForTeam(
async handleStripeSubscriptionDeleted(event: Stripe.Event) {
const subscription = event.data.object as Stripe.Subscription;
const teamId = subscription?.metadata?.teamId;
const plan = PlatformPlan[subscription?.metadata?.plan?.toUpperCase() as keyof typeof PlatformPlan];
if (teamId && plan) {
const currentBilling = await this.billingRepository.getBillingForTeam(Number.parseInt(teamId));
if (currentBilling?.subscriptionId === subscription.id) {
await this.billingRepository.deleteBilling(currentBilling.id);
this.logger.log(`Stripe Subscription deleted`, {
customerId: currentBilling.customerId,
subscriptionId: currentBilling.subscriptionId,
teamId,
subscription.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
});
return;
}
if (subscription.mode === "setup") {
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
}
return {
status: "success",
};
this.logger.log("No platform billing found.");
return;
}
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return;
}
getSubscriptionIdFromInvoice(invoice: Stripe.Invoice): string | null {
if (typeof invoice.subscription === "string") {
return invoice.subscription;
} else if (invoice.subscription && typeof invoice.subscription === "object") {
return invoice.subscription.id;
} else {
return null;
}
}
getCustomerIdFromInvoice(invoice: Stripe.Invoice): string | null {
if (typeof invoice.customer === "string") {
return invoice.customer;
} else if (invoice.customer && typeof invoice.customer === "object") {
return invoice.customer.id;
} else {
return null;
}
}
async handleStripePaymentSuccess(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
if (subscriptionId && customerId) {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, false);
}
}
async handleStripePaymentFailed(event: Stripe.Event) {
const invoice = event.data.object as Stripe.Invoice;
const subscriptionId = this.getSubscriptionIdFromInvoice(invoice);
const customerId = this.getCustomerIdFromInvoice(invoice);
if (subscriptionId && customerId) {
await this.billingRepository.updateBillingOverdue(subscriptionId, customerId, true);
}
}
async handleStripeCheckoutEvents(event: Stripe.Event) {
const checkoutSession = event.data.object as Stripe.Checkout.Session;
if (!checkoutSession.metadata?.teamId) {
return;
}
const teamId = Number.parseInt(checkoutSession.metadata.teamId);
const plan = checkoutSession.metadata.plan;
if (!plan || !teamId) {
this.logger.log("Webhook received but not pertaining to Platform, discarding.");
return;
}
if (checkoutSession.mode === "subscription") {
await this.setSubscriptionForTeam(
teamId,
checkoutSession.subscription as string,
PlatformPlan[plan.toUpperCase() as keyof typeof PlatformPlan]
);
}
if (checkoutSession.mode === "setup") {
await this.updateStripeSubscriptionForTeam(teamId, plan as PlatformPlan);
}
return;
}
async updateStripeSubscriptionForTeam(teamId: number, plan: PlatformPlan) {
@@ -175,9 +226,9 @@ export class BillingService implements OnModuleDestroy {
throw new NotFoundException("Team plan not found");
}
const existingUserSubscription = await this.stripeService.stripe.subscriptions.retrieve(
teamWithBilling?.platformBilling?.subscriptionId
);
const existingUserSubscription = await this.stripeService
.getStripe()
.subscriptions.retrieve(teamWithBilling?.platformBilling?.subscriptionId);
const currentLicensedItem = existingUserSubscription.items.data.find(
(item) => item.price?.recurring?.usage_type === "licensed"
);
@@ -193,21 +244,23 @@ export class BillingService implements OnModuleDestroy {
throw new NotFoundException("There is no overage item present in the subscription");
}
await this.stripeService.stripe.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
items: [
{
id: currentLicensedItem.id,
price: this.billingConfigService.get(plan)?.base,
},
{
id: currentOverageItem.id,
price: this.billingConfigService.get(plan)?.overage,
clear_usage: false,
},
],
billing_cycle_anchor: "now",
proration_behavior: "create_prorations",
});
await this.stripeService
.getStripe()
.subscriptions.update(teamWithBilling?.platformBilling?.subscriptionId, {
items: [
{
id: currentLicensedItem.id,
price: this.billingConfigService.get(plan)?.base,
},
{
id: currentOverageItem.id,
price: this.billingConfigService.get(plan)?.overage,
clear_usage: false,
},
],
billing_cycle_anchor: "now",
proration_behavior: "create_prorations",
});
await this.setSubscriptionForTeam(
teamId,
+2 -1
View File
@@ -1,8 +1,9 @@
export enum PlatformPlan {
FREE = "FREE",
STARTER = "STARTER",
ESSENTIALS = "ESSENTIALS",
SCALE = "SCALE",
ENTERPRISE = "ENTERPRISE",
}
export type PlatformPlanType = "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
export type PlatformPlanType = "FREE" | "STARTER" | "ESSENTIALS" | "SCALE" | "ENTERPRISE";
@@ -12,6 +12,9 @@ export class BookingSeatRepository {
where: {
referenceUid,
},
include: {
booking: { select: { uid: true } },
},
});
}
}
@@ -37,6 +37,10 @@ export class CredentialsRepository {
return this.dbWrite.prisma.credential.findFirst({ where: { type, userId } });
}
getByTypeAndTeamId(type: string, teamId: number) {
return this.dbWrite.prisma.credential.findFirst({ where: { type, teamId } });
}
getAllUserCredentialsByTypeAndId(type: string, userId: number) {
return this.dbRead.prisma.credential.findMany({ where: { type, userId } });
}
@@ -510,7 +510,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(oAuthClient1).toBeDefined();
});
it(`should create managed user and update team event-types of OAuthClient marked as assignAllTeamMembers: true`, async () => {
it(`should create managed user and update team event-types`, async () => {
const requestBody: CreateManagedUserInput = {
email: userEmail,
timeZone: userTimeZone,
@@ -532,48 +532,13 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
await userHasCorrectEventTypes(responseBody.data.user.id);
await teamHasCorrectEventTypes(team1.id);
expect(responseBody.data.user.name).toEqual(requestBody.name);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(5);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): default event types
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutes.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.thirtyMinutesVideo.slug)
).toBeTruthy();
expect(
eventTypes?.find((eventType) => eventType.slug === DEFAULT_EVENT_TYPES.sixtyMinutesVideo.slug)
).toBeTruthy();
}
async function teamHasCorrectEventTypes(teamId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
expect(eventTypes?.length).toEqual(2);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType1.slug)).toBeTruthy();
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
expect(collective).toBeTruthy();
expect(collective?.hosts).toBeDefined();
expect(collective?.hosts?.length).toEqual(1);
expect(collective?.hosts[0].userId).toEqual(postResponseData.user.id);
}
afterAll(async () => {
@@ -25,9 +25,9 @@ import {
import {
ApiTags as DocsTags,
ApiExcludeController as DocsExcludeController,
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiOperation as DocsOperation,
ApiOkResponse as DocsOkResponse,
ApiExcludeEndpoint as DocsExcludeEndpoint,
ApiBadRequestResponse as DocsBadRequestResponse,
ApiHeader as DocsHeader,
} from "@nestjs/swagger";
@@ -50,20 +50,7 @@ export class OAuthFlowController {
@Post("/authorize")
@HttpCode(HttpStatus.OK)
@UseGuards(NextAuthGuard)
@DocsOperation({
summary: "Authorize an OAuth client",
description:
"Redirects the user to the specified 'redirect_uri' with an authorization code in query parameter if the client is authorized successfully. The code is then exchanged for access and refresh tokens via the `/exchange` endpoint.",
})
@DocsOkResponse({
description:
"The user is redirected to the 'redirect_uri' with an authorization code in query parameter e.g. `redirectUri?code=secretcode.`",
})
@DocsBadRequestResponse({
description:
"Bad request if the OAuth client is not found, if the redirect URI is invalid, or if the user has already authorized the client.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
@DocsExcludeEndpoint()
async authorize(
@Param("clientId") clientId: string,
@Body() body: OAuthAuthorizeInput,
@@ -97,20 +84,7 @@ export class OAuthFlowController {
@Post("/exchange")
@HttpCode(HttpStatus.OK)
@DocsOperation({
summary: "Exchange authorization code for access tokens",
description:
"Exchanges the authorization code received from the `/authorize` endpoint for access and refresh tokens. The authorization code should be provided in the 'Authorization' header prefixed with 'Bearer '.",
})
@DocsOkResponse({
type: KeysResponseDto,
description: "Successfully exchanged authorization code for access and refresh tokens.",
})
@DocsBadRequestResponse({
description:
"Bad request if the authorization code is missing, invalid, or if the client ID and secret do not match.",
})
@DocsExcludeEndpoint(getEnv("NODE_ENV") === "production")
@DocsExcludeEndpoint()
async exchange(
@Headers("Authorization") authorization: string,
@Param("clientId") clientId: string,
@@ -83,8 +83,6 @@ export class OAuthClientUsersService {
user.defaultScheduleId = defaultSchedule.id;
}
await this.organizationsTeamsService.addUserToPlatformTeamEvents(user.id, organizationId, oAuthClientId);
return {
user,
tokens: {
@@ -9,8 +9,9 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { EventType, User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -26,6 +27,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
@@ -36,6 +38,8 @@ describe("Organizations Teams Memberships Endpoints", () => {
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
let teamEventType: EventType;
let managedEventType: EventType;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: OrgTeamMembershipOutputDto;
@@ -66,6 +70,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -97,6 +102,32 @@ describe("Organizations Teams Memberships Endpoints", () => {
parent: { connect: { id: org.id } },
});
teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: orgTeam.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: orgTeam.id },
},
title: "Managed Event Type",
slug: "managed-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: "Non Org Team",
isOrganization: false,
@@ -264,7 +295,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
.expect(404);
});
it("should create the membership of the org's team", async () => {
it("should have created the membership of the org's team and assigned team wide events", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.send({
@@ -281,9 +312,22 @@ describe("Organizations Teams Memberships Endpoints", () => {
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
});
});
async function userHasCorrectEventTypes(userId: number) {
const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(orgTeam.id);
expect(managedEventTypes?.length).toEqual(1);
expect(teamEventTypes?.length).toEqual(2);
const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug);
expect(collectiveEvenType).toBeTruthy();
const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId);
expect(userHost).toBeTruthy();
expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
}
it("should fail to create the membership of the org's team for a non org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
@@ -15,6 +15,7 @@ import {
OrgTeamMembershipsOutputResponseDto,
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/outputs/organization-teams-memberships.output";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
import {
Controller,
@@ -30,11 +31,13 @@ import {
HttpStatus,
HttpCode,
UnprocessableEntityException,
Logger,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
@@ -44,8 +47,11 @@ import { SkipTakePagination } from "@calcom/platform-types";
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Teams / Memberships")
export class OrganizationsTeamsMembershipsController {
private logger = new Logger("OrganizationsTeamsMembershipsController");
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private oganizationsEventTypesService: OrganizationsEventTypesService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@@ -112,6 +118,9 @@ export class OrganizationsTeamsMembershipsController {
teamId,
membershipId
);
await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
@@ -129,15 +138,29 @@ export class OrganizationsTeamsMembershipsController {
@Param("membershipId", ParseIntPipe) membershipId: number,
@Body() data: UpdateOrgTeamMembershipDto
): Promise<OrgTeamMembershipOutputResponseDto> {
const membership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
const currentMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership(
orgId,
teamId,
membershipId
);
const updatedMembership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
orgId,
teamId,
membershipId,
data
);
if (!currentMembership.accepted && updatedMembership.accepted) {
try {
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }),
};
}
@@ -158,6 +181,13 @@ export class OrganizationsTeamsMembershipsController {
}
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
if (membership.accepted) {
try {
await updateNewTeamMemberEventTypes(user.id, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
@@ -469,34 +469,13 @@ describe("Organizations Users Endpoints", () => {
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
userHasCorrectEventTypes(userData.id);
createdUser = userData;
teamHasCorrectEventTypes(team.id);
});
async function userHasCorrectEventTypes(userId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
expect(eventTypes?.length).toEqual(1);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
}
async function teamHasCorrectEventTypes(teamId: number) {
const eventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(teamId);
expect(eventTypes?.length).toEqual(2);
// note(Lauris): managed event-types with assignAllTeamMembers: true
expect(eventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
// note(Lauris): check if managed user added to collective event-type hosts given that it has assignAllTeamMembers: true
const collective = eventTypes?.find((eventType) => eventType.schedulingType === "COLLECTIVE");
expect(collective).toBeTruthy();
expect(collective?.hosts).toBeDefined();
expect(collective?.hosts?.length).toEqual(1);
expect(collective?.hosts[0].userId).toEqual(createdUser.id);
}
afterAll(async () => {
@@ -33,7 +33,7 @@ export class OrganizationsRepository {
}
async createNewBillingRelation(orgId: number, plan?: PlatformPlan) {
const { id } = await this.stripeService.stripe.customers.create({
const { id } = await this.stripeService.getStripe().customers.create({
metadata: {
createdBy: "oauth_client_no_csid", // mark in case this is needed in the future.
},
@@ -70,4 +70,26 @@ export class OrganizationsEventTypesRepository {
include: { children: true },
});
}
async deleteUserManagedTeamEventTypes(userId: number, teamId: number) {
return this.dbWrite.prisma.eventType.deleteMany({
where: {
parent: {
teamId,
},
userId,
},
});
}
async removeUserFromTeamEventTypesHosts(userId: number, teamId: number) {
return this.dbWrite.prisma.host.deleteMany({
where: {
userId,
eventType: {
teamId,
},
},
});
}
}
@@ -6,13 +6,15 @@ import { DatabaseTeamEventType } from "@/modules/organizations/services/event-ty
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { InputTeamEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
export class OrganizationsEventTypesService {
private readonly logger = new Logger("OrganizationsEventTypesService");
constructor(
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly dbWrite: PrismaWriteService,
@@ -145,4 +147,17 @@ export class OrganizationsEventTypesService {
return this.eventTypesRepository.deleteEventType(eventTypeId);
}
async deleteUserTeamEventTypesAndHosts(userId: number, teamId: number) {
try {
await this.organizationEventTypesRepository.deleteUserManagedTeamEventTypes(userId, teamId);
await this.organizationEventTypesRepository.removeUserFromTeamEventTypesHosts(userId, teamId);
} catch (err) {
this.logger.error("Could not remove user from all team event-types.", {
error: err,
userId,
teamId,
});
}
}
}
@@ -5,8 +5,6 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/repositori
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsService {
constructor(
@@ -69,23 +67,4 @@ export class OrganizationsTeamsService {
}
return team;
}
async addUserToTeamEvents(userId: number, organizationId: number) {
const orgTeams = await this.organizationsTeamRepository.findOrgTeams(organizationId);
for (const team of orgTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
async addUserToPlatformTeamEvents(userId: number, organizationId: number, oAuthClientId: string) {
const oAuthClientTeams = await this.organizationsTeamRepository.getPlatformOrgTeams(
organizationId,
oAuthClientId
);
for (const team of oAuthClientTeams) {
await updateNewTeamMemberEventTypes(userId, team.id);
}
}
}
@@ -79,8 +79,6 @@ export class OrganizationsUsersService {
updateUserBody
);
await this.organizationsTeamsService.addUserToTeamEvents(user.id, org.id);
// Need to send email to new user to create password
await this.emailService.sendSignupToOrganizationEmail({
usernameOrEmail,
@@ -21,6 +21,7 @@ import {
Req,
BadRequestException,
Headers,
Param,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiOperation } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
@@ -46,7 +47,8 @@ export class StripeController {
@Headers("Authorization") authorization: string,
@GetUser() user: UserWithProfile,
@Query("redir") redir?: string | null,
@Query("errorRedir") errorRedir?: string | null
@Query("errorRedir") errorRedir?: string | null,
@Query("teamId") teamId?: string | null
): Promise<StripConnectOutputResponseDto> {
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
@@ -56,6 +58,7 @@ export class StripeController {
fromApp: false,
returnTo: !!redir ? redir : origin,
accessToken,
teamId: Number(teamId) ?? null,
};
const stripeRedirectUrl = await this.stripeService.getStripeRedirectUrl(
@@ -99,6 +102,16 @@ export class StripeController {
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check stripe connection" })
async check(@GetUser() user: UserWithProfile): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfStripeAccountConnected(user.id);
return await this.stripeService.checkIfIndividualStripeAccountConnected(user.id);
}
@Get("/check/:teamId")
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Check team stripe connection" })
async checkTeamStripeConnection(
@Param("teamId") teamId: string
): Promise<StripCredentialsCheckOutputResponseDto> {
return await this.stripeService.checkIfTeamStripeAccountConnected(Number(teamId));
}
}
@@ -1,5 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { StripeController } from "@/modules/stripe/controllers/stripe.controller";
import { StripeService } from "@/modules/stripe/stripe.service";
@@ -10,7 +11,7 @@ import { ConfigModule } from "@nestjs/config";
@Module({
imports: [ConfigModule, PrismaModule],
exports: [StripeService],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository],
providers: [StripeService, AppsRepository, CredentialsRepository, TokensRepository, MembershipsRepository],
controllers: [StripeController],
})
export class StripeModule {}
@@ -1,13 +1,14 @@
import { AppConfig } from "@/config/type";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { getReturnToValueFromQueryState } from "@/modules/stripe/utils/getReturnToValueFromQueryState";
import { stripeInstance } from "@/modules/stripe/utils/newStripeInstance";
import { StripeData } from "@/modules/stripe/utils/stripeDataSchemas";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import type { Prisma, Credential } from "@prisma/client";
import Stripe from "stripe";
import { z } from "zod";
@@ -17,9 +18,17 @@ import { stripeKeysResponseSchema } from "./utils/stripeDataSchemas";
import stringify = require("qs-stringify");
type IntegrationOAuthCallbackState = {
accessToken: string;
returnTo: string;
onErrorReturnTo: string;
fromApp: boolean;
teamId?: number | null;
};
@Injectable()
export class StripeService {
public stripe: Stripe;
private stripe: Stripe;
private redirectUri = `${this.config.get("api.url")}/stripe/save`;
constructor(
@@ -27,13 +36,18 @@ export class StripeService {
private readonly config: ConfigService,
private readonly appsRepository: AppsRepository,
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository
private readonly tokensRepository: TokensRepository,
private readonly membershipRepository: MembershipsRepository
) {
this.stripe = new Stripe(configService.get("stripe.apiKey", { infer: true }) ?? "", {
apiVersion: "2020-08-27",
});
}
getStripe() {
return this.stripe;
}
async getStripeRedirectUrl(state: string, userEmail?: string, userName?: string | null) {
const { client_id } = await this.getStripeAppKeys();
@@ -76,6 +90,7 @@ export class StripeService {
async saveStripeAccount(state: string, code: string, accessToken: string): Promise<{ url: string }> {
const userId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
const oAuthCallbackState: IntegrationOAuthCallbackState = JSON.parse(state);
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
@@ -92,6 +107,19 @@ export class StripeService {
data["default_currency"] = account.default_currency;
}
if (oAuthCallbackState.teamId) {
await this.checkIfUserHasAdminAccessToTeam(oAuthCallbackState.teamId, userId);
await this.appsRepository.createTeamAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
oAuthCallbackState.teamId,
"stripe"
);
return { url: getReturnToValueFromQueryState(state) };
}
await this.appsRepository.createAppCredential(
"stripe_payment",
data as unknown as Prisma.InputJsonObject,
@@ -102,18 +130,39 @@ export class StripeService {
return { url: getReturnToValueFromQueryState(state) };
}
async checkIfStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
async checkIfIndividualStripeAccountConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndUserId("stripe_payment", userId);
if (!stripeCredentials) {
return await this.validateStripeCredentials(stripeCredentials);
}
async checkIfTeamStripeAccountConnected(teamId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const stripeCredentials = await this.credentialRepository.getByTypeAndTeamId("stripe_payment", teamId);
return await this.validateStripeCredentials(stripeCredentials);
}
async checkIfUserHasAdminAccessToTeam(teamId: number, userId: number) {
const teamMembership = await this.membershipRepository.findMembershipByTeamId(teamId, userId);
const hasAdminAccessToTeam = teamMembership?.role === "ADMIN" || teamMembership?.role === "OWNER";
if (!hasAdminAccessToTeam) {
throw new BadRequestException("You must be team owner or admin to do this");
}
}
async validateStripeCredentials(
credentials?: Credential | null
): Promise<{ status: typeof SUCCESS_STATUS }> {
if (!credentials) {
throw new NotFoundException("Credentials for stripe not found.");
}
if (stripeCredentials.invalid) {
if (credentials.invalid) {
throw new BadRequestException("Invalid stripe credentials.");
}
const stripeKey = JSON.stringify(stripeCredentials.key);
const stripeKey = JSON.stringify(credentials.key);
const stripeKeyObject = JSON.parse(stripeKey);
const stripeAccount = await stripeInstance.accounts.retrieve(stripeKeyObject?.stripe_user_id);
@@ -82,7 +82,10 @@ export class WebhooksController {
}
@Get("/")
@ApiOperation({ summary: "Get all webooks (paginated)" })
@ApiOperation({
summary: "Get all webooks",
description: "Gets a paginated list of webhooks for the authenticated user.",
})
async getWebhooks(
@GetUser() user: UserWithProfile,
@Query() query: SkipTakePagination
File diff suppressed because it is too large Load Diff
@@ -23,6 +23,14 @@ export class PlatformBillingRepositoryFixture {
});
}
async get(orgId: number) {
return this.prismaWriteClient.platformBilling.findFirst({
where: {
id: orgId,
},
});
}
async deleteSubscriptionForTeam(teamId: number) {
// silently try to delete the subscription
try {
-3
View File
@@ -8,9 +8,6 @@ import { FUTURE_ROUTES_ENABLED_COOKIE_NAME, FUTURE_ROUTES_OVERRIDE_COOKIE_NAME }
const ROUTES: [URLPattern, boolean][] = [
["/event-types", process.env.APP_ROUTER_EVENT_TYPES_ENABLED === "1"] as const,
["/settings/developer/:path*", process.env.APP_ROUTER_SETTINGS_DEVELOPER_ENABLED === "1"] as const,
["/settings/admin/:path*", process.env.APP_ROUTER_SETTINGS_ADMIN_ENABLED === "1"] as const,
["/settings/my-account/:path*", process.env.APP_ROUTER_SETTINGS_MY_ACCOUNT_ENABLED === "1"] as const,
["/settings/platform/:path*", process.env.APP_ROUTER_SETTINGS_PLATFORM_ENABLED === "1"] as const,
["/settings/organizations/:path*", process.env.APP_ROUTER_SETTINGS_ORG_ENABLED === "1"] as const,
["/apps/installed/:category", process.env.APP_ROUTER_APPS_INSTALLED_CATEGORY_ENABLED === "1"] as const,
["/apps/:slug", process.env.APP_ROUTER_APPS_SLUG_ENABLED === "1"] as const,
+6
View File
@@ -1 +1,7 @@
export { GET } from "@calcom/features/tasker/api/cron";
/**
* This runs each minute and we need fresh data each time
* @see https://nextjs.org/docs/app/building-your-application/caching#opting-out-2
**/
export const revalidate = 0;
+1 -2
View File
@@ -4,7 +4,7 @@ import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/apps/getServerSideProps";
import AppsPage, { LayoutWrapper } from "~/apps/apps-view";
import AppsPage from "~/apps/apps-view";
export const generateMetadata = async () => {
return await _generateMetadata(
@@ -14,7 +14,6 @@ export const generateMetadata = async () => {
};
export default WithLayout({
getLayout: LayoutWrapper,
getData: withAppDirSsr(getServerSideProps),
Page: AppsPage,
});
@@ -1,20 +0,0 @@
import { _generateMetadata } from "app/_utils";
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Feature Flags",
() => "Here you can toggle your Cal.com instance features."
);
const Page = async () => {
return (
<SettingsHeader title="Feature Flags" description="Here you can toggle your Cal.com instance features.">
<FlagListingView />
</SettingsHeader>
);
};
export default Page;
@@ -1,21 +0,0 @@
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LockedSMSView from "~/settings/admin/locked-sms-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Locked SMS",
() => "Lock or unlock SMS sending for users"
);
const Page = () => {
return (
<SettingsHeader title="Locked SMS" description="Lock or unlock SMS sending for users">
<LockedSMSView />
</SettingsHeader>
);
};
export default Page;
@@ -1,21 +0,0 @@
import { _generateMetadata } from "app/_utils";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LegacyPage from "~/settings/admin/oauth-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth",
() => "Add new OAuth Clients"
);
const Page = () => {
return (
<SettingsHeader title="OAuth" description="Add new OAuth Clients">
<LegacyPage />
</SettingsHeader>
);
};
export default Page;
@@ -1,20 +0,0 @@
import { _generateMetadata } from "app/_utils";
import UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Add new user",
() => "Here you can add a new user."
);
const Page = () => {
return (
<SettingsHeader title="Add new user" description="Here you can add a new user">
<UsersAddView />
</SettingsHeader>
);
};
export default Page;
@@ -5,7 +5,7 @@ import EditView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-vie
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth client updation form",
(t) => t("oAuth_client_updation_form"),
() => ""
);
@@ -5,7 +5,7 @@ import EditWebhooksView from "~/settings/platform/oauth-clients/[clientId]/edit/
export const generateMetadata = async () =>
await _generateMetadata(
() => "OAuth client updation form",
(t) => t("oAuth_client_updation_form"),
() => ""
);
+1
View File
@@ -25,6 +25,7 @@ export function WithLayout<T extends Record<string, any>>({
isBookingPage,
requiresLicense,
}: WithLayoutParams<T>) {
// eslint-disable-next-line react/display-name
return async <P extends "P" | "L">(p: P extends "P" ? PageProps : LayoutProps) => {
const h = headers();
const nonce = h.get("x-nonce") ?? undefined;
@@ -0,0 +1,23 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("feature_flags"),
(t) => t("admin_flags_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("feature_flags")} description={t("admin_flags_description")}>
<FlagListingView />
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,24 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LockedSMSView from "~/settings/admin/locked-sms-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("lockedSMS"),
(t) => t("admin_lockedSMS_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("lockedSMS")} description={t("admin_lockedSMS_description")}>
<LockedSMSView />
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,24 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LegacyPage from "~/settings/admin/oauth-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("oAuth"),
(t) => t("admin_oAuth_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("oAuth")} description={t("admin_oAuth_description")}>
<LegacyPage />
</SettingsHeader>
);
};
export default Page;
@@ -1,8 +1,9 @@
import { type Params } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { _generateMetadata, getFixedT } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -14,16 +15,16 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
const input = orgIdSchema.safeParse(params);
if (!input.success) {
return await _generateMetadata(
() => `Editing organization`,
() => "Here you can edit an organization."
(t) => t("editing_org"),
(t) => t("admin_orgs_edit_description")
);
}
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
return await _generateMetadata(
() => `Editing organization: ${org.name}`,
() => "Here you can edit an organization."
(t) => `${t("editing_org")}: ${org.name}`,
(t) => t("admin_orgs_edit_description")
);
};
@@ -34,11 +35,12 @@ const Page = async ({ params }: { params: Params }) => {
try {
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader
title={`Editing organization: ${org.name}`}
description="Here you can edit an organization.">
title={`${t("editing_org")}: ${org.name}`}
description={t("admin_orgs_edit_description")}>
<LicenseRequired>
<OrgForm org={org} />
</LicenseRequired>
@@ -2,8 +2,8 @@ import { _generateMetadata } from "app/_utils";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Admin",
() => "admin_description"
(t) => t("admin"),
() => ""
);
const Page = () => <h1>Admin index</h1>;
@@ -1,8 +1,9 @@
import { type Params } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { _generateMetadata, getFixedT } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -14,16 +15,16 @@ export const generateMetadata = async ({ params }: { params: Params }) => {
const input = userIdSchema.safeParse(params);
if (!input.success) {
return await _generateMetadata(
() => `Editing user`,
() => "Here you can edit a current user."
(t) => t("editing_user"),
(t) => t("admin_users_edit_description")
);
}
const user = await UserRepository.adminFindById(input.data.id);
return await _generateMetadata(
() => `Editing user: ${user.username}`,
() => "Here you can edit a current user."
(t) => `${t("editing_user")}: ${user.username}`,
(t) => t("admin_users_edit_description")
);
};
@@ -36,9 +37,11 @@ const Page = async ({ params }: { params: Params }) => {
try {
const user = await UserRepository.adminFindById(input.data.id);
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title="Editing user" description="Here you can edit a current user">
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
<LicenseRequired>
<UsersEditView user={user} />
</LicenseRequired>
@@ -0,0 +1,24 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("add_new_user"),
(t) => t("admin_users_add_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("add_new_user")} description={t("admin_users_add_description")}>
<UsersAddView />
</SettingsHeader>
);
};
export default Page;
@@ -1,20 +1,23 @@
import { _generateMetadata } from "app/_utils";
import { _generateMetadata, getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import UsersListingView from "@calcom/features/ee/users/pages/users-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { Button } from "@calcom/ui";
export const generateMetadata = async () =>
await _generateMetadata(
() => "Users",
() => "A list of all the users in your account including their name, title, email and role."
(t) => t("users"),
(t) => t("admin_users_description")
);
const Page = () => {
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader
title="Users"
description="A list of all the users in your account including their name, title, email and role."
title={t("users")}
description={t("admin_users_description")}
CTA={
<div className="mt-4 space-x-5 sm:ml-16 sm:mt-0 sm:flex-none">
{/* TODO: Add import users functionality */}
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import PlatformBillingUpgrade from "~/settings/platform/billing/billing-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("platform_billing"),
(t) => t("manage_billing_description")
);
};
export default WithLayout({
Page: PlatformBillingUpgrade,
});
@@ -0,0 +1,21 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/settings/organizations/new/getServerSideProps";
import { type inferSSRProps } from "@lib/types/inferSSRProps";
import LegacyPage, { LayoutWrapper } from "~/settings/platform/new/create-new-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("set_up_your_platform_organization"),
(t) => t("platform_organization_description")
);
export default WithLayout({
getLayout: LayoutWrapper,
Page: LegacyPage,
getData: withAppDirSsr<inferSSRProps<typeof getServerSideProps>>(getServerSideProps),
requiresLicense: true,
});
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import EditView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("oAuth_client_updation_form"),
() => ""
);
export default WithLayout({
getLayout: null,
Page: EditView,
});
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import EditWebhooksView from "~/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("webhook_update_form"),
() => ""
);
export default WithLayout({
getLayout: null,
Page: EditWebhooksView,
});
@@ -0,0 +1,16 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import CreateNewView from "~/settings/platform/oauth-clients/create-new-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("oAuth_client_creation_form"),
() => ""
);
};
export default WithLayout({
getLayout: null,
Page: CreateNewView,
});
+16
View File
@@ -0,0 +1,16 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import PlatformView from "~/settings/platform/platform-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("platform"),
(t) => t("platform_description")
);
};
export default WithLayout({
getLayout: null,
Page: PlatformView,
});
@@ -0,0 +1,15 @@
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import PlatformPlansView from "~/settings/platform/plans/platform-plans-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${t("platform")} ${t("plans")}`,
() => ""
);
};
export default WithLayout({
Page: PlatformPlansView,
});
+2
View File
@@ -40,6 +40,8 @@ function PageWrapper(props: AppProps) {
pageStatus = "404";
} else if (router.pathname === "/500") {
pageStatus = "500";
} else if (router.pathname === "/403") {
pageStatus = "403";
}
// On client side don't let nonce creep into DOM
@@ -31,6 +31,8 @@ function PageWrapper(props: PageWrapperProps) {
pageStatus = "404";
} else if (pathname === "/500") {
pageStatus = "500";
} else if (pathname === "/403") {
pageStatus = "403";
}
// On client side don't let nonce creep into DOM
+2 -2
View File
@@ -165,8 +165,8 @@ export const AppList = ({ data, handleDisconnect, variant, listClassName }: AppL
{cardsForAppsWithTeams.map((apps) => apps.map((cards) => cards))}
{data.items
.filter((item) => item.invalidCredentialIds)
.map((item) => {
if (!item.teams.length) return <ChildAppCard item={item} />;
.map((item, i) => {
if (!item.teams.length) return <ChildAppCard key={i} item={item} />;
})}
</List>
{locationType && (
@@ -3,7 +3,7 @@ import { useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React from "react";
import { ShellMain } from "@calcom/features/shell/Shell";
import Shell from "@calcom/features/shell/Shell";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { EmptyScreen } from "@calcom/ui";
@@ -11,7 +11,7 @@ type AppsLayoutProps = {
children: React.ReactNode;
actions?: (className?: string) => JSX.Element;
emptyStore?: boolean;
} & Omit<ComponentProps<typeof ShellMain>, "actions">;
} & Omit<ComponentProps<typeof Shell>, "actions">;
export default function AppsLayout({ children, actions, emptyStore, ...rest }: AppsLayoutProps) {
const { t } = useLocale();
@@ -21,7 +21,7 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
if (session.status === "loading") return <></>;
return (
<ShellMain {...rest} actions={actions?.("block")} hideHeadingOnMobile>
<Shell {...rest} actions={actions?.("block")} hideHeadingOnMobile>
<div className="flex flex-col xl:flex-row">
<main className="w-full">
{emptyStore ? (
@@ -37,6 +37,6 @@ export default function AppsLayout({ children, actions, emptyStore, ...rest }: A
)}
</main>
</div>
</ShellMain>
</Shell>
);
}
+100 -9
View File
@@ -103,7 +103,7 @@ const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooki
function BookingListItem(booking: BookingItemProps) {
const parsedBooking = buildParsedBooking(booking);
const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const { userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser;
const {
t,
i18n: { language },
@@ -113,6 +113,7 @@ function BookingListItem(booking: BookingItemProps) {
const [rejectionDialogIsOpen, setRejectionDialogIsOpen] = useState(false);
const [chargeCardDialogIsOpen, setChargeCardDialogIsOpen] = useState(false);
const [viewRecordingsDialogIsOpen, setViewRecordingsDialogIsOpen] = useState<boolean>(false);
const [isNoShowDialogOpen, setIsNoShowDialogOpen] = useState<boolean>(false);
const cardCharged = booking?.payment[0]?.success;
const mutation = trpc.viewer.bookings.confirm.useMutation({
onSuccess: (data) => {
@@ -268,6 +269,17 @@ function BookingListItem(booking: BookingItemProps) {
});
}
if (isBookingInPast) {
editBookingActions.push({
id: "no_show",
label: t("mark_as_no_show"),
onClick: () => {
setIsNoShowDialogOpen(true);
},
icon: "eye-off" as const,
});
}
let bookedActions: ActionType[] = [
{
id: "cancel",
@@ -417,12 +429,14 @@ function BookingListItem(booking: BookingItemProps) {
setIsOpenDialog={setIsOpenRescheduleDialog}
bookingUId={booking.uid}
/>
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
/>
{isOpenReassignDialog && (
<ReassignDialog
isOpenDialog={isOpenReassignDialog}
setIsOpenDialog={setIsOpenReassignDialog}
bookingId={booking.id}
teamId={booking.eventType?.team?.id || 0}
/>
)}
<EditLocationDialog
booking={booking}
saveLocation={saveLocation}
@@ -452,6 +466,14 @@ function BookingListItem(booking: BookingItemProps) {
timeFormat={userTimeFormat ?? null}
/>
)}
{isNoShowDialogOpen && (
<NoShowAttendeesDialog
bookingUid={booking.uid}
attendees={attendeeList}
setIsOpen={setIsNoShowDialogOpen}
isOpen={isNoShowDialogOpen}
/>
)}
<Dialog open={rejectionDialogIsOpen} onOpenChange={setRejectionDialogIsOpen}>
<DialogContent title={t("rejection_reason_title")} description={t("rejection_reason_description")}>
<div>
@@ -983,7 +1005,7 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
/>
))}
<DropdownMenuSeparator />
<div className=" flex justify-end p-2">
<div className="flex justify-end p-2 ">
<Button
data-testid="update-no-show"
color="secondary"
@@ -999,6 +1021,75 @@ const GroupedAttendees = (groupedAttendeeProps: GroupedAttendeeProps) => {
</Dropdown>
);
};
const NoShowAttendeesDialog = ({
attendees,
isOpen,
setIsOpen,
bookingUid,
}: {
attendees: AttendeeProps[];
isOpen: boolean;
setIsOpen: (value: boolean) => void;
bookingUid: string;
}) => {
const { t } = useLocale();
const [noShowAttendees, setNoShowAttendees] = useState(
attendees.map((attendee) => ({
id: attendee.id,
email: attendee.email,
name: attendee.name,
noShow: attendee.noShow || false,
}))
);
const noShowMutation = trpc.viewer.markNoShow.useMutation({
onSuccess: async (data) => {
const newValue = data.attendees[0];
setNoShowAttendees((old) =>
old.map((attendee) =>
attendee.email === newValue.email ? { ...attendee, noShow: newValue.noShow } : attendee
)
);
showToast(t(data.message), "success");
},
onError: (err) => {
showToast(err.message, "error");
},
});
return (
<Dialog open={isOpen} onOpenChange={() => setIsOpen(false)}>
<DialogContent title={t("mark_as_no_show_title")} description={t("no_show_description")}>
{noShowAttendees.map((attendee) => (
<form
key={attendee.id}
onSubmit={(e) => {
e.preventDefault();
noShowMutation.mutate({
bookingUid,
attendees: [{ email: attendee.email, noShow: !attendee.noShow }],
});
}}>
<div className="bg-muted flex items-center justify-between rounded-md px-4 py-2">
<span className="text-emphasis flex flex-col text-sm">
{attendee.name}
{attendee.email && <span className="text-muted">({attendee.email})</span>}
</span>
<Button color="minimal" type="submit" StartIcon={attendee.noShow ? "eye-off" : "eye"}>
{attendee.noShow ? t("unmark_as_no_show") : t("mark_as_no_show")}
</Button>
</div>
</form>
))}
<DialogFooter>
<DialogClose>{t("done")}</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
const [openDropdown, setOpenDropdown] = useState(false);
const { t } = useLocale();
@@ -1035,7 +1126,7 @@ const GroupedGuests = ({ guests }: { guests: AttendeeProps[] }) => {
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<div className=" flex justify-end space-x-2 p-2">
<div className="flex justify-end space-x-2 p-2 ">
<Link href={`mailto:${selectedEmail}`}>
<Button
color="secondary"
+219 -77
View File
@@ -1,10 +1,14 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Dispatch, SetStateAction } from "react";
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { classNames } from "@calcom/lib";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
@@ -15,9 +19,12 @@ import {
DialogClose,
DialogContent,
DialogFooter,
ConfirmationDialogContent,
showToast,
Select,
Input,
Icon,
RadioGroup as RadioArea,
TextAreaField,
} from "@calcom/ui";
type ReassignDialog = {
@@ -30,8 +37,26 @@ type ReassignDialog = {
type FormValues = {
reassignType: "round_robin" | "team_member";
teamMemberId?: number;
reassignReason?: string;
};
const formSchema = z.object({
reassignType: z.enum(["round_robin", "team_member"]),
teamMemberId: z.number().optional(),
reassignReason: z
.string()
.optional()
.refine((val) => !val || val.length >= 10, {
message: "Reassign reason must be at least 10 characters long if provided",
}),
});
interface TeamMemberOption {
label: string;
value: number;
status: string;
}
export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingId }: ReassignDialog) => {
const { t } = useLocale();
const utils = trpc.useUtils();
@@ -39,38 +64,52 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
duration: 150,
easing: "ease-in-out",
});
// Were using legacy list members here because we don't currently have an easy way to paginate a select via infinite scroll
const teamMembers = trpc.viewer.teams.getRoundRobinHostsToReassign.useQuery({
bookingId,
exclude: "fixedHosts",
});
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearch = useDebounce(searchTerm, 500);
const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage } =
trpc.viewer.teams.getRoundRobinHostsToReassign.useInfiniteQuery(
{
bookingId,
exclude: "fixedHosts",
limit: 10,
searchTerm: debouncedSearch,
},
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
const allRows = useMemo(() => {
return data?.pages.flatMap((page) => page.items) ?? [];
}, [data]);
const teamMemberOptions = useMemo(() => {
if (teamMembers.isLoading)
return [
{
label: "Loading...",
value: 0,
},
];
return teamMembers.data?.map((member) => ({
label: member.name,
return allRows.map((member) => ({
label: member.name || member.email,
value: member.id,
}));
}, [teamMembers]);
status: member.status,
})) as TeamMemberOption[];
}, [allRows]);
const { ref: observerRef } = useInViewObserver(() => {
if (hasNextPage && !isFetching) {
fetchNextPage();
}
}, document.querySelector('[role="dialog"]'));
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
reassignType: "round_robin",
},
});
const roundRobinReassignMutation = trpc.viewer.teams.roundRobinReassign.useMutation({
onSuccess: async () => {
onSuccess: async (data) => {
await utils.viewer.bookings.get.invalidate();
setIsOpenDialog(false);
showToast(t("booking_reassigned"), "success");
showToast(t("booking_reassigned_to_host", { host: data?.reassignedTo.name }), "success");
},
onError: async (error) => {
if (error.message.includes(ErrorCode.NoAvailableUsersFound)) {
@@ -96,74 +135,177 @@ export const ReassignDialog = ({ isOpenDialog, setIsOpenDialog, teamId, bookingI
},
});
const [confirmationModal, setConfirmationModal] = useState<{
show: boolean;
membersStatus: "unavailable" | "available" | null;
}>({
show: false,
membersStatus: null,
});
const handleSubmit = (values: FormValues) => {
if (values.reassignType === "round_robin") {
roundRobinReassignMutation.mutate({ teamId, bookingId });
} else {
if (values.teamMemberId) {
roundRobinManualReassignMutation.mutate({ bookingId, teamMemberId: values.teamMemberId });
const selectedMember = teamMemberOptions?.find((member) => member.value === values.teamMemberId);
if (selectedMember) {
setConfirmationModal({
show: true,
membersStatus: selectedMember.status as "available" | "unavailable",
});
}
}
}
};
const watchedReassignType = form.watch("reassignType");
const watchedTeamMemberId = form.watch("teamMemberId");
return (
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
setIsOpenDialog(open);
}}>
<DialogContent
title={t("reassign_round_robin_host")}
description={t("reassign_to_another_rr_host")}
enableOverflow>
{/* TODO add team member reassignment*/}
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
<RadioArea.Group
onValueChange={(val) => {
form.setValue("reassignType", val as "team_member" | "round_robin");
}}
className={classNames("mt-1 flex flex-col gap-4")}>
<RadioArea.Item
value="round_robin"
className={classNames("w-full text-sm")}
classNames={{ container: classNames("w-full") }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_reassign_description")}</p>
</RadioArea.Item>
<RadioArea.Item
value="team_member"
className={classNames("text-sm")}
classNames={{ container: classNames("w-full") }}>
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
<p>{t("team_member_round_robin_reassign_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
<>
<Dialog
open={isOpenDialog}
onOpenChange={(open) => {
setIsOpenDialog(open);
}}>
<DialogContent
title={t("reassign_round_robin_host")}
description={t("reassign_to_another_rr_host")}
enableOverflow>
<Form form={form} handleSubmit={handleSubmit} ref={animationParentRef}>
<RadioArea.Group
onValueChange={(val) => {
form.setValue("reassignType", val as "team_member" | "round_robin");
}}
className="mt-1 flex flex-col gap-4">
<RadioArea.Item
value="round_robin"
className="w-full text-sm"
classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("round_robin")}</strong>
<p>{t("round_robin_reassign_description")}</p>
</RadioArea.Item>
<RadioArea.Item value="team_member" className="text-sm" classNames={{ container: "w-full" }}>
<strong className="mb-1 block">{t("team_member_round_robin_reassign")}</strong>
<p>{t("team_member_round_robin_reassign_description")}</p>
</RadioArea.Item>
</RadioArea.Group>
{watchedReassignType === "team_member" && (
<div className="mb-2">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<Select
value={teamMemberOptions?.find((option) => option.value === form.getValues("teamMemberId"))}
options={teamMemberOptions}
onChange={(event) => {
form.setValue("teamMemberId", event?.value);
}}
/>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
type="submit"
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
{t("reassign")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
{watchedReassignType === "team_member" && (
<div className="mb-2">
<Label className="text-emphasis mt-6">{t("select_team_member")}</Label>
<div className="mt-2">
<Input
type="text"
placeholder={t("search")}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="scroll-bar flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
{teamMemberOptions.map((member) => (
<label
key={member.value}
tabIndex={watchedTeamMemberId === member.value ? -1 : 0}
role="radio"
aria-checked={watchedTeamMemberId === member.value}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
form.setValue("teamMemberId", member.value);
}
}}
className={classNames(
"hover:bg-subtle focus:bg-subtle focus:ring-emphasis cursor-pointer items-center justify-between gap-0.5 rounded-sm py-2 outline-none focus:ring focus:ring-2",
watchedTeamMemberId === member.value && "bg-subtle"
)}>
<div className="flex flex-1 items-center space-x-3">
<input
type="radio"
className="hidden"
checked={watchedTeamMemberId === member.value}
onChange={() => form.setValue("teamMemberId", member.value)}
/>
<div
className={classNames(
"h-3 w-3 flex-shrink-0 rounded-full",
member.status === "unavailable" ? "bg-red-500" : "bg-green-500"
)}
/>
<span className="text-emphasis w-full text-sm">{member.label}</span>
{watchedTeamMemberId === member.value && (
<div className="place-self-end pr-2">
<Icon name="check" className="text-emphasis h-4 w-4" />
</div>
)}
</div>
</label>
))}
<div className="text-default text-center" ref={observerRef}>
<Button
color="minimal"
loading={isFetchingNextPage}
disabled={!hasNextPage}
onClick={() => fetchNextPage()}>
{hasNextPage ? t("load_more_results") : t("no_more_results")}
</Button>
</div>
</div>
</div>
</div>
)}
<DialogFooter>
<DialogClose />
<Button
type="submit"
data-testid="rejection-confirm"
loading={roundRobinReassignMutation.isPending || roundRobinManualReassignMutation.isPending}>
{t("reassign")}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
<Dialog
open={confirmationModal?.show}
onOpenChange={(open) => setConfirmationModal({ ...confirmationModal, show: open })}>
<ConfirmationDialogContent
variety={confirmationModal?.membersStatus === "unavailable" ? "warning" : "success"}
title={
confirmationModal?.membersStatus === "unavailable"
? t("confirm_reassign_unavailable")
: t("confirm_reassign_available")
}
confirmBtnText={t("yes_reassign")}
cancelBtnText={t("cancel")}
onConfirm={() => {
const teamMemberId = form.getValues("teamMemberId");
if (!teamMemberId) {
return;
}
roundRobinManualReassignMutation.mutate({
bookingId,
teamMemberId,
reassignReason: form.getValues("reassignReason"),
});
setConfirmationModal({
show: false,
membersStatus: null,
});
}}>
<p className="mb-4">
{confirmationModal?.membersStatus === "unavailable"
? t("reassign_unavailable_team_member_description")
: t("reassign_available_team_member_description")}
</p>
<TextAreaField
name="reassignReason"
label={t("reassign_reason")}
onChange={(e) => form.setValue("reassignReason", e.target.value)}
required={confirmationModal?.membersStatus === "unavailable"}
/>
</ConfirmationDialogContent>
</Dialog>
</>
);
};
+16 -11
View File
@@ -26,7 +26,7 @@ import { trpc } from "@calcom/trpc/react";
import type { Ensure } from "@calcom/types/utils";
import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui";
import { Button, Tooltip } from "@calcom/ui";
import { showToast } from "@calcom/ui/components/toast";
import { showToast } from "@calcom/ui";
const enum ReroutingStatusEnum {
REROUTING_NOT_INITIATED = "not_initiated",
@@ -278,7 +278,12 @@ const NewRoutingManager = ({
const router = useRouter();
const bookerUrl = useBookerUrl();
const teamMemberIdsMatchingAttributeLogic =
teamMembersMatchingAttributeLogic?.data?.map((member) => member.id) || null;
teamMembersMatchingAttributeLogic?.data
?.map((member) => member.id)
.filter((id) => {
// We don't want to reroute to the same user who booked the booking
return id !== booking.user?.id;
}) || null;
const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id;
const bookingEventType = booking.eventType;
@@ -696,10 +701,13 @@ const RerouteDialogContentAndFooter = ({
}: Pick<RerouteDialogProps, "isOpenDialog" | "setIsOpenDialog"> & {
booking: TeamEventTypeBookingToReroute;
}) => {
const { data: responseWithForm, isPending: isRoutingFormLoading, error: formResponseFetchError } =
trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({
formResponseId: booking.routedFromRoutingFormReponse.id,
});
const {
data: responseWithForm,
isPending: isRoutingFormLoading,
error: formResponseFetchError,
} = trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({
formResponseId: booking.routedFromRoutingFormReponse.id,
});
const { t } = useLocale();
@@ -790,7 +798,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
findTeamMembersMatchingAttributeLogicMutation.mutate({
formId: form.id,
response: currentResponse,
routeId: route.id,
route,
});
}
@@ -840,10 +848,7 @@ const RerouteDialogContentAndFooterWithFormResponse = ({
<Button
onClick={verifyRoute}
data-testid="verify-new-route-button"
disabled={
reroutingState.status === ReroutingStatusEnum.REROUTING_IN_PROGRESS ||
isResponseFromOrganizerUnpopulated
}>
disabled={reroutingState.status === ReroutingStatusEnum.REROUTING_IN_PROGRESS}>
{t("verify_new_route")}
</Button>
</DialogFooter>
@@ -1,5 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useRouter } from "next/navigation";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
@@ -176,6 +175,11 @@ vi.mock("@calcom/trpc/react", () => ({
name: "Matching User 1",
email: "matching-user-1@example.com",
},
{
id: 2,
name: "Matching User 2",
email: "matching-user-2@example.com",
},
],
});
}),
@@ -204,7 +208,7 @@ vi.mock("@calcom/trpc/react", () => ({
},
}));
let mockMutateFn = vi.fn(({ __testOnSuccess }) => {
const mockMutateFn = vi.fn(({ __testOnSuccess }) => {
__testOnSuccess({
uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT",
});
@@ -244,11 +248,7 @@ async function mockMessageFromOpenedTab({ type, data }: { type: string; data: an
return messageReceivedPromise;
}
function correctResponses() {
fireEvent.click(screen.getByTestId("mock-form-update-response-button"));
}
function expectEventTypeInfoInCurrentRouting({
async function expectEventTypeInfoInCurrentRouting({
eventTypeText,
eventTypeHref,
}: {
@@ -257,10 +257,10 @@ function expectEventTypeInfoInCurrentRouting({
}) {
const eventTypeEl = screen.getByTestId("current-routing-status-event-type");
expect(eventTypeEl).toHaveTextContent(eventTypeText);
expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
await expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
}
function expectEventTypeInfoInReroutePreview({
async function expectEventTypeInfoInReroutePreview({
eventTypeText,
eventTypeHref,
}: {
@@ -269,7 +269,7 @@ function expectEventTypeInfoInReroutePreview({
}) {
const eventTypeEl = screen.getByTestId("reroute-preview-event-type");
expect(eventTypeEl).toHaveTextContent(eventTypeText);
expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
await expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref);
}
function expectOrganizerInfoInCurrentRouting({ organizerText }: { organizerText: string }) {
@@ -281,7 +281,7 @@ function expectAttendeesInfoInCurrentRouting({ attendeesText }: { attendeesText:
const attendeesEl = screen.getByTestId("current-routing-status-attendees");
expect(attendeesEl).toHaveTextContent(attendeesText);
}
const userWhoBooked = { id: 1, name: "Test User", email: "user@example.com" };
const mockBooking = {
id: 1,
uid: "original-booking-uid",
@@ -298,7 +298,7 @@ const mockBooking = {
schedulingType: SchedulingType.ROUND_ROBIN,
title: "Test Event",
},
user: { id: 1, name: "Test User", email: "user@example.com" },
user: userWhoBooked,
routedFromRoutingFormReponse: { id: 1 },
status: BookingStatus.ACCEPTED, // Add this line
};
@@ -348,11 +348,9 @@ describe("RerouteDialog", () => {
// screen.logTestingPlaygroundURL()
});
test("verify_new_route button is disabled when form fields are not filled", async () => {
test("verify_new_route button is enabled even when form fields are not filled", async () => {
render(<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />);
expect(screen.getByText("verify_new_route")).toBeDisabled();
correctResponses();
expect(screen.getByText("verify_new_route")).toBeEnabled();
await expect(screen.getByText("verify_new_route")).toBeEnabled();
});
test("disabledFields are passed to FormInputFields with value ['email'] - email field is disabled", async () => {
@@ -383,15 +381,13 @@ describe("RerouteDialog", () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
// Enables verify_new_route button
correctResponses();
fireEvent.click(screen.getByText("verify_new_route"));
expectEventTypeInfoInReroutePreview({
eventTypeText: "team/test-team/new-test-event",
eventTypeHref: "https://cal.com/team/test-team/new-test-event",
});
expect(screen.getByText("verify_new_route")).toBeEnabled();
await expect(screen.getByText("verify_new_route")).toBeEnabled();
expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("reroute_preview_possible_host");
expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("matching-user-1@example.com");
@@ -404,8 +400,6 @@ describe("RerouteDialog", () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
// Enables verify_new_route button
correctResponses();
clickVerifyNewRouteButton();
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
await mockMessageFromOpenedTab({
@@ -421,6 +415,8 @@ describe("RerouteDialog", () => {
expect.objectContaining({
rescheduleUid: mockBooking.uid,
"cal.rerouting": "true",
// Shouldn't include the user who booked the booking
"cal.routedTeamMemberIds": "2",
"cal.reroutingFormResponses": JSON.stringify({
"company-size": {
value: "small",
@@ -443,13 +439,13 @@ describe("RerouteDialog", () => {
render(
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
);
// Enables verify_new_route button
correctResponses();
clickVerifyNewRouteButton();
clickRescheduleWithSameTimeslotOfChosenEventButton();
expect(mockMutateFn).toHaveBeenCalledWith(
expect.objectContaining({
rescheduleUid: mockBooking.uid,
// Shouldn't include the user who booked the booking
routedTeamMemberIds: [2],
reroutingFormResponses: {
"company-size": {
value: "small",
@@ -1,82 +0,0 @@
import type { TFunction } from "next-i18next";
import { Trans } from "next-i18next";
import { useState } from "react";
import {
SettingsToggle,
Dialog,
DialogContent,
DialogFooter,
InputField,
DialogClose,
Button,
} from "@calcom/ui";
interface DisableEmailsSettingProps {
checked: boolean;
onCheckedChange: (e: boolean) => void;
recipient: "attendees" | "hosts";
t: TFunction;
}
export const DisableAllEmailsSetting = ({
checked,
onCheckedChange,
recipient,
t,
}: DisableEmailsSettingProps) => {
const [dialogOpen, setDialogOpen] = useState(false);
const [confirmText, setConfirmText] = useState("");
const title =
recipient === "attendees" ? t("disable_all_emails_to_attendees") : t("disable_all_emails_to_hosts");
return (
<div>
<Dialog open={dialogOpen} onOpenChange={(e) => setDialogOpen(e)}>
<DialogContent title={title} Icon="circle-alert">
<p className="text-default text-sm">
<Trans i18nKey="disable_attendees_emails_description">
This will disable all emails to {{ recipient }}. This includes booking confirmations, requests,
reschedules and reschedule requests, cancellation emails, and any other emails related to
booking updates.
<br />
<br />
It is your responsibility to ensure that your {{ recipient }} are aware of any bookings and
changes to their bookings.
</Trans>
</p>
<p className="text-default mb-1 mt-2 text-sm">{t("type_confirm_to_continue")}</p>
<InputField
value={confirmText}
onChange={(e) => {
setConfirmText(e.target.value);
}}
/>
<DialogFooter>
<DialogClose />
<Button
disabled={confirmText !== "confirm"}
onClick={(e) => {
onCheckedChange(true);
setDialogOpen(false);
}}>
{t("disable_email")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={title}
description={t("disable_all_emails_description")}
checked={!!checked}
onCheckedChange={() => {
checked ? onCheckedChange(!checked) : setDialogOpen(true);
}}
/>
</div>
);
};
@@ -159,14 +159,14 @@ export const OAuthClientCard = ({
</div>
<div className="flex items-start gap-4">
<Button
className="bg-subtle hover:bg-emphasis text-white"
color="primary"
loading={isLoading}
disabled={isLoading}
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit/webhooks`)}>
Webhooks
</Button>
<Button
className="bg-subtle hover:bg-emphasis text-white"
color="secondary"
loading={isLoading}
disabled={isLoading}
onClick={() => router.push(`/settings/platform/oauth-clients/${id}/edit`)}>

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