Compare commits

..
312 Commits
Author SHA1 Message Date
GitHub Actions 1aa2f68e73 chore: release v5.2.9
Post release / release (push) Canceled after 0s
2025-05-05 17:55:27 +00:00
Udit Takkar ad707b0ee4 chore: improve error message (#21112) 2025-05-05 16:41:44 +00:00
Alex van Andel 49a2f4d4f7 chore: Add proton.me to email provider list (#21117) 2025-05-05 16:09:43 +00:00
Benny Joo f576979345 perf: remove unnecessary routes from middleware (#21089)
* perf: Remove unnecessary routes from middleware

* remove tests not needed
2025-05-05 11:47:27 -04:00
Keith Williams b45399ab78 chore: Update CODEOWNERS for booking tRPC handlers (#21118) 2025-05-05 15:40:42 +00:00
306dc5e4fb fix: add back round robin data to insights/router-position (#21116)
* add back all data to router-position

* fix type error

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-05-05 15:30:55 +00:00
Anik Dhabal Babu 605304b93b fix: availability view error (#21115)
* fix: avaiability view error

* Update list.handler.ts
2025-05-05 14:41:17 +00:00
amritandTushar Bhatt d6a17b2b1e feat: booking URL validation to accept links without explicit mention of https:// (#21110)
* fix: trigger lingo.dev by removing duplicate value

* feat: allow urls without an explicit https://

* fixed the test that ensures malformed URLs like "http:/nope" are rejected

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-05 12:49:19 +00:00
Yadong (Adam) Zhang 4bee875a8c build(packages/features): resolved type error when building apps/web. (#21094) 2025-05-05 11:20:05 +00:00
Anik Dhabal Babu fe4ae3ae62 fix: {Organizer} placeholder in event title not updating on reschedule (#21034) 2025-05-05 10:22:29 +00:00
Anik Dhabal Babu a245578ff2 fix: meeting link fails when manually reassign (#21106)
* fix: meeting link fails when manually reassign

* update

* Update booking.ts
2025-05-05 09:32:56 +00:00
sean-brydonandEunjae Lee f5ec696e15 fix: button fixes v3 (#20557)
* fix button styling

* add button usage

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-05-05 09:31:14 +00:00
sean-brydonandUdit Takkar ec9384858b move form builder email input away from routing form widget (#21077)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-05-05 09:05:01 +00:00
Eunjae Lee 47613f796e feat: add membership creation date to Organization Member List table (CAL-5406) (#21008)
* feat: add membership creation date to Organization Member List table (CAL-5406)

This reverts commit 5aaca2b7aa.

* fill createdAt
2025-05-05 10:15:30 +02:00
luzpaz 446c780d85 fix: typos in apps/api/v2 (#21105)
Found via `codespell -q 3 -S "*.svg,./apps/web/public/static/locales,./packages/app-store/stripepayment/lib/currencyOptions.ts,./packages/lib/freeEmailDomainCheck/freeEmailDomains.ts" -L afterall,atleast,datea,fo,incase,ist,nam,notin,optionel,perview,reccuring`

Closes #21104
2025-05-04 15:58:37 +00:00
Shashwatt 615968c640 fix: add missing 'add' in profile username tip (#21049) (#21097) 2025-05-04 20:13:55 +05:30
Morgan fc89436b6a chore: bump platform libraries (#21101) 2025-05-04 10:13:28 +00:00
Joe Au-Yeung 3c1015cb11 Get correct userId (#21092) 2025-05-03 00:43:05 +00:00
Anik Dhabal Babu 46c513330c test: retry times for api v2 e2e test in CI (#21087)
* test: test retry times for api v2 test

* Update jest.setup-e2e.ts
2025-05-02 19:42:36 +00:00
sean-brydon ef1598faeb fix return (#21088) 2025-05-02 20:06:58 +01:00
Anik Dhabal Babu 42a6903057 fix: wrong info on cancelled email (#21085) 2025-05-02 17:19:19 +00:00
Morgan 0f0c2b8b92 feat: verified resources endpoint [v2] (#21006)
* feat: verified resources endpoint [v2]

* add tests and fixes

* chore: add endpoint throttler decorator and handling

* chore: code review comments

* chore: code review comments

* chore: code review comments

* bump platform libraries

* chore: add tests and fix issues
2025-05-02 13:37:19 +03:00
VijayandTushar Bhatt c400c36306 fix: between operator for attribute matching (#21031)
* update json logic for raqb between operator

* nit

* add tests

* e2e tests

* after merge: updated tests due to latest changes in routingforms v3

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-02 10:29:48 +00:00
sean-brydon 6410bd2eaf fix: rf v3 tidy up mobile issues (#21078)
* remove incomplete booking from mobile

* add incomplete booking to dropdown on mobile

* padding
2025-05-02 10:01:07 +00:00
Alex van Andel 2ddc5317b8 fix: disabled sms hanging, by calling resolve() (#21070) 2025-05-02 02:23:42 +00:00
Joe Au-Yeung 6fffe3377d refactor: Replace attendee email with user id in idempotency key (#21058)
* Use userId instead of attendee email

* Only write if booking is accepted

* Create `getBookingRequest` function

* Return already requested booking

* Type fixes

* Type fix

* Type fix

* Pass empty seatReferenceId

* Handle no userId

* Update test
2025-05-02 00:46:19 +01:00
GitHub Actions eedef35d02 chore: release v5.2.8
Post release / release (push) Canceled after 0s
2025-05-01 22:09:04 +00:00
Keith Williams 08d232a816 revert: "feat: Ability to set a locked timezone for event type (#20425)" (#21066)
This reverts commit 03240709dc.
2025-05-01 21:51:20 +00:00
Udit TakkarandSyed Ali Shahbaz d9b3f8657f feat: add setting to disable phone only sms notification (#21038)
* feat: add setting to disable phone only sms notification

* Update apps/web/public/static/locales/en/common.json

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>

---------

Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-05-01 18:18:52 +00:00
Eunjae Lee 99dc5837f5 revert: "fix: eventType filter on /insights (#20823)" (#21059)
This reverts commit 45904c07bc.
2025-05-01 15:51:49 +00:00
Tushar Bhatt 5a35540522 feat: show-utm-params (#20733)
* feat:utm

* locale

* yarn.lock

* fix-typecheck

* indentation

* edge-case

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

* Update apps/web/modules/bookings/views/bookings-single-view.tsx
2025-05-01 14:46:42 +00:00
Keith Williams c01fc08f67 chore: Allow Foundation to approve on behalf of Consumer (#21057) 2025-05-01 14:37:35 +00:00
Peer Richelsen 7397722eb3 chore: fixed headline of timelessui (#21051)
* Update page.tsx

* Update apps/ui-playground/app/(home)/page.tsx
2025-05-01 13:23:22 +01:00
d51e03b14b feat: routing v3 design (#20607)
Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-05-01 10:35:17 +01:00
Anik Dhabal Babu c4af180a5b fix: attendeePhoneNumber not appearing in calendar event (#21035) 2025-05-01 07:48:16 +00:00
Hariom Balhara ee5f1dc879 fix unit test bug (#21046) 2025-05-01 07:05:12 +00:00
Rajiv Sahal 15b43176ff uncomment props (#20818) 2025-05-01 11:47:21 +05:30
Anik Dhabal Babu 03240709dc feat: Ability to set a locked timezone for event type (#20425)
* feat: ability to set locked timezone

* update

* Update update-event-type.input.ts

* Update get-event-type-public.output.ts

* Update documentation.json

* revert

* nit
2025-05-01 11:09:57 +05:30
Romit GabaniandHariom Balhara 7ee451ca02 fix: restricting platform user from creating another regular org (#20929)
* restricting platform user from creating another regular org

* changed as suggested, additional check of membership is accepted

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-05-01 03:59:32 +00:00
Alex van Andel 55b066c475 fix: cal.cache not working (#21044)
* fix: cal.cache not working

* Thanks mrge
2025-05-01 00:42:57 +00:00
Anik Dhabal Babu c52bbc4be3 fix: meeting link fails when host requests to reschedule (#20999)
* fix: meeting link fails when host requests to reschedule

* Update EventManager.ts
2025-04-30 17:18:48 +00:00
Morgan 31b1f31d82 chore: format calendar events to remove oauthClientIds (#21040) 2025-04-30 16:01:34 +01:00
Lauris Skraucis ab1c1bb015 fix: allow only platform organisation or non-platform organisation (#20972)
* fix: platform customers cant subscribe to organizations plan

* fix: organization customers cant subscribe to platform plan
2025-04-30 10:33:01 +02:00
45219f4736 fix: cleanup html reports when pr is merged/closed (#20089)
Co-authored-by: anik@cal.com <anik@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-04-29 18:48:05 +00:00
3296255f80 feat: /skip trial for teams (#20753)
* skip team trials

* add loading state

* update mocks

* cleanup

* add import oops

* fix tests files

---------

Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2025-04-29 11:48:41 +00:00
Ronit Raj 81b4651d6e fix: UI Bug - ToggleGroup (#20997)
* fix #20986: UI Bug - ToggleGroup

* some changes

* some changes
2025-04-29 03:01:20 +00:00
Hariom Balhara e3bd90c4f2 fix: Handle calendar-cache with Delegation Credentials
Fixes CAL-5372

# Delegation Credentials with CalendarCache.

Following content is a snapshot of the [internal document](https://calendso.slack.com/docs/T08B8KA2BNF/F08L5JYU3V3)



**Problem-1 :** 

CalendarCache needs SelectedCalendar records to work but SelectedCalendar record is only created when a user connects their calendar and then enables some calendar for conflict checking. Because with Delegation, no manual connection is done by any of the members, we need a way to create SelectedCalendar records automatically.

**Problem-2**

CalendarCache connects to credential(regular credential) which doesn’t exist for Delegation Credential scenario. Also, DelegationCredential is common for all the members(different from Credential which is different for different members) of the organization and we need to identify to which user the CalendarCache belongs.  

**Solution for both problems**
- Create credential records for Delegation Credentials as well - Through Cron(new - we could schedule it every 5mins)
- Now create SelectedCalendar  records for those Credential records -  Through another Cron(new - we could schedule it every 5mins)
- Now CalendarCache records will automatically be created for those SelectedCalendar records -existing cron

## Fixed some Delegation Credentials bugs unrelated to calendar-cache
- If DestinationCalendar wasn't set(which is possible only with Delegation Credentials), then Google Meet wasn't used as a conferencing app - [Added a test]
- If no SelectedCalendar is there but Google Calendar connection exists(possible only with Delegation Credential) then we were not doing conflict checking. It is expected to not do it for Regular Credentials, but for Delegation Credential we must check for conflict in that case too [Added a test]
- Earlier if a user has Regular Credential as well as Delegation Credential for the same external id which is the member email(say member1@acme.com) then availability were retrieved twice because we weren't deduplicating credentials as it wasn't a trivial thing to do. Now that is being done.


**Env Variables:**
Note this PR doesn't introduce any new env variable. The existing env variable has been added to .env.example. But if this env variable isn't already set, it must be set.

`CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY={SAME_AS_SET_FOR_V2_API}`

**Deployment Plan:**
1. Add Observability for SelectedCalendar when _error_ field is set
2. Follow https://github.com/calcom/cal.com/blob/calendar-cache-dwd-support/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md#setting-up-delegation-credential-for-google-calendar-api to enable Delegation Credential for i.cal.com
3. Note that to be able to see the option to enable Delegation Credential for an organization, you need to enable `teamFeature` and `feature` for `delegation-credential`

## Automation Tests
- Introduced tests for calendar-cache.repository.ts
   - Tests all methods of the repository
- Added more tests for handleNewBooking/delegation-credential flow.
   - Added test to verify the bug fix when no DestinationCalendar exists and Google Meet should be used still 
- Added more tests for Google Calendar/CalendarService targeting DelegationCredential
- Added more tests for getCalendarsEvents. 
    - To test the new logic of calling getAvailability still if there are no selectedCalendars in case of Delegation Credential
    - Also introduced tests for `getAvailabitlityWithTimezones` which was an existing function but now has some new changes.
- Added tests for deduplication logic in CalendarManager.ts

## How to Test
Enable Calendar Cache and Delegation Credential feature for acme org through `features` and `teamFeatures` tables.
- Enable Delegation Credential for acme org
- Enable atleast 1 calendar for conflict checking for one of the users(say owner1)
- Ensure GOOGLE_WEBHOOK_TOKEN is set in .env file
- Ensure GOOGLE_WEBHOOK_URL is set to ngrok url of webapp in .env file
- Hit cron endpoint `curl http://localhost:3000/api/calendar-cache/cron\?apiKey\={API_KEY}` that would cache the freebusy result for the selected calendars



Followup 
- https://github.com/calcom/cal.com/pull/20698
- https://github.com/calcom/cal.com/pull/18619/files#r2046795643
2025-04-28 18:11:29 -03:00
Joe Au-YeungandOmar López 17f1932467 refactor: Use Salesforce GraphQL for account ownership routing (#20655)
Co-authored-by: Omar López <zomars@me.com>
2025-04-28 20:54:58 +00:00
Anik Dhabal Babu 1b04fe82a7 fix: hide orgnizer email in cal video sidebar (#21011) 2025-04-28 16:34:32 +00:00
Carina WollendorferandCarinaWolli ffb36509ff fix: schedule unscheduled emails with tasker in cron job (#20748)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-04-28 08:56:13 -07:00
Benny Joo dc361f7879 perf: SSR - /event-types and /event-types/[type] (#20952)
* add revalidation

* ssr for event-types

* wip

* wip

* fix types

* fix skeleton

* better error component

* make skeleton better

* no need to export

* call revalidations early

* remove revalidations if not needed

* address comments

* address
2025-04-28 16:58:53 +02:00
Udit Takkar 0b59a5d184 fix: encoding bug (#21004)
* fix: encoding bug

* fix: use escapeValue
2025-04-28 14:56:12 +00:00
Anik Dhabal Babu 54c17eb827 fix: unable to create meeting with ms teams (#20993)
* fix: few details not populate

* fix: unable to create meeting with ms teams

* Update CrmService.ts

* update
2025-04-28 13:41:02 +00:00
Anik Dhabal Babu bd1142ea7a fix: wrong translation value (#17923)
* feat: ability to remove attendees from booking page

* update

* fix
2025-04-28 13:16:18 +00:00
Hariom Balhara 26051833cf fix: Skip already completed org onboarding, as webhook request can come when invoice is paid again next month (#20984)
* there is nothing to do for repeat invoice payment

* there is nothing to do for repeat invoice payment
2025-04-28 18:36:14 +05:30
Somay Chauhan 1d8bc6ca49 feat: added hideOrganizerEmail to api/v2/event-types (#21005)
* feat: added `hideOrganizerEmail` to api/v2/event-types

* updated docs
2025-04-28 15:39:51 +03:00
Rajiv SahalandMorgan fbe8cf8223 fix: cancel platform subscription on dashboard (#20787)
* add new endpoint to cancel user subscription

* remove comments

* add delete button to frontend

* update correct request method

* fixup

* frontend for cancelling subscription

* better warning message

* better function name

* update locales

* better error handling in case stripe api call fails

* rename teamId to organizationId

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-04-28 08:41:25 +01:00
Anik Dhabal Babu ef8b3e83ac fix: few details not populate (#20987) 2025-04-28 04:47:56 +00:00
devin-ai-integration[bot]andhariom@cal.com 9d228a1d96 Remove 30 seats minimum text from organization setup page (#20983)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
2025-04-28 08:30:03 +05:30
Benny Joo 57df25505d perf: workflows—remove data fetching for metadata (#20734) 2025-04-26 12:21:11 +00:00
Hariom Balhara 31cbea0e23 Do not retrieve already persisted data as well (#20982) 2025-04-26 17:32:08 +05:30
Syed Ali Shahbaz edd80c056c Update sms-manager.ts (#20985) 2025-04-26 11:56:30 +00:00
Hariom Balhara 6ebf0db389 Handle acme.cal.local root path (#20981) 2025-04-26 08:35:23 -03:00
GitHub Actions 6f4e51b859 chore: release v5.2.7
Post release / release (push) Canceled after 0s
2025-04-26 01:52:58 +00:00
Omar López 4f5d0db792 docs: add JSDoc documentation to FeaturesRepository class and methods (#20980) 2025-04-26 01:44:37 +00:00
Keith Williams 8733bcaf7a perf: Add credentialId index to selectedCalendar (#20979) 2025-04-26 01:40:28 +00:00
Keith Williams 5aaca2b7aa revert: "feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)" (#20978)
This reverts commit d21f7ccbc8.
2025-04-26 00:46:49 +00:00
Alex van Andel eacd05bf3b perf: Improve performance of period limits (#20974)
* perf: Improve performance of period limits

* Type fixes

* Fixed correct check for instanceof -> isDayjs
2025-04-25 21:07:18 +01:00
Hariom Balhara d51e0182b5 fix: dont persist last booking response, temporarily (#20975)
* dont persist last booking response

* remove dead code to fix ts error
2025-04-25 23:07:48 +05:30
Benny Joo 220897ba86 chore: better code - migrate getServerSideProps file directly to RSC for /apps (#20926)
* migrate getServerSideProps file directly to RSC for /apps

* fix
2025-04-25 06:41:21 -07:00
Benny Joo 78c0203a45 chore: leverage error.tsx in app router (#20955) 2025-04-25 13:55:52 +01:00
Syed Ali Shahbaz 720fd90066 fix: Don't delete Cal video room upon requesting reschedule (#20969)
* Update requestReschedule.handler.ts

* Update requestReschedule.handler.ts
2025-04-25 08:30:44 -03:00
Omar López ca8ee749a2 fix: double end-date parsing when saving availability (#20963)
* fix: double end-date parsing when saving availablity

Signed-off-by: Omar López <zomars@me.com>

* fix: cache hits in current and next month

* Update CalendarService.test.ts

---------

Signed-off-by: Omar López <zomars@me.com>
2025-04-24 19:33:55 -03:00
Anik Dhabal Babu ce2d3a4b00 fix: added order by (#20959) 2025-04-24 19:21:21 +00:00
Anik Dhabal Babu 2923684c7c fix: exclude email from replyTo (#20953)
* fix: exclude email from replyTo

* update

* Update scheduleEmailReminders.ts
2025-04-24 19:20:45 +00:00
Alex van Andel 879ae597be fix: Offset is required but not passed everywhere, default to zero. (#20917) 2025-04-24 16:46:19 +01:00
d21f7ccbc8 feat: add membership creation date to Organization Member List table (CAL-5406) (#20595)
* feat: add membership creation date to Organization Member List table (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add migration for membership creation date (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: make createdAt and updatedAt nullable (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add updatedAt column to Organization Member List table (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use type assertion to access createdAt and updatedAt fields (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: display N/A for null date values (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use proper type assertions for createdAt and updatedAt fields (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: add createdAt and updatedAt to UserTableUser mock in test (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add PostgreSQL trigger for membership timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use empty string instead of N/A and add translations for column headers (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* add i18n text

* clean up type issue

* feat: add translation keys for column headers (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* disable sort

* remove duplicated i18n texts

* feat: add filters for lastActiveAt, createdAt, and updatedAt (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* support date range filter

* fix date range for end date

* hide columns by default

* revert wrong change

* add missing selects

* fix e2e test

* fix: remove PostgreSQL trigger and let application handle timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* feat: add application-level timestamp handling for Membership model (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* add more timestamp handling

* refactor: use Prisma's built-in decorators for Membership timestamps (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* refactor: remove application-level timestamp handling in favor of Prisma decorators (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* refactor: remove more application-level timestamp handling in favor of Prisma decorators (CAL-5406)

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix e2e test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-04-24 10:48:06 -03:00
Lauris Skraucis 4cc1bc6746 fix: v2 sentry errors (#20902)
Post release / release (push) Canceled after 0s
* refactor: no_available_users_found_error for team event

* fix: hosts_unavailable_for_booking

* fix: Cannot read properties of undefined (reading 'replace')

* fix: Cannot read properties of undefined (reading 'phoneNumber')

* fix: No SelectedCalendar found.

* fix: Cannot read properties of undefined (reading 'length')

* refactor: add bookings errors service
2025-04-24 16:06:37 +03:00
GitHub Actions 49a2c60212 chore: release v5.2.6 2025-04-24 12:55:32 +00:00
Lauris Skraucis 7dfbb42ced docs: global platform event types and custom booking flows (#20940)
* docs: global event types

* docs: custom booking flow
2025-04-24 12:47:09 +00:00
Anik Dhabal Babu 41fafb9e5e fix: Include ordered attendees in booking select query (#20935)
* fix: correct ordering

* another
2025-04-24 12:33:20 +00:00
Benny Joo 6815f2f9c7 perf: server-side fetching for /availability (#20804)
* availability

* refactor

* add loader

* improve

* update

* fix loader

* rename

* satisfying types

* refactor

* remove log

* add CTA to skeleton

* refactor

* fix revalidations

* refactor

* update

* finish
2025-04-24 11:10:54 +02:00
Benny Joo 91ab27e070 feat: add loading component for /apps main page (#20924) 2025-04-24 10:48:03 +02:00
Lauris Skraucis 396ccb1cd2 refactor: v2 automatic team slugs (#20897)
* refactor: automatic team slugs

* refactor: automatic organizations team slugs

* refactor: automatic managed organizations slugs

* docs

* refactor: test
2025-04-24 10:00:01 +02:00
Benny Joo 4e8d5cebca perf: ssr for /settings/organizations/teams/other (#20927) 2025-04-24 09:42:09 +05:30
Benny Joo 80f2e61189 perf: remove next.redirect by going straight to event-types (#20920) 2025-04-23 20:23:50 +00:00
Benny Joo d844fd6ef7 perf: Slim down trpc queries in Settings Layout (#20918)
* slim down settings layout by removing server queries

* add check method

* fix type check

* refactor
2025-04-23 20:13:26 +00:00
Anik Dhabal Babu 513f54f4fe feat: ability to hide organizer email (#20782)
* feat: hide orgainzer email

* fix type error

* update

* update

* Update schema.prisma
2025-04-23 19:58:29 +00:00
Keith Williams 2e6d71f493 fix: Docs for deleting reserved slot (#20922) 2025-04-23 19:58:04 +00:00
GitHub Actions 55488c39f4 chore: release v5.2.5
Post release / release (push) Canceled after 0s
2025-04-23 17:22:16 +00:00
Joe Au-YeungandAnik Dhabal Babu c0e123a917 Pass customReplyTo as a managed event type prop (#20909)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-04-23 17:55:46 +01:00
Morgan ec88f16f1f fix: getBookings max 1 month in the past when no date filters specified (#20911)
* fix: getBookings max 1 month in the past

* bump platform libraries
2025-04-23 13:22:20 -03:00
Benny Joo a957c15062 perf: SSR - /availablilty/[schedule] (#20829)
* ssr for schedule page

* improve skeleton

* improve skeleton

* improve skeleton
2025-04-23 11:31:01 -03:00
Benny Joo b32b31ccdc perf: SSR for /teams (#20824)
* finish

* improve

* refactor

* add revalidations

* /settings/teams is not needed

* refactor

* rename

* redundant type casting
2025-04-23 15:09:25 +01:00
e29f6af540 feat(insights): add progress indicator to download button on /insights and /insights/routing (CAL-5408) (#20631)
* feat(insights): add progress indicator to routing form responses download button (CAL-5408)

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: address PR feedback - remove initial progress update and ensure no decimal parts in percentage

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* feat(insights): add progress indicator to Download component

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* feat: paginate rawData endpoint and implement batch fetching in Download component

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: simplify pagination logic in trpc-router.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* show progress on tooltip

* fix download for /insights

* fix download when there is no data

* feat: replace tooltip progress indicator with toast notification

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: remove duplicate ProgressToast component from showToast.tsx

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: export ProgressToast component from index.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add missing translation for downloading_data

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* update progress toast implementation

* fix error

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-04-23 15:59:09 +02:00
Anik Dhabal BabuandJoe Au-Yeung c6cb3423fa feat: custom reply To Email (#20771)
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2025-04-23 06:44:44 -07:00
Eunjae Lee 45904c07bc fix: eventType filter on /insights (#20823)
* fix: eventType filter on /insights

* remove log
2025-04-23 13:42:39 +00:00
Omar López 0623887ff8 refactor: Returns 400 error for ZodErrors (#20831) 2025-04-23 13:47:29 +01:00
Lauris Skraucis f31c45a153 fix: platform org members cannot access their public booking page (#20821)
* fix: platform member event pages

* chore: update slots docs

* refactor: user repository call

* refactor: remove newline
2025-04-23 14:29:26 +03:00
Vijay f5217caef2 fix: caching for sprite.svg (#20832)
* update cache-control header for sprite.svg

* updated regex
2025-04-23 11:18:04 +00:00
GitHub Actions e1beec0487 chore: release v5.2.4
Post release / release (push) Canceled after 0s
2025-04-23 01:47:22 +00:00
Joe Au-Yeung 6bbf263981 feat: Optout of SMS workflows (#20769)
* Do not call dub if API key isn't present

* Add `WorkflowOptOutContact` table

* Add `sendTo` column to `WorkflowReminder`

* Add `workflowOptOutContactRepository`

* Add `WorkflowReminderRepository`

* Write `sendTo` number when scheduling SMS messages

* Add `p-limit` package

* Add `determineOptOutType` to `TwilioProvider`

* Add `deleteMultipleScheduledSMS`

* Create `WorkflowOptOutService`

* Add endpoint handler for Twilio SMS responses

* Add `isOptedOut` method to `workflowOptOutContactRepository`

* Verify phone number is not opted out before scheduling SMS

* Use `smsReminderNumber` instead of `sendTo`

* Type fix

* Add .env variable if opt out is available

* Add opt out message to SMS

* Import `pLimit` directly in method

* Address select comment

* Guard against undefined phone number from form

* Add early return to `deleteWorkflowReminders`

* Add request validation from Twilio

* Add fallback message as i18N string

* Only delete SMS attendee scheduled reminders
2025-04-22 22:00:19 +01:00
Benny Joo d86a28bf7f simplify metadata in /availability/[schedule] route (#20827) 2025-04-22 19:41:24 +00:00
Benny Joo c00e8cfa54 perf: Fetch data server-side for Api Keys page (#20677)
* server-side fetching for api keys page

* refactor billing page

* fix
2025-04-22 18:20:41 +00:00
Alex van Andel e82cc328d3 refactor: Remove kysely as its unused (#20817) 2025-04-22 14:53:41 -03:00
Rajiv Sahal 7dbfc1b2e0 fix: user is already part of a team (#20774)
* better error message

* redirect to login if no user session

* fixup

* remove callback url since the same function is used by org and platform both

* remove extra exclamation mark

* implement PR feedback
2025-04-22 16:07:13 +00:00
Benny Joo 02fb7c0ae0 fix: remove window usage in not-found client (#20822)
* remove window usage in not-found client

* fix
2025-04-22 16:04:36 +00:00
Eunjae Lee 9004dcb93b feat: open dropdown right after adding a filter (#20819)
* feat: open dropdown right after adding a filter

* clean up timeout
2025-04-22 15:29:56 +00:00
devin-ai-integration[bot]andeunjae@cal.com cf83b0a236 feat: update DeleteSegmentDialog to use ConfirmationDialogContent CAL-5399 (#20820)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
2025-04-22 15:24:06 +00:00
Lauris Skraucis 7f02464fed refactor: forbidden 403 v2 guard messages (#20794)
* refactor: delete unused proxy guard

* refactor: roles.guard.ts

* refactor: is-org.guard.ts

* refactor: permissions.guard.ts

* refactor: oauth-client-guard.ts

* refactor: is-user-event-type-webhook-guard.ts

* refactor: platform-plan.guard.ts

* refactor: roles.guard.ts

* refactor: cache guard result if canAccess=true

* refactor: is-admin-api-enabled.guard.ts

* refactor: is-team-in-org.guard.ts

* refactor: platform-plan.guard.ts

* refactor: is-webhook-in-org.guard.ts

* refactor: is-user-in-org.guard.ts

* refactor: is-user-webhook-guard.ts

* refactor: is-user-in-org-team.guard.ts

* refactor: is-membership-in-org.guard.ts

* refactor: is-oauth-client-webhook-guard.ts

* refactor: is-routing-form-in-team.guard.ts

* refactor: is-managed-org-in-manager-org.guard.ts

* fill the gaps

* refactor: api-auth.strategy.ts

* fix: test

* implement feedback

* fix: tests

* fix: refactor

* fix: test
2025-04-22 16:28:53 +03:00
Keith Williams 2371253cb5 chore: Add more Foundation and Consumer team CODEOWNERS configuration (#20816) 2025-04-22 09:58:13 -03:00
Morgan 859e7f1c88 fix: api v2 license check (#20813)
* fix: api v2 license check

* update cache key to avoid conflicts
2025-04-22 09:02:43 -03:00
amrit f1e8481878 refactor: remove iframe usage note from Preview tab in EmbedTabs component (#20810)
* fix: trigger lingo.dev by removing duplicate value

* refactor: remove iframe usage note from Preview tab in EmbedTabs component
2025-04-22 10:03:19 +00:00
amritandHariom Balhara acf3a7c54e feat: add "React (Atom)" to embed generator and rename rest (#20618)
* fix: trigger lingo.dev by removing duplicate value

* feat: add support for React Atom embed options

* fix: update locator for React code tab in embed code generator E2E tests

* fix: update embed tab names to clarify iframe usage

* fix failing test

* fix: indentation and installation commands for atoms

* fix: add note about iframe usage in Preview tab

* refactor: replace Cal component with BookerEmbed in EmbedCodes

* fix: Add EmbedConfig type import in Embed.tsx

- Added missing EmbedConfig type to the existing types import
- Resolves TypeScript error "Cannot find name 'EmbedConfig'"

* fix: update BookerEmbed usage in EmbedCodes component

* fix: enhance BookerEmbed implementation and update installation instructions in EmbedTabs

* fix: improve comments in EmbedCodes for clarity on layout options and styling references

* fix: streamline BookerEmbed usage in EmbedCodes with improved class handling and layout options

* fix: embed type selection

* fix: update layout selection in EmbedCodes to use dynamic configuration

* fix: update embed configuration to use theme instead of layout

* fix: conditionally render embed options based on if we are selecting atom

* fix: use simple props instead of embed specific logic to fix the active embed bug

* Fix double tabs activeness

* Revert isCurrent change

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-04-22 04:51:10 +00:00
Hariom Balhara f163e4638b fix: Convert an error in webhook to info (#20806)
* Convert error to info

* Convert error to info
2025-04-22 10:10:00 +05:30
Omar López 9dab915ddd chore: upgrade dependencies (#20740)
Post release / release (push) Canceled after 0s
2025-04-21 15:21:10 -03:00
GitHub Actions 8fca8f3910 chore: release v5.2.3 2025-04-21 18:20:38 +00:00
Omar López 1638465607 chore: remove swagger app and its dependencies (#20798) 2025-04-21 18:13:19 +00:00
Keith Williams ab7eb7f4b1 chore: Add layout.tsx to Foundation team CODEOWNERS (#20802) 2025-04-21 23:35:10 +05:30
Benny Joo 0592badd56 fix: semi-colon in the bottom of layout UI (#20799) 2025-04-21 15:46:23 +00:00
Morgan 7e82b8fa86 fix: sms workflow api v2 (#20795)
* fix: sms workflow api v2

* fixup! fix: sms workflow api v2
2025-04-21 14:41:59 +00:00
7dfb71ca30 fix: Location not changing when rescheduling (#20404)
* fix: location not chnging

* update

* update

* Update EventManager.ts

* update

* update

* Update reschedule.test.ts

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-04-21 14:45:59 +01:00
d99ecde0e2 fix: improved 'use-identity-login' error message for users trying to signInn using different provider (#20582)
* improved 'use-identity-login' error message for users trying to signin using different provider

* passing original identity provider as query parameter

* Update packages/features/auth/lib/next-auth-options.ts

* passed different error names for different types of error

---------

Co-authored-by: romit <romitgabani1@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-21 13:38:19 +00:00
Omar López d81b905ad7 refactor: replace timeZone string validation with timeZoneSchema (#20719)
* refactor: replace timeZone string validation with timeZoneSchema - Replace all instances of timeZone: z.string() with timeZoneSchema - Preserve modifiers (.optional(), .default(), .nullish()) where present - Fix VideoApiAdapter to use organizer's timezone value - Remove redundant isSupportedTimeZone import from booking.ts - Add timeZoneSchema import where needed

* Update index.ts

* fix: conflicts

Signed-off-by: Omar López <zomars@me.com>

* Update yarn.lock

* Discard changes to yarn.lock

* Update booking.ts

* Discard changes to packages/platform/types/me.ts

* Discard changes to packages/platform/types/me/outputs/me.ts

* Discard changes to packages/platform/types/schedules/schedules-2024-04-15/schedules.ts

* Discard changes to packages/features/ee/cal-ai-phone/zod-utils.ts

---------

Signed-off-by: Omar López <zomars@me.com>
2025-04-21 14:37:07 +01:00
Hariom Balhara 46c6582328 fix: Wrong text EmbedTheme -> Theme (#20790) 2025-04-21 10:16:09 +00:00
Hariom Balhara 396811b954 fix: Org onboarding handle error due to platform team being moved to regular org (#20786)
* Add better error logs and handle platform team

* Add better error logs and handle platform team
2025-04-21 14:58:55 +05:30
Lauris Skraucis f6c7d8d97f fix: v2 Sentry errors (#20788)
* fix: Only Team Event Types are supported for Instant Meeting

* refactor: Cannot read properties of undefined (reading 'timeZone')

* fix: Cannot read properties of undefined (reading 'map')

* refactor: rearrange function order

* refactor: booking_seats_full_error

* fix: invalid_round_robin_host

* refactor: booking_time_out_of_bounds_error

* refactor: Attempting to book a meeting in the past.

* fix: obj.phoneNumber.trim is not a function

* fix: Cannot use 'in' operator to search for 'disabled' in 1
2025-04-21 12:12:46 +03:00
Ronit Raj 930165b6e8 Update rateLimit.ts (#20785) 2025-04-21 05:33:00 +00:00
Hariom Balhara a77bd7bbe0 fx 404 for embed (#20120) 2025-04-20 16:16:48 +00:00
51212cdacf fix: header overlaps in month view on short viewport (#20749)
* fix: header overlaps in month view on short viewport

* refactor: Merge classes into same condition

* fix: Remove booker margin top if isEmbed

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
2025-04-20 08:03:12 +05:30
Lauris Skraucis 3731b1ef3d fix: calendar connect atoms (#20766) 2025-04-18 11:29:11 +00:00
amrit f631c4aa79 feat: make skeleton for booking embed accurate (#20765)
* fix: trigger lingo.dev by removing duplicate value

* feat: make skeleton for booking embed accurate
2025-04-18 10:28:37 +00:00
Lauris Skraucis 95cd76e732 fix: only managed users assignable to managed team event (#20754)
* fix: only managed users assignable to managed team event

* chore: bump platform libraries
2025-04-18 12:21:02 +05:30
Joe Au-Yeung 7e525779b7 Remove trailing } from utm param (#20763) 2025-04-18 11:13:26 +05:30
Anik Dhabal Babu f176ebf1da fix: offer seats broken (#20758) 2025-04-18 05:32:31 +00:00
Keith Williams 5058e0e750 chore: Remove verbose logging (#20732) 2025-04-18 04:20:19 +00:00
Joe Au-Yeung dc409e1c5a chore: Add consent message when SMS workflows are related (#20741)
* Add consent message

* Add consent message if using SMS workflow

* Skip flakey tests

* Revert "Skip flakey tests"

This reverts commit 3bd6f3fd71469df4dbd67b69b8e17011657df9d8.
2025-04-18 09:23:15 +05:30
Joe Au-Yeung 89b5d1ebc3 Write UTM params to Salesforce (#20759) 2025-04-18 03:24:57 +00:00
Joe Au-Yeung 0d24664a72 Skip flakey tests (#20761) 2025-04-18 03:01:35 +00:00
69efc26f36 perf: slim down middleware matcher (#20735)
* wip

* wip

* refactor

* add pathname

* fix

* fix: manually construct pathname for metadata functions

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add pathname parameter to all metadata functions

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add pathname parameter to remaining metadata functions

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix

* fix

* fix type checks

* Refactor

* fix

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
2025-04-17 19:55:01 -07:00
GitHub Actions 95c48ac805 chore: release v5.2.2
Post release / release (push) Canceled after 0s
2025-04-17 20:49:03 +00:00
Somay Chauhan 59ab38db98 feat: add guards to stripe teams controller (#20540)
* feat: add guards to stripe teams controller

* remove logs and comments

* fix return type status

* refactor: move PlatformSubscription to a dedicated module

* reroute to `organizations/stripe/save` for teams

* fix: type errors

* feat: fixed it for conferencing apps

* feat: Add error handling and fallback URL support in Stripe callback

* Refactor OAuth callback handling and move token validation to service layer

* Add documentation for OAuth callback proxying in conferencing and stripe controllers

* Move OAuthCallbackState type from organizations to stripe service module
2025-04-17 16:36:31 +00:00
Benny Joo 11a41e0e39 fix: return react fragment from all layout.tsx 2025-04-17 16:26:15 +00:00
Lauris Skraucis 880fe151c0 fix: v2 automatic round robin reassign error message (#20755) 2025-04-17 15:50:16 +00:00
Anik Dhabal Babu 603f106389 fix: Admin/owner unable to confirm managed bookings (#20743)
* fix: Admin/owner can't confirm managed booking

* Update confirm.handler.ts
2025-04-17 12:27:11 +00:00
Morgan 5c7f1aebb2 feat: filter users by assigned attribute options (#20659)
* feat: assigned attributes options base

* feat: assigned attributes options base e2e

* feat: filter assigned options by other assigned options

* feat: filter assigned options by other assigned options

* fixup! feat: filter assigned options by other assigned options

* feat: get org users by assigned attributes

* adding missing input file

* push generated docs

* feat: add filter org users by teamIds

* feat: add filter assigned attribute options by teamIds

* chore; update doc

* chore: remove console log

* chore: minor e2e fix

* chore: minor e2e fix

* chore: improve docs

* fix: assigned options by teamIds + optionIds
2025-04-17 14:05:06 +02:00
Anik Dhabal Babu a8fe3a6466 fix: failing e2e test (#20751) 2025-04-17 17:33:29 +05:30
Lauris Skraucis 8974f8f9de feat: v2 book team events using teamSlug & eventTypeSlug (#20585)
* feat: v2 book team events using teamSlug & eventTypeSlug

* fix: tests
2025-04-17 13:23:25 +03:00
Anik Dhabal Babu a11c2cc52c fix: Wrong schedule ID set for child managed event (#20745)
* fix: wrong schule id set for managed event

* Update handleChildrenEventTypes.ts
2025-04-17 08:58:01 +00:00
amrit 14d319f992 fix: buggy skeleton rendering for month view (#20744)
* fix: trigger lingo.dev by removing duplicate value

* fix: buggy skeleton monthly view
2025-04-17 11:10:47 +05:30
Benny Joo 49f0759f31 fix: disable Google Translate from HTML 2025-04-17 00:10:25 +00:00
GitHub Actions cae8470350 chore: release v5.2.1
Post release / release (push) Canceled after 0s
2025-04-17 00:05:25 +00:00
Joe Au-YeungandBenny Joo a82d616b79 feat: Whitelist user's from workflow spam scanning (#20717)
* Add whitelistWorkflows to user

* Do not autolock whitelisted users

* Add endpoint to whitelist a user

* Get users data - include whitelistWorkflows

* Whitelist a user on the user admin page

* Fix typo

* Add test

* Use translations

* Add `updateWhitelistWorkflows` to `UserRepository`

* Use i18 for strings

* Rename to whitelistWorkflows

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-04-16 18:09:29 -04:00
Anik Dhabal Babu 515f3263e0 test: e2e test for the Routing Form to Event Booking flow (#20728)
* test: e2e test Routing Form submission to Booking flow

* update

* tweak
2025-04-16 16:09:15 -03:00
Lauris Skraucis aebbc50ba6 fix: EventTypeSettings atom default schedule title (#20725) 2025-04-16 21:26:30 +03:00
Benny Joo 7cdc78d02d update middleware (#20726) 2025-04-16 17:44:25 +00:00
Keith Williams df55dec553 chore: Rename @calcom/lib/date-fns to @calcom/lib/dayjs (#19870)
* chore: Rename date-fns to dayjs

* Fixed types
2025-04-16 16:26:42 +00:00
Benny Joo 398ad22868 fix wrong translation (#20727) 2025-04-16 15:37:21 +00:00
Lauris Skraucis 7ef1050946 feat: v2 get slots using teamSlug + eventTypeSlug + optional organizationSlug (#20554)
* feat: allow teamSlug + eventTypeSlug + optional organizationSlug as input

* feat: return team event slots

* test: get slots using teamSlug + eventTypeSlug + optional organizationalSlug

* docs: how to get team event slots
2025-04-16 18:29:47 +03:00
Peer Richelsen 7b8bbaaa86 added lindy (#20721) 2025-04-16 14:27:53 +00:00
Omar López ddfd3fda70 refactor: improve error handling in captureErrorsMiddleware (#20718) 2025-04-16 14:01:47 +00:00
Benny Joo 5972a6f77f perf: Fetch data server-side for Webhook Create page (#20675)
* perf: fetch data server-side for create webhooks page

* refactor

* refactor

* refactor

* improve skeleton styling

* fix styling
2025-04-16 10:59:18 -03:00
9bc703a437 feat: Embed - Show skeleton loader when pageType is provided (#20646)
* mvp done

* wip

* fix ts errors and other code improvements

* fix ts errors

* ensure mobile layout support

* Make skeleton responsive on screen resize

* refactor

* Add test for EmbedElement

* make skeleton closer to pixel perfect

* Address PR feedback

* Remove PR_TODO

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
2025-04-16 13:40:51 +00:00
Syed Ali Shahbaz 15aaf7f51b fix: readonly ics error (#20651)
* readonly error fix

* warn instead of throwing err

* .

* .

* fix err

* dont throw error, only warn

* --

* remove skip writing as that's unnecessary

* fix api
2025-04-16 09:02:56 -03:00
Lauris SkraucisandAnik Dhabal Babu c89a1877ae feat: organization booking hooks (#20611)
* docs: request auth headers

* feat: useOrganizationsBookings hook

* feat: useOrganizationUserBookings hook

* rename hooks

* query params

* fix: organizations users bookings extract user from userId not auth

* fix: passing array from hook

* docs

* correct header doc

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-04-16 10:59:02 +03:00
Lauris Skraucis 15d2c0f858 feat: v2 return slots based on routing form response (#20637)
* feat: routing form response slots

* fix

* test routing form slots

* chore: bump libraries

* docs

* docs

* chore: api docs from main to merge main

* chore: regenerate api docs
2025-04-16 10:55:01 +03:00
Morgan 43a6663230 fix: routing form endpoints date filters validation (#20715) 2025-04-16 06:30:29 +00:00
58315e96c9 fix: don't allow seats for recurring events (#19257)
* fix: Don't allow seats for recurring events #19061

* update the commit with English

* Update common.json

* Update common.json

* Update common.json

* Add edge case when people have already enabled seats as well as recurring event

* fix type issue

* Handle case where seats and recurring is already enabled

* minor fix

* minor change

* typo

* Block backend functionality

* blocking new booking and other enhancements

* final-changes

* minor-changes

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
2025-04-15 21:07:57 +00:00
sean-brydon 3e466022b8 fix availability CTA height (#20714) 2025-04-15 20:11:29 +00:00
Keith Williams 68c036cd0a fix: Tasker import and error handling for routing form responses (#20713) 2025-04-15 19:43:54 +00:00
Benny Joo 77b656024a feat: placeholder component for user dropdown to remove layout shift (#20696)
* feat

* refactor
2025-04-15 15:17:14 -04:00
amritandTushar Bhatt 89772a0dac fix: make padding equal on availability toggle (#20708)
* fix: trigger lingo.dev by removing duplicate value

* fix: uneven height in toggle

* address mrge bots comments

* use tailwind

* fix: make padding equal for availability toggle

* Update apps/web/modules/availability/availability-view.tsx

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-15 18:40:04 +00:00
Giridhar 381c462660 feat: email workflow templates translation (#20674)
* Add translations in respective files

* implement multilingual support for email templates

* fix: indentation in common.js & typos in common.js and update.handler.ts
2025-04-15 23:56:34 +05:30
Shawn CazaandTushar Bhatt 417acdaacb fix: Improves dialog window that appears when there is no availability in a month. Adding a description to clarify limits when rolling or range period types are in use. (#20451)
* init

* updated typing, english localization text, used existing calculatePeriodLimits and isTimeViolatingFutureLimit to determine if go to next month button should be disabled

* improving copy

* semantic dialog heading tag, proper EN localization,

* reverting

* force local to origin

* Translations by LLM

* intit tests

* moving NoAvailability to its own file.

* refactor overlay component to encpsulate logic in hooks

* improved tests

* remove unintended change

* fix: period data type import. Providing default periodData values

* fix: type errors on event object

* fix: updates case in type import

* removing translations

* remove unnecessary import

* remove unnecessary import

* improving mocks for booker and logger

* moving tests to __tests__ folder

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-15 23:46:01 +05:30
Kartik SainiandTushar Bhatt 1584dd3179 feat: Redirect to original event link for rescheduling cancelled booking (#18605)
* feat: redirect to original event link for rescheduling cancelled booking

* allow cancelled booking rescheduling when allowRescheduleForCancelledBooking is true

* chore

* fix test

* chore

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-15 14:09:18 -04:00
d66f983f56 feat: dry run behaviour for connect atoms (#20699)
* add dry run prop for mocking actual behaviour without actual api calls

* fix e2e tests

* fixup

* remove await present twice

* fix: adjust success toast for apple connect dry run

Co-Authored-By: rajiv@cal.com <rajiv@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: rajiv@cal.com <rajiv@cal.com>
2025-04-15 17:45:14 +00:00
Rajiv Sahal b1f50fe22d feat: dry run behaviour for AvailabilitySettings atom (#20686) 2025-04-15 22:24:21 +05:30
Rajiv Sahal ce995c39f2 feat: dry run behaviour for event type atoms (#20689) 2025-04-15 22:23:51 +05:30
Rajiv Sahal b99ea16b31 feat: dry run behaviour for CalendarSettings atom (#20690) 2025-04-15 22:23:05 +05:30
Lauris Skraucis 574d8882f4 refactor: required booking fields message (#20688) 2025-04-15 16:29:45 +00:00
GitHub Actions b73f7f4a28 chore: release v5.2.0
Post release / release (push) Canceled after 0s
2025-04-15 15:56:48 +00:00
e4ebe25626 fix: made text dependent on host's refund policy by conditionally rendering text. (#20572)
* made text dependent on host's refund policy by conditionally rendering refund message

* add more conditions in case of no refund message

* made refund message more appropriate

* Update apps/web/public/static/locales/en/common.json

* Update apps/web/public/static/locales/en/common.json

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

---------

Co-authored-by: romit <romitgabani1@gmail.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-15 20:18:37 +05:30
amrit 7b6999831a fix: uneven toggle ui on availability page (#20701)
* fix: trigger lingo.dev by removing duplicate value

* fix: uneven height in toggle
2025-04-15 14:11:47 +00:00
Eunjae LeeandSean Brydon 0386723ae8 fix: recalculate date range from filter segments (#20633)
* fix: recalculate date range from filter segments

* clean up

* add unit tets for date-range utils

---------

Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
2025-04-15 08:54:44 +00:00
6615b885d1 chore: Make app compatible with Fluid Compute (#19841)
* chore: Make app compatible with Fluid Compute

* Removed isCold from me api endpoint

* chore: Remove handler caching for Fluid Compute compatibility (#20692)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

* fix client_id issue

* chore: Remove UNSTABLE_HANDLER_CACHE for Fluid Compute compatibility (#20694)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

* Fixing types

* Fixing type issues

* chore: Remove importHandler references for Fluid Compute compatibility

Co-Authored-By: keith@cal.com <keith@cal.com>

* chore: Remove importHandler references from attributes router for Fluid Compute compatibility

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-04-14 18:27:59 -04:00
Benny Joo 254a2e310b Revert "fix: Make event type title visible in mobile view (#20670)" (#20695)
This reverts commit bdf3935704.
2025-04-14 20:25:52 +00:00
GitHub Actions 1934c96353 chore: release v5.1.21
Post release / release (push) Canceled after 0s
2025-04-14 19:15:55 +00:00
Benny Joo a43437b1d6 fix (#20693) 2025-04-14 16:54:25 +00:00
devin-ai-integration[bot]andlauris@cal.com d25094a152 feat: allow paid users to see report issue option (#20687)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: lauris@cal.com <lauris@cal.com>
2025-04-14 18:19:05 +05:30
70d29ab6fa feat: conditionally show contact us or report issue based on plan (#20629)
* feat: conditionally show contact us or report issue based on plan

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: update icon name to triangle-alert to fix type error

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: check specific plan type instead of isPaidUser flag

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: check userBillingData.valid to ensure billing plan is active

Co-Authored-By: peer@cal.com <peer@cal.com>

* fix: convert plan to lowercase before comparison

Co-Authored-By: peer@cal.com <peer@cal.com>

* fixup: dont show contact card for free platform users

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: peer@cal.com <peer@cal.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
2025-04-14 11:13:05 +00:00
devin-ai-integration[bot]andsean@cal.com 65abf4edc6 fix: make settings sidebar accordion items keyboard accessible (#20663)
* fix: make settings sidebar accordion items keyboard accessible

Co-Authored-By: sean@cal.com <sean@cal.com>

* fix: ensure keyboard events properly toggle accordion content

Co-Authored-By: sean@cal.com <sean@cal.com>

* fix: add toggle functionality to accordion buttons

Co-Authored-By: sean@cal.com <sean@cal.com>

* fix: clean up accordion code and improve keyboard accessibility

Co-Authored-By: sean@cal.com <sean@cal.com>

* fix: add keyboard event handlers for accordion toggle

Co-Authored-By: sean@cal.com <sean@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sean@cal.com <sean@cal.com>
2025-04-14 08:55:13 +00:00
Keith Williams ad4aeee45b chore: clean up api v2 docs (#20668)
* chore: Clean up API v2 docs

* Regenerated the doc files

* Added an 'a' to match other titles
2025-04-14 05:37:07 -03:00
40a2ec8ab3 feat(event-types): phase out offset start times feature for new event types [CAL-5450] (#20623)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: carina@cal.com <carina@cal.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-04-14 11:58:18 +05:30
devin-ai-integration[bot]andhariom@cal.com 2b7a87a34c fix(analytics): Add cookie_flags to GA4 config for embedded contexts (#20627)
* fix(analytics): Add cookie_flags to GA4 config for embedded contexts (CAL-5446)

Co-Authored-By: hariom@cal.com <hariom@cal.com>

* fix(analytics): Fix JavaScript syntax for cookie_flags parameter

Co-Authored-By: hariom@cal.com <hariom@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: hariom@cal.com <hariom@cal.com>
2025-04-14 11:36:52 +05:30
Anik Dhabal Babu b15060e306 fix: dynamic text variable not populating on workflows (#20657)
* fix: dynamic text variable not populating

* Update customTemplate.ts
2025-04-14 10:54:43 +05:30
devin-ai-integration[bot]andbenny@cal.com bdf3935704 fix: Make event type title visible in mobile view (#20670)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
2025-04-14 09:48:34 +05:30
Gaurav Verma 7952cfc9e9 feat: Org and Team ID on organisation and team pages (#20645)
* added org and team id on respective pages

* added error hadling on clipboard.writeText
2025-04-14 09:37:46 +05:30
devin-ai-integration[bot]andbenny@cal.com 4383d23d4c fix: replace hard-coded strings with translation keys for internationalization (#20672)
* fix: replace hard-coded strings with translation keys for internationalization

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: replace loading text with translation key

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add translation keys for platform plans view

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add translation keys for oauth view

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add translation keys for users table

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add translation keys for oauth view toast messages

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add translation keys for verify view

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: update sendVerificationLogin to use t function

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: remove duplicate translation keys in common.json

Co-Authored-By: benny@cal.com <benny@cal.com>

* fix: add missing verification_email_sent translation key

Co-Authored-By: benny@cal.com <benny@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: benny@cal.com <benny@cal.com>
2025-04-12 01:50:04 -04:00
Anik Dhabal Babu 027cad2cfd fix: location input value in custom event name (#20619)
* fix: location input value in custom event name

* Update EventAdvancedTab.tsx
2025-04-12 08:22:39 +05:30
Benny Joo b17b5a169f perf: make useLocale hook to use server-fetched i18n if in App router context (#20597)
* write I18nProvider

* rename

* update useLocale

* fix

* use a singleton

* safer

* better

* fix e2e test 1

* fix e2e test 2

* fix e2e test 3

* fix e2e test 4

* fix

* refactor

* address comments

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix flaky test

* add more caching

* revert changes to e2e tests

* add jsdoc

* memoize

* update window object with new translations in /settings/my-account/general

* remove dead code

* only use locale and ns as deps

* fix
2025-04-11 14:28:26 -07:00
Anik Dhabal Babu 9eb1fe06e3 fix: App installation option at the team level (#20666)
* fix: app installation option on team level

* update
2025-04-11 19:06:18 +00:00
Keith Williams 007349a180 revert: "fix: Broken API docs (#20664)" (#20667)
This reverts commit 9d0afbd6f3.
2025-04-11 18:46:42 +00:00
Joe Au-Yeung 46f979ba6d chore: Add logging and try/catch blocks in handleNewBooking (#20665)
* Add logging and try/catch blocks

* Add additional logging around `deleteEventsAndMeetings`

* Type fix
2025-04-11 13:49:21 -04:00
devin-ai-integration[bot]andsean@cal.com d4eb8e4b3a fix: ensure only top tip has focusable elements (#20661)
* fix: ensure only top tip has focusable elements

Co-Authored-By: sean@cal.com <sean@cal.com>

* fix: disable all interactive elements for non-top tips

Co-Authored-By: sean@cal.com <sean@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: sean@cal.com <sean@cal.com>
2025-04-11 15:42:14 +00:00
Keith Williams 9d0afbd6f3 fix: Broken API docs (#20664) 2025-04-11 15:15:04 +00:00
Omar López 821bd37b37 fix: convert SVG avatar into PNG on save and serve (#20654) 2025-04-11 08:14:01 -07:00
Morgan 2ab34004a2 fix: updatedAt routing form response migration (#20660) 2025-04-11 11:59:22 +00:00
3ee8032538 feat: add organization routing forms endpoints [v2] (#20580)
* feat: add organization routing forms endpoints

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* refactor: reorganize team routing forms modules and add new endpoints

* fix: routing form responses and updated at

* fix: OrganizationsRoutingFormController e2e test

* refactor and fix stuff

* remove not needed files and fix comments

* fix OrganizationsTeamsRoutingFormsResponsesController

* fix tests

* fix tests

* fix: get routing form output

* remove log

* remove now

* fix getTeamRoutingForms types

* refactor: update routing forms API endpoints and repository methods

* added `teamIds?: number[]` filter

* Update get-routing-form-responses-params.input.ts

* Update get-routing-form-responses-params.input.ts

* fix e2e

* add Patch team routing form responses e2e

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-04-11 06:58:00 -03:00
GitHub Actions 9838488068 chore: release v5.1.20
Post release / release (push) Canceled after 0s
2025-04-10 22:59:10 +00:00
Vijay 1c365dee38 fix: flaky ooo e2e test (#20649)
* split big date range filter test to smaller tests

* add missing waitFor()
2025-04-10 21:10:37 +05:30
amrit a77ee7aaa2 chore: remove unnecessary tests and mock data (#20648)
* fix: trigger lingo.dev by removing duplicate value

* fix: Update event type in tests and remove offset times checks

* Changed event type from "Offset Event" to "Test Event" in the limits tab tests.
* Removed the check for offset times from the booking page fixture, streamlining the booking process.
2025-04-10 19:39:39 +05:30
devin-ai-integration[bot]andeunjae@cal.com 07d8a39829 feat: display year for bookings in different years (#20647)
Conditionally displays the year in the date format for upcoming bookings
on the /bookings page when the booking's year differs from the current year.

Fixes CAL-5455

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
2025-04-10 13:28:00 +00:00
Avnish Jha 10fa080b7f feat: Removed offset start times feature from UI (#20634)
* feat: Removed offset start times feature from UI

* conditionally rendering offsetStart (#20622)

* fixes for failing tests

* fixes failing tests

* failing test fixes
2025-04-10 17:46:53 +05:30
Tushar Bhatt eb19edfa8f feat:overlay-calendar (#20610) 2025-04-10 11:53:02 +00:00
Omar López 3719bb3762 Update CONTRIBUTING.md (#20641) 2025-04-10 08:03:50 +01:00
Joe Au-Yeung c43c5a0604 chore: Admin unlocking user, also verify workflow steps (#20613) 2025-04-09 14:30:00 -07:00
GitHub Actions 1467b0b768 chore: release v5.1.19
Post release / release (push) Canceled after 0s
2025-04-09 20:53:57 +00:00
Gaurav Vermaandamrit d3d4a90776 feat: added rescheduled UI to Booking page and Booking Listing page (#20401)
* feat: add rescheduling to UI

- Introduced displaying the original booking information.
- Updated UI components to show rescheduling status and details.

* fix: update link styling in booking single view

- Changed link text size to small for better UI consistency in the booking details section.

* refactor, type fixes

* added missing margin

* added rescheduled under other badges

* optimised getting booking

* updated namings and perf

---------

Co-authored-by: amrit <iamamrit27@gmail.com>
2025-04-09 15:21:26 -04:00
Joe Au-Yeung 5ae50f8670 chore: Add more Salesforce logging (#20639)
* Add logging to `companyFieldName`

* Add logging to `getTextValueFromRoutingFormResponse`

* Add logging around unhandled field type

* Remove extra bracket
2025-04-09 18:55:23 +00:00
a3642a5594 feat: move workflow emails to smtp (#20293)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-04-09 11:25:39 -07:00
Peer Richelsen 96bfb19769 Create platform.md 2025-04-09 12:31:43 +02:00
Eunjae Lee 5935cf7f1b fix: separate Filter Segment implementation from DataTableProvider (#20587)
* fix: separate Filter Segment implementation from DataTableProvider

* disable components

* early return

* fix type error

* add missing export
2025-04-09 09:53:02 +00:00
sean-brydonandSean Brydon 70eff63529 fix full page reload (#20625)
Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
2025-04-09 09:24:00 +00:00
Anik Dhabal Babu 3d02df3b08 fix: use correct organizer info when admin accepts team booking requiring confirmation (#20612)
* fix: confrim handler

* fix test
2025-04-09 10:45:27 +05:30
GitHub Actions b9b12256fa chore: release v5.1.18
Post release / release (push) Canceled after 0s
2025-04-08 19:12:55 +00:00
Anik Dhabal Babu 1b051d102c fix: few flakes (#20608) 2025-04-08 16:33:44 +00:00
76a820f3ca fix(CAL-5435): Conditionally render selection bar buttons based on user role (#20604)
* fix(CAL-5435): Conditionally render selection bar buttons based on user role

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix(CAL-5435): Add fallback for test environments to ensure E2E tests pass

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix(CAL-5435): Fix data-testid attribute case for members-privacy-warning

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* clean up

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-04-08 16:06:44 +00:00
Joe Au-Yeung b3d5a6edca feat: Salesforce - on booking cancel, write to event object (#20601)
* Type app options

* Refactor `writeToPersonRecord` to `writeToRecord`

* Abstract writing to salesforce record options component

* Add option to write to event object on cancellation

* Add on booking cancel write to event object to schema

* Refactor writing to record on booking option to use `<WriteToObjectSettings />`

* Pass event object to crm delete method

* When cancelling booking write to event object
- App data isn't being read

* V1 pass app data to crm service

* Undo .env commit

* Fix passing event to cancel crm cancel event method

* Pass event type metadata to `EventManager` in `handleCancelBooking`

* Handle writing to event record

* Type fix
2025-04-08 11:40:05 -04:00
devin-ai-integration[bot] 4753bd785a feat: restrict attributes tab to admin/owners only (CAL-4852) (#20603) 2025-04-08 11:26:01 -04:00
Anik Dhabal Babu 1f634b7ca0 fix: Incorrect booker URL for individual event types inside the org (#20602)
* fix: incorrect domain

* Update

* tweak

* remove space

* Update
2025-04-08 11:59:19 +00:00
Morgan 2e0f4d3176 fix: use real ip in throttler [v2] (#20606) 2025-04-08 10:21:29 +00:00
Tushar BhattandPeer Richelsen c2fd990504 feat: sticky-shell (#20566)
* feat:sticky-shell

* minor-change

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-04-08 09:04:41 +00:00
ea3f4fedb0 fix: weight filter should never return length=0 (#20588)
* fix error

* add test

* return host weight instead

* adjust test

* remove .only from test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2025-04-08 07:21:34 +00:00
Anik Dhabal Babu 2db4222ffe wrong layout (#20584) 2025-04-08 05:20:29 +00:00
Hariom Balhara 1dc5cd4d41 add id field calendar-cache (#20578) 2025-04-08 02:51:30 +00:00
Keith Williams 35d54ff223 revert: "feat: enable 'Overlay Calendar' enabled by default for signed in users (#18188)" (#20600)
This reverts commit 46c7afade6.
2025-04-08 00:56:17 +00:00
Omar López e4c46e3387 Revert "chore: Migration to backfill EventType.userId with B, if null (#20562)" (#20598)
This reverts commit 6b00167657.
2025-04-07 16:32:40 -07:00
GitHub Actions 77d1a873a9 chore: release v5.1.17
Post release / release (push) Canceled after 0s
2025-04-07 21:49:31 +00:00
Anik Dhabal Babu 54d98e73a2 fix: all flaky e2e tests (#20476)
* fix: all flaky e2e tests

* update

* Update basic.e2e.ts

* Update basic.e2e.ts

* update

* Update basic.e2e.ts

* update

* update

* push

* update

* final

* update

* fix
2025-04-07 19:43:01 +00:00
Benny JooandKeith Williams 134086f229 perf: fetch data server side for /settings/webhooks (#20583)
* update client components

* update server components

* update middleware

* fix

* fix

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-04-07 18:21:13 +00:00
Anik Dhabal Babu e9b5aa6f22 fix: verification code truncation fix (#20592) 2025-04-07 17:52:34 +00:00
Morganandsupalarry 3908a6cd6f revert: refactor v2 access and refresh tokens (#20589)
* revert: refactor v2 access and refresh tokens

* feat: add refreshTokenExpiresAt to /force-refresh, /exchange/, /refresh

* feat: encode ownerId, expiresAt in tokens & return refresh expires at when creating managed user

* driveby:fix: pass language when creating managed user

* docs: tokens expires at and payload

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-04-07 19:44:10 +03:00
Benny Joo 886028c400 fix: turbo pack error in webhooks/new/page.tsx (#20581) 2025-04-07 15:48:45 +05:30
Eunjae Lee 703e20a43a feat: support searchTerm in Filter Segment (#20526)
* feat: support searchTerm in Filter Segment

* update tests

* save and load searchTerm

* fix types

* add missing condition
2025-04-07 05:27:58 -03:00
Hariom Balhara b6aedb830b Require confirmation on toggling delegation credential (#20576) 2025-04-07 08:05:53 +00:00
46c7afade6 feat: enable 'Overlay Calendar' enabled by default for signed in users (#18188)
* feat: enable 'Overlay Calendar' enabled by default for signed in users

* feat: enable 'Overlay Calendar' enabled by default for signed in users

Changes Made:
- 'Overlay Calendar' is enabled by default when the component mounts for signed in users
- On every view which is toggled, 'Overlay Calendar' will be enabled by default for signed in users

* feat: enable 'Overlay Calendar' enabled by default for signed in users

Changes Made:
- 'Overlay Calendar' is enabled by default when the component mounts for signed in users in the web wrapper instead of the Booker Component

* feat: enable 'Overlay Calendar' enabled by default for signed in users

Changes Made:
- Persist the user’s preference to keep it disabled, even after a view toggle.

* minor-enhancement

---------

Co-authored-by: amrit <iamamrit27@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
2025-04-07 12:28:53 +05:30
Benny Joo 5c7d7579ee fix: improve booking page error boundary + remove dynamic imports from Booker (#20560)
* improve booking page error boundary + remove dynamic imports from Booker

* safer

* fix

* fix test

* change name
2025-04-07 09:51:14 +03:00
Somay Chauhan d158a1e007 fix: enabling weights on RR events breaks atoms (#20297)
* fix: enabling weights on RR events breaks atoms

* Update find-team-members-matching-attribute.input.ts

* refactor: Extract team member processing logic into separate hook

* Update find-team-members-matching-attribute.input.ts

* moved `TeamMemberDto` and `FindTeamMembersMatchingAttributeOutputDto`  to platform-types

* `team-members.output.ts` -> `team-members-attributes.output.ts`

* upgrade platform-libraries
2025-04-07 12:11:19 +05:30
Brijesh Thummar 1fb4147940 fix: update German translation for 'cancel' to 'Stornieren' (#20565) 2025-04-06 05:47:32 +00:00
Benny Joo 9cf76cbb2f perf: leverage trpc initialData for event-types page (#20496)
* create createRouterCaller and use it

* fix

* leverage trpc initialData for event-types page

* finalize
2025-04-05 11:29:46 +05:30
Alex van Andel 6b00167657 chore: Migration to backfill EventType.userId with B, if null (#20562)
* chore: Migration to backfill EventType.userId with _user_eventtype.B, if empty

* Add newline
2025-04-05 01:14:15 +01:00
Alex van Andel 592f44eb32 fix: broken getBusyTimes test in TZ!=UTC (#20555) 2025-04-04 23:26:41 +00:00
Benny Joo 00afafb550 chore: use createCallerFactory (#20467) 2025-04-04 20:06:32 -03:00
GitHub Actions 089941553e chore: release v5.1.16
Post release / release (push) Canceled after 0s
2025-04-04 23:02:43 +00:00
Benny Joo 2dc83a1c55 chore: remove unused dep—react-intl (#20531)
* remove react-intl

* yarn lock
2025-04-04 22:49:29 +00:00
Joe Au-Yeung 5bfb6fc148 refactor: SCIM - handleGroupEvents (#20537)
* Add logging to `handleGroupEvents`

* `createUsersAndConnectOrg` skipDuplicates when creating new users

* `ProfileRepository.createMany` to get username from `orgAutoAcceptEmail`

* Refactor `createUsersAndConnectToOrg` to use `createMany` instead of individual db calls

* Add error logging to `sendSignupToOrganizationEmail`

* Move `sendSignupToOrganizationEmail` to a `Promise.allSettled`

* Add `createManyForExistingUsers` and `createManyPromise`

* Move creating existing users profiles to `ProfileRepository.createManyForExistingUsers`

* Move `sendExistingUserTeamInviteEmails` to a `Promise.allSettled`

* Refactor `createUsersAndConnectToOrg` to use `ProfileRepository.createManyPromise`

* Type fix

* Create `generateNewChildEventTypeDataForDB`

* Create `addNewMembersToEventTypes`

* Type fix

* Type fix

* Type fix

* `createMany` new workflowsOnEventTypes

* Introduce `DATABASE_CHUNK_SIZE`

* Batch connect children event types

* Batch connect profiles to existing users
2025-04-04 17:36:26 -04:00
Max Prilutskiy 31ccdba203 feat: update translations via @lingodotdev (#20559) 2025-04-04 20:58:54 +00:00
sean-brydonandSean Brydon 82fb5272c8 add border to footer (#20543)
Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
2025-04-04 20:40:46 +01:00
e86ad03cde perf: Slim down logged in round 2 (#20535)
* perf: Slim down loggedInViewer some more

* Move locationOptions to apps router

* Moved calVideo related handlers to separate router

* Moved calendar related handlers to separate router

* Rename

* Moved deleteCredential to new router

* Moved updateProfile to me router

* fix unit test

* fix e2e test

* tweak

* missing

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2025-04-04 15:33:14 -04:00
Morgan 1953402f81 fix: platform dialog portal issue (#20553) 2025-04-04 15:39:36 +00:00
Lauris Skraucis d6a086c9c7 docs: platform refresh tokens and team event type atoms (#20524)
* docs: managing refresh token expiring

* docs: team event type creation and settings
2025-04-04 15:39:11 +00:00
sean-brydonandSean Brydon 9a517a89b6 fix: toggle group segment design (#20549)
* add toggle state correctly

* fix height

---------

Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
2025-04-04 13:47:05 +00:00
Keith Williams 19f2197eef Vital and libphonenumber-js (#20547) 2025-04-04 13:35:12 +00:00
Keith Williams 3d583678cf perf: Remove dynamic imports of Sentry (#20545) 2025-04-04 13:22:27 +00:00
Christoph HeikeandTushar Bhatt ddde762f9b feat: Implements Greetmate.ai app into Cal.com (#19812)
* Implements Greetmate.ai app into Cal.com

* Update GreetmateButton.tsx

* Greetmate app

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-04 10:07:13 -03:00
sean-brydonandSean Brydon 9332eba5d7 fix: badge dot sizes to be 8x8 (#20544)
* fix badge sizes to be 8x8

* add data-testid to dot

---------

Co-authored-by: Sean Brydon <sean@Seans-Mac-mini.local>
2025-04-04 09:49:00 -03:00
sean-brydon 46d1823ccc feat: add new UI component for multi option inputs (#20533)
* add multi option input support

* fix import

* add multi-key-field-support
2025-04-04 10:27:04 +00:00
Lauris Skraucis 277da187d0 feat: v2 admin GET bookings, user information (#20221)
* feat: membership user return bio and metadata

* feat: input and output bio and metadata for users and manager users

* test: managed event type appears when fetching user event types

* test: managed event type appears when fetching user event types

* test: managed event type appears when fetching user event types

* test: managed event type appears when fetching non-org team user event types

* feat: organizations users bookings endpoint

* feat: book using eventSlug & username & organizationSlug

* chore: generate latest docs
2025-04-04 10:25:10 +00:00
Carina WollendorferandCarinaWolli 7c6b055cd1 add include timezone param (#20542)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-04-04 10:05:51 +00:00
Benny Joo 97308ebf1c perf: /event-types/[type] — use server-fetched data to skip client-side trpc fetch (#20497) 2025-04-04 09:43:04 +00:00
Pranav Patil c2cf55f10c fix: Updated DateRangePicker in limit future booking (#20483)
* fix: Updated DateRangePicker in limit future booking

* removed unnecessary comments

* minor changes in end date selection

* final changes in end date selection

* minor changes in end date selection

* fixed issue of logs

* added allowPastDates props for out of office data selection
2025-04-04 13:42:18 +05:30
Joe Au-Yeung 3867ff6874 chore: Salesforce - Handle datetime and textarea field types (#20539)
* Add datetime and textarea field types

* Add logic for datetime and textarea field types
2025-04-04 09:32:30 +02:00
Pratheek Ravikumar 0e1c4e53d8 fix: Fixing the overlapping timezone dropdown menu (#20487)
* Fixing the overlapping timezone dropdown menu

* making the timezone menu to display 5 timezones at a time
2025-04-04 12:54:15 +05:30
Vincent Lamandamrit 3834e69100 Added new table under Insights page for most cancelled bookings (#19858)
- Added new Table componnent
- Added trpc call
- Added locale title

fixed rebase changes

Co-authored-by: amrit <iamamrit27@gmail.com>
2025-04-04 11:49:49 +05:30
Pranav Rajveer a05935cdf6 fix: fixed language translations (#20488)
* fix: fixed language translations

* fix: removed type-error

* Update DisableAllEmailsSetting.tsx
2025-04-04 05:36:21 +00:00
Benny Joo 19a0f968ca perf: Speed up User Type booking page (#20516)
* refactor [user]/[type] server logic

* refactor useSlot

* refactor

* fix

* fix OG images

* fix
2025-04-03 22:13:53 -03:00
GitHub Actions 3b945ba439 chore: release v5.1.15
Post release / release (push) Canceled after 0s
2025-04-03 22:23:50 +00:00
Keith Williams 7a47eacb34 chore: Update favicon for docs (#20532) 2025-04-03 21:48:15 +00:00
MorganandEunjae Lee da853958f7 feat: get org bookings endpoint api-v2 (#20462)
* feat: get org bookings endpoint api-v2

* fix userId filter transform

* fix: update getBookings to take filter.userIds in account

* refactor: getBooking where clause

* fixup! refactor: getBooking where clause

* feat: enable org owners to see org member bookings as attendees

* update flaky e2e tests

* fixup! feat: enable org owners to see org member bookings as attendees

* fixup! fixup! feat: enable org owners to see org member bookings as attendees

* chore: differentiate between org/team owner/admin

* chore: throw error with invalid userIds filter

* chore: clearly ensure non team orgs when orgId is not specified

* bump libraries and add comments

* bump libraries

* chore: give main profile orgId to getAllUserBookings

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-04-03 17:36:10 -04:00
Udit Takkar d63d15b7b2 fix: don't display user event types with no owner (#20525) 2025-04-03 21:08:19 +00:00
6bc040b7bb feat: Update @dub/analytics to support domainsConfig.refer (#20495)
* Update `@dub/analytics` to support domainsConfig.refer

* add reverse proxy

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-03 16:32:58 -04:00
0465c639e6 feat: options to disable cancelling and/or rescheduling (#20489)
* feat:disable-rescheduling

* feat:disable-cancelling

* enhancements

* enhancements-2

* enhancements-3

* revert CancelBooking.tsx

* add-test

* final-changes

* minor-change

* fix: Revert yarn.lock accidental commit

* minor-change

* fix:type checks

* fix:type check

* fix:type check

* add-test

* add-tests

* add-test

* fix:type checks

* fix:type check

* fix:clean-code

* fix:type check

* fix:type check

* fix:minor-change

* fix:tests

* fix:BookingListItem

* fix:type-check

* fix:type-check

* adding check for platform

* revert-change

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-04-03 20:04:02 +00:00
Tushar Bhatt 8d59c2fc16 feat: max-seats-per-time-slot (#20213)
* feat: max-seats-per-time-slot

* fix : test

* enhancements

* enhancements

* fix:test

* fix:test
2025-04-03 15:29:57 -04:00
Lauris Skraucis cb365117b3 feat: v2 return booking icsUid (#20527)
* feat: v2 return booking icsUid

* docs: regenerate
2025-04-03 14:37:40 +00:00
Benny Joo 7c9aae7eaf selectedDateString could be null (#20517) 2025-04-03 11:40:28 +00:00
Eunjae Leeandsean-brydon 1e7453edcd fix: keep search term on the url (DataTableToolbar.SearchBar) (#20521)
* fix: keep search term on the url (DataTableToolbar.SearchBar)

* update comment

* Update packages/features/data-table/components/DataTableToolbar.tsx

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

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-04-03 12:59:36 +02:00
Morgan f353fdf252 chore: bridge logs api v2 <-> libraries (#20440)
* chore: bridge logs api v2 <-> libraries

* chore: replace tslog directly

* chore: add settings handling

* chore: use minLevel to stop debug logs

* handle log levels

* bump libraries
2025-04-03 08:27:12 +00:00
Lauris Skraucis feff588603 fix: v2 allow creating team events without hosts (#20520)
* fix: v2 allow creating team events without hosts

* chore: remove log
2025-04-03 07:53:58 +00:00
8ae5d32fe5 fixed firstname lastname identifiers (#20480)
Co-authored-by: romit <romitgabani1@gmail.com>
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-04-03 04:21:03 +00:00
Anik Dhabal Babu 9600ab6085 fix: Add to calendar feature not working properly (#20515) 2025-04-03 02:41:36 +00:00
417a612aa3 refactor: extract NextRequest from handle new booking (#20082)
* extract NextRequest

* update api and tests

* booking limits tests

* fix more tests to use new approach

* update more tests with new format

* extract getOrgDomainConfig to not use req

* extract req from loadNewUsers and pass in hostname/forcedSlug

* fix instant meeting types and hostname fixes

* fix handleNewBookingReq

* fix type errors in tests

* make hostName and forcedSlug optional

* fix type err

* Revert "fix type err"

This reverts commit 9d5de9019d9dafe348c97b876baaa1d0675967e5.

* wip fix e2e

* fix: add missing headers

* migrate handle recurring event and also create tests specific to fn

* platform recurringbooking

* fix type

* hard code types on request object

* bump libraries

* fixup! bump libraries

* fix: accessing host if headers not passed

* fix: v2 recurring booking

* fix: accessing host if headers not passed

* chore: bump platform libraries

* fix tests

* push

* chore: bump libraries

* push lock changes

* bump libraries

---------

Co-authored-by: amrit <iamamrit27@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-04-02 17:57:58 -04:00
Eunjae Lee 4db3c592e5 fix: add oauth client id to table identifier on platform managed user list (#20513) 2025-04-02 21:40:38 +02:00
Lauris Skraucis d99db25dca docs: CalProvider (#20508) 2025-04-03 00:34:32 +05:30
Alex van Andel ba423e1061 Revert "fix: Correct import of markdownToSafeHtml on the client (#18195)"
This reverts commit f5750f3933.
2025-04-02 18:25:06 +01:00
GitHub Actions d5ca11b27b chore: release v5.1.14
Post release / release (push) Canceled after 0s
2025-04-02 16:08:32 +00:00
Benny Joo 25534f721a chore: refactor to use createRouterCaller util (#20494)
* create createRouterCaller and use it

* fix
2025-04-02 16:46:17 +01:00
fad5497363 feat: allow conferencing apps to be installed on teams (#19784)
* feat: added zoom, office365-video as valid event-type locations

* added docs

* Update stripe.service.ts

* enable conferencing apps for teams initial commit

* fix: make conferencing atom work with teams

* update team events in bulk update default location

* feat: Separate endpoints for team conferencing apps instead of query params

* Display all team events in 'Bulk update existing event types' modal when conferencing atom is enabled for Teams

* fix: bulk update default location for team events

* removed the organizations endpoints

* Refactor: Separate functions for team events to follow SRP

* undo extra changes

* undo extra changes

* fix: platform libraries build breaking

* feat: only allow integrations which are installed in create/update team/user event-type endpoints

* fix: merge conflicts

* remove redundant imports

* Update ConferencingAppsViewPlatformWrapper.tsx

* Update useBanners.ts

* fix: axios error response interceptor

* feat: update :app param docs in `organizations-conferencing.controller`

* Revert "fix: axios error response interceptor"

This reverts commit 729eac3eee9f81c19b5368bbae4b1666884221fd.

* Update useAtomsGetInstalledConferencingApps.ts

* upgrade platform libraries

* Update apps.ts

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2025-04-02 11:32:11 -04:00
Lauris Skraucis 563b31b7d0 fix: v2 bookings attendeeEmail filter (#20506)
* fix: GET bookings if attendeeEmail belong to managed user

* fix: GET bookings if attendeeEmail belong to managed user but does not include oAuthId
2025-04-02 15:21:27 +00:00
Joe Au-Yeung 8c9eb18463 chore: Add spam checking for workflow bodies (#18822)
* Add akismet package to tasker

* Create scanWorkflowBody task

* Schedule workflow body scan

* Add AKISMET_API_KEY .env

* Auto lock user if spam is detected

* Uncommit key

* Add safe param to workflow step

* Migration for safe field

* Do not process workflow steps is `safe` is false

* Update migration to set previous records to true

* Address comments

* Refactor `scheduleWorkflowNotifications` to accept an object

* If new steps or editing old ones send to tasker

* Call `scheduleWorkflowNotifications` in task

* Fix `IS_SELF_HOSTED`

* Remove unused function

* Make `safe` optional in schema

* Type fix

* Revert "Make `safe` optional in schema"

This reverts commit d0964702affa87c35562300301473d25635c565b.

* Revert "Type fix"

This reverts commit d9a031303269a2994ae46f576ab2a3d31e4d977b.

* Type fixes

* Type fixes

* Address comments

* Fix tests

* Add tests

* Update tests

* Typo fix

* Update `safe` to `verifiedAt`

* feat: Compare workflow reminder bodies to default template (#19060)

* Add `getTemplateForAction` function

* Use `getTemplateForAction` when creating a new step

* Use `getTemplateForAction` when action changes

* Have `emailReminderTemplate` accept an object as a param

* Rename `getTemplateForAction` to  `getTemplateBodyForAction`

* Simplify changing body when changing templates

* Create `compareReminderBodyToTemplate`

* In task, compare if reminderBody is a template

* Linting

* Add tests

* refactor: `emailReminderTemplate` to accept object as param (#19288)

* Add `getTemplateForAction` function

* Use `getTemplateForAction` when creating a new step

* Use `getTemplateForAction` when action changes

* Have `emailReminderTemplate` accept an object as a param

* Rename `getTemplateForAction` to  `getTemplateBodyForAction`

* Simplify changing body when changing templates

* Create `compareReminderBodyToTemplate`

* In task, compare if reminderBody is a template

* Linting

* Add tests

* Refactor `scheduleEmailReminders`

* Refactor `create.handler` for new workflows

* Refactor `emailReminderManager`

* Refactor `getEmailTemplateText`

* Fix typo

* Type fix - whatsapp plain text template imports

* Type fix - no template found

* Type fix - add `isBrandingDisabled` to `emailReminderTemplate`

* Add workflow and user to prisma mock

* Fix imports for akismet dependencies

* Record user lock reason

* Undo linting changes

* Fix tests

* New workflow, at verify created step

* Handle if `SCANNING_WORKFLOW_STEPS` is toggled

* Move `verifiedAt` checks to specific schedule functions
- `scheduleWhatsappReminder`
- `scheduleEmailReminder`
- `scheduleSMSReminder`

* Update logic

* Do not fallback verifiedAt

* Add comment to next.config.js
2025-04-02 08:16:26 -07:00
Lauris Skraucis 0965fad72a fix: v2 filter out ooo days out of slots (#20303)
* refactor: slots input service rely on eventTypeId and set start hours if none set

* fix: filter out ooo slots

* test: ooo days filtered out of slots

* revert changes
2025-04-02 11:53:52 -03:00
Lauris Skraucis e76bb37f78 refactor: v2 access and refresh tokens (#20486)
* feat: add refreshTokenExpiresAt to /force-refresh, /exchange/, /refresh

* feat: encode ownerId, expiresAt in tokens & return refresh expires at when creating managed user

* driveby:fix: pass language when creating managed user

* refactor: remove void notation and await access token propagation

* refactor: centralize createOAuthTokens and refreshOAuthTokens logic

* docs: tokens expires at and payload

* chore: comment selectedDate in examples app booker
2025-04-02 17:27:00 +03:00
sean-brydon b0a85a5637 removes additional space on top and bottom (#20503) 2025-04-02 13:13:59 +00:00
Morgan 82f374d84c chore: set x-request-id in response header for exeptions apiv2 (#20505) 2025-04-02 11:56:26 +00:00
Eunjae Lee 9ac037c524 fix: switch saveMode for SaveFilterSegmentButton (#20502) 2025-04-02 09:58:18 +00:00
Anik Dhabal Babu f5750f3933 fix: Correct import of markdownToSafeHtml on the client (#18195)
* chore: markdowntosafeHtml refactor

* update

* Update
2025-04-02 14:41:33 +05:30
sean-brydon 8f2cb0522d fix: google signin getting started redirect (#20501)
* fix redirect conditions

* remove addiiotnal query call
2025-04-02 08:59:18 +00:00
Eunjae Lee 4cd8dbbab4 feat: support Filter Segment in other pages (#20461)
* feat: support Filter Segment in other pages

* minor changes to the behaviors

* remove comment

* fix type error

* fix e2e test
2025-04-02 10:36:42 +02:00
1fb778ae78 feat: v3/UI docs (#20454)
* ui playground

* add types to ui

* push avatar ignore ts and eslint errors

* feat: V3 UI avatar  (#18997)

* avatar playground

* add overflow-hidden back for loading indicator

* update lock

* feat: v3/badge  (#18999)

* badge intro

* fix text on small badge

* add dark mode warning

* feat: v3/button (#19014)

* TOC

* wip buttons

* shadows

* secondary

* semantic colours

* minimal buttons

* update disabled states

* checkbox

* dropdown v3

* load icons

* fix design sections

* fix icon gap

* fix

* remove client component from rsc

* use peer as avatar

* gif

* fix button gap - dropdown iocn

* support start icon in dropdowns

* add alert demo + v3 component + darken semantic text

* base inputs and text field

* wip input

* input field addon/suffix

* toast page

* wip select

* match designs for input and select rounded

* fix

* fix select isloading transform

* fix multi icons transition rotation

* fix group heading

* range slider demo + range slider

* range slider popover design

* v3 dialog

* fix slider thumb size

* wrap in suspense for useSearchParam difference in next14+

* base of tabs + horizontal tabs

* fix select sizing to match input 36px

* fix dark mode hover on button

* target domState class for light/dark so we work on portal elements

* fix default bg on popover

* lightmode toasts + eleveation token

* empty screen

* intro to split button concept

* mass colour updates

* intro to themes

* remove i18n from label with name

* bouncy buttons

* feat: small select

* nav item dumb component

* full demo

* settings demo

* fix tabs

* fix mt

* fix dropdown feedback from loom

* alert fixes mentioned in loom

* push font-cal

* move to fumadocs

* fix avatar

* colors table

* add typeography

* icon + spacing guide

* add border radius and shadow tables

* fix colours and border radius

* remove prose undline via fumas fork

* fix icon buttons sizes

* button group component

* fix horizontal tabs

* tab fixes

* switches

* pagination component

* show toasts components raw

* sync with airtable

* add use client

* 10px on input again

* 10px in select also

* fix input height

* fix files merge

* fix loading state for icon buttons

* fix button fab

* Enable addon to now overflow to next line in DS

* use cal font for dialog text

* add avatar square variant

* fix select padding and menu size

* fix type errors

* fix type errors

* add switch small size and fix type errors

* fix ref types on input

* update web styles

* remove addonFilled to fix type errors

* push dialog fixes

* fix checkbox display

* remove prose from alert examples

* remove prose from all our component examples

* badge rounded-lg on lg size

* update colors

* force w/h on icon buttons instead of inheriting

* remove border-focus

* remove border-focus from tailwind config

* update tokens to match new colours

* add new border subtle tokens and add to toasts

* fix lg button padding

* fix and drastically improve rtl rests

* multi select use badges

* use badge design for select pills

* fix avatar alignment

* fix tests and remove redudant ones

* fix tests and remove redudant ones

* update atoms globals css

* extract tokens.css

* extract app.css

* force flex for atoms

* add @calcom/ui/css

* revert ui playground styles abstraction

* fix input focus states

* fix focus states for buttons

* feat: v3/app work (#19233)

* globals.css for app + event types listing search

* few app wide NITS

* fix select foucs within styling

* fix formbuilder switch styles

* fix disabled state of nested inputs

* workflow fixes

* webhook form fixes

* i18n on tabs info

* booking filters + tab

* fix filters spacing

* make first item so we get correct border radius if not default

* round radio group in create event types + make button small

* profile page

* update editor br

* button nits in existing places in the app

* fix timezone select and multilist in guest form

* fix text area on public booking page

* size small event types create

* margin on routing filter

* filters improvments

* routing insights fixes

* fix date filter range

* ooo fixes

* fix teams/members bulk actions

* create team UI

* i18n in useTabs instead of in tab component now

* Add event type setup border to availability

* fix bulk actions on org members table

* onboarding fixes

* fix input types with ref stripe comparision

* i18n fixes for platform

* update globals of atoms

* fix form builder firstname/lastname switch + correct footer useage

* fix select

* Revert "add @calcom/ui/css"

This reverts commit 0dd8f9012907a278cfede4a98f289cb0744c9244.

* revert tokens css

* Revert "extract app.css"

This reverts commit 10b998604816ad3310c24b36a02a638dabc5be24.

* linear nits

* workflow dropdown

* fix page theme heading sizes to match cards

* fix stepper for team creation

* adjust margin to FilterSearchField

* fix ooo icon

* fix booker and dark mode due to darkgray 50

* more dark mode fixes

* add size prop

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Morgan Vernay <morgan@cal.com>

* update package name

* update package json lost in merge

* update package json lost in merge

* re-genrate source

* fix imports

* imports

* fix merge issues and imports

* restore globals css

* add source to git ignore

* remove old source files

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2025-04-02 09:10:07 +01:00
Udit TakkarandOmar López bc9d76e7da chore: do not route to meeting-ended page for daily link used for testing (#20371)
* chore: remove 14 day buffer for QA

* chore: check uid

---------

Co-authored-by: Omar López <zomars@me.com>
2025-04-02 00:00:28 +00:00
Joe Au-Yeung af51ce1890 chore: Salesforce - handle checkbox type fields (#20490)
* Add checkbox field type

* Handle accepting booleans under value field when saving writing to field settings

* UI saving settings to write to checkbox field

* Prepare update body to pass boolean value to checkbox field

* Type fixes

* Type fix
2025-04-02 00:00:02 +00:00
Benny Joo 75c79f76c8 perf: Remove ssrInit completely (#20439)
* remove ssrInit completely

* remove instances of dehydratedState

* fix

* refactor

* fix type checks

* fix linting

* Refactor

* Refactor

* remove log
2025-04-01 19:14:46 -04:00
Benny Joo b5b4134b75 feat: implement booking page error boundary (#20492)
* implement booking page error boundary

* use it
2025-04-01 22:13:18 +00:00
1480 changed files with 61328 additions and 16087 deletions
+9
View File
@@ -187,6 +187,7 @@ TWILIO_WHATSAPP_PHONE_NUMBER=
# For NEXT_PUBLIC_SENDER_ID only letters, numbers and spaces are allowed (max. 11 characters)
NEXT_PUBLIC_SENDER_ID=
TWILIO_VERIFY_SID=
TWILIO_OPT_OUT_ENABLED=
# Set it to "1" if you need to run E2E tests locally.
NEXT_PUBLIC_IS_E2E=
@@ -424,6 +425,8 @@ DIRECTORY_IDS_TO_LOG=
# Read more about it in the README.md
NEXT_PUBLIC_SINGLE_ORG_SLUG=
AKISMET_API_KEY=
## Env variables related to avoiding booking failures
# Request for checking reservation would be attempted to send every these seconds if the request is stale at that time
NEXT_PUBLIC_QUERY_RESERVATION_INTERVAL_SECONDS=
@@ -439,3 +442,9 @@ NEXT_PUBLIC_QUICK_AVAILABILITY_ROLLOUT=10
# Deprecated. Use NEXT_PUBLIC_BODY_SCRIPTS instead. NEXT_PUBLIC_HEAD_SCRIPTS are now injected into the body only.
NEXT_PUBLIC_HEAD_SCRIPTS=
NEXT_PUBLIC_BODY_SCRIPTS=
DATABASE_CHUNK_SIZE=
# Service Account Encryption Key for encrypting/decrypting service account keys
# You can use: `openssl rand -base64 24` to generate one
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY=
+9
View File
@@ -1,5 +1,11 @@
/**/package.json @calcom/Foundation
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/components/**/* @calcom/Consumer @calcom/Foundation
/apps/web/modules/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/**/* @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
/deploy/**/* @calcom/Foundation
/infra/**/* @calcom/Foundation
/packages/app-store/applecalendar/**/* @calcom/Foundation
@@ -20,4 +26,7 @@
/packages/lib/slots.ts @calcom/Foundation
/packages/platform/**/* @calcom/Platform @calcom/Foundation
/packages/prisma/**/* @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
/packages/ui/**/* @calcom/Consumer @calcom/Foundation
+58
View File
@@ -0,0 +1,58 @@
---
name: Cal.com/platform request
about: Suggest a feature or bug report for cal.com/platform
title: ""
labels: ["platform", "🚨 needs approval"]
assignees: ""
---
### Is your proposal related to a problem?
<!--
Provide a clear and concise description of what the problem is.
For example, "I'm always frustrated when..."
-->
(Write your answer here.)
### Describe the solution you'd like
<!--
Provide a clear and concise description of what you want to happen.
-->
(Describe your proposed solution here.)
### Describe alternatives you've considered
<!--
Let us know about other solutions you've tried or researched.
-->
(Write your answer here.)
### Additional context
<!--
Is there anything else you can add about the proposal?
You might want to link to related issues here, if you haven't already.
-->
(Write your answer here.)
### Requirement/Document
<!--
Is there any type of document that could support that feature?
-->
(Share it here.)
---
##### House rules
- If this issue has a `🚨 needs approval` label, don't start coding yet. Wait until a core member approves feature request by removing this label, then you can start coding.
- For clarity: Non-core member issues automatically get the `🚨 needs approval` label.
- Your feature ideas are invaluable to us! However, they undergo review to ensure alignment with the product's direction.
- Follow Best Practices lined out in our [Contributor Docs](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
+37
View File
@@ -0,0 +1,37 @@
on:
workflow_call:
pull_request:
types: [closed]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
cleanup-report:
runs-on: ubuntu-latest
steps:
- name: Checkout GitHub Pages Branch
uses: actions/checkout@v4
with:
repository: calcom/test-results
ref: gh-pages
token: ${{ secrets.GH_ACCESS_TOKEN }}
- name: Set Git User
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Cleanup HTML reports on PR close/merge
env:
BRANCH_REPORTS_DIR: reports/${{ github.event.pull_request.head.ref }}
run: |
if [ -d "$BRANCH_REPORTS_DIR" ]; then
rm -rf "$BRANCH_REPORTS_DIR"
git add .
git commit -m "workflow: remove all reports for branch ${{ github.event.pull_request.head.ref }} (PR closed/merged)"
git push
else
echo "No reports found for branch ${{ github.event.pull_request.head.ref }}. Nothing to clean up."
fi
@@ -1,3 +1,5 @@
# deprecated - use smtp with tasker instead */
name: Cron - scheduleEmailReminders
on:
+11
View File
@@ -219,6 +219,17 @@ jobs:
uses: ./.github/workflows/publish-report.yml
secrets: inherit
cleanup-report:
name: Cleanup HTML report
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && (github.event.pull_request.merged == true || github.event.pull_request.state == 'closed') }}
permissions:
contents: write
issues: write
pull-requests: write
needs: [changes, check-label, publish-report]
uses: ./.github/workflows/cleanup-report.yml
secrets: inherit
required:
needs:
[
View File
View File
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
View File
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+69 -156
View File
@@ -1,43 +1,76 @@
# Contributing to Cal.com
Contributions are what makes the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**.
## House rules
## House Rules (for PRs and Issues)
- Before submitting a new issue or PR, check if it already exists in [issues](https://github.com/calcom/cal.com/issues) or [PRs](https://github.com/calcom/cal.com/pulls).
- GitHub issues: take note of the `🚨 needs approval` label.
- **For Contributors**:
- Feature Requests: Wait for a core member to approve and remove the `🚨 needs approval` label before you start coding or submit a PR.
- Bugs, Security, Performance, Documentation, etc.: You can start coding immediately, even if the `🚨 needs approval` label is present. This label mainly concerns feature requests.
- **Our Process**:
- Issues from non-core members automatically receive the `🚨 needs approval` label.
- We greatly value new feature ideas. To ensure consistency in the product's direction, they undergo review and approval.
### 👥 Prevent Work Duplication
Before submitting a new issue or PR, check if it already exists in the [Issues](https://github.com/calcom/cal.com/issues) or [Pull Requests](https://github.com/calcom/cal.com/pulls).
### ✅ Work Only on Approved Issues
For feature requests, please wait for a core team member to approve and remove the `🚨 needs approval` label before you start coding or submitting a PR.
For bugs, security, performance, documentation, etc., you can start coding immediately—even if the `🚨 needs approval` label is present.
We highly value new feature ideas, but to maintain consistency in the product direction, they must go through a review and approval process.
### 🚫 Don’t Just Drop a Link
Avoid posting third-party links (e.g., Slack threads or Linear tickets) without context. A GitHub issue or PR should stand on its own—reviewers shouldn’t have to chase information across multiple tools to understand the context.
### 👀 Think Like a Reviewer
Put yourself in the reviewer’s shoes. What would you want to know if reading this for the first time? Are there key decisions, goals, or constraints that need clarification? Does the PR assume knowledge that isn’t obvious? Are there related issues or previous PRs that should be linked?
### 🧵 Bring in Context from Private Channels
If the task originated from a private conversation (e.g., Slack), take a moment to extract the relevant details and include them in the GitHub issue or PR. Avoid sharing sensitive information, but make sure important reasoning is captured.
> Example:
> “A user requested feature X to solve problem Y. I considered approaches A, B, and C, but chose C for the following reasons…”
### 📚 Treat It Like Documentation
GitHub is a shared source of truth. Every issue and PR contributes to the long-term understanding of the codebase. Write clearly enough that someone—possibly you—can revisit it months later and still understand what happened and why.
### ✅ Summarize Your PR at the Top
Even if the code changes are minor or self-explanatory, a short written summary helps reviewers quickly understand the intent. You can use GitHub Copilot’s auto-summarize feature, but make sure to verify it for accuracy and relevance.
### 🔗 Use GitHub Keywords to Auto-Link Issues
Use phrases like “Closes #123” or “Fixes #456” in your PR descriptions. This automatically links your PR to the related issue and closes it once merged—keeping everything traceable and organized.
### 🧪 Mention What Was Tested (and How)
Explain how you validated your changes. It doesn’t need to be exhaustive—just enough to give reviewers confidence that things were tested and work as expected.
> Example:
> “Tested locally with mock data and confirmed the flow works on staging.”
### 🧠 Assume Future-You Won’t Remember
Write with the future in mind. If there are trade-offs, edge cases, or temporary workarounds, document them clearly so they don’t get lost or misinterpreted later.
## Priorities
<table>
<tr>
<td>
Type of Issue
</td>
<td>
Priority
</td>
<td><strong>Type of Issue</strong></td>
<td><strong>Priority</strong></td>
</tr>
<tr>
<td>
Minor improvements, non-core feature requests
</td>
<td>Minor improvements, non-core feature requests</td>
<td>
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22Low+priority%22">
<img src="https://img.shields.io/badge/-Low%20Priority-green">
</a>
</td>
</tr>
<tr>
<td>
Confusing UX (... but working)
</td>
<tr>
<td>Confusing UX (but still functional)</td>
<td>
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22Medium+priority%22">
<img src="https://img.shields.io/badge/-Medium%20Priority-yellow">
@@ -45,9 +78,7 @@ Contributions are what makes the open source community such an amazing place to
</td>
</tr>
<tr>
<td>
Core Features (Booking page, availability, timezone calculation)
</td>
<td>Core Features (Booking page, availability, timezone calculation)</td>
<td>
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:%22High+priority%22">
<img src="https://img.shields.io/badge/-High%20Priority-orange">
@@ -55,9 +86,7 @@ Contributions are what makes the open source community such an amazing place to
</td>
</tr>
<tr>
<td>
Core Bugs (Login, Booking page, Emails are not working)
</td>
<td>Core Bugs (Login, Booking page, Emails not working)</td>
<td>
<a href="https://github.com/calcom/cal.com/issues?q=is:issue+is:open+sort:updated-desc+label:Urgent">
<img src="https://img.shields.io/badge/-Urgent-red">
@@ -68,59 +97,7 @@ Contributions are what makes the open source community such an amazing place to
## Developing
The development branch is `main`. This is the branch that all pull
requests should be made against. The changes on the `main`
branch are tagged into a release monthly.
To develop locally:
1. [Fork](https://github.com/calcom/cal.com/fork/) this repository to your
own GitHub account and then
[clone](https://help.github.com/articles/cloning-a-repository/) it to your local device.
2. Create a new branch:
```sh
git checkout -b MY_BRANCH_NAME
```
3. Install yarn:
```sh
npm install -g yarn
```
4. Install the dependencies with:
```sh
yarn
```
5. Set up your `.env` file:
- Duplicate `.env.example` to `.env`.
- Use `openssl rand -base64 32` to generate a key and add it under `NEXTAUTH_SECRET` in the `.env` file.
- Use `openssl rand -base64 32` to generate a key and add it under `CALENDSO_ENCRYPTION_KEY` in the `.env` file.
6. Setup Node
If your Node version does not meet the project's requirements as instructed by the docs, "nvm" (Node Version Manager) allows using Node at the version required by the project:
```sh
nvm use
```
You first might need to install the specific version:
```sh
nvm install
```
You can install nvm from [here](https://github.com/nvm-sh/nvm).
7. Start developing and watch for code changes:
```sh
yarn dev
```
[See README](https://github.com/calcom/cal.com#development)
## Building
@@ -130,23 +107,19 @@ You can build the project with:
yarn build
```
Please be sure that you can make a full production build before pushing code.
Please ensure that you can make a full production build before pushing code.
## Testing
More info on how to add new tests coming soon.
### Running tests
### Running Tests
This will run and test all flows in multiple Chromium windows to verify that no critical flow breaks:
[See README](https://github.com/calcom/cal.com#e2e-testing)
```sh
yarn test-e2e
```
#### Resolving Issues
#### Resolving issues
##### E2E test browsers not installed
##### E2E Test Browsers Not Installed
Run `npx playwright install` to download test browsers and resolve the error below when running `yarn test-e2e`:
@@ -166,68 +139,8 @@ If you get errors, be sure to fix them before committing.
## Making a Pull Request
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR.
- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Be sure to fill the PR Template accordingly.
- Review [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations
## Guidelines for committing yarn lockfile
Do not commit your `yarn.lock` unless you've made changes to the `package.json`. If you've already committed `yarn.lock` unintentionally, follow these steps to undo:
If your last commit has the `yarn.lock` file alongside other files and you only wish to uncommit the `yarn.lock`:
```bash
git checkout HEAD~1 yarn.lock
git commit -m "Revert yarn.lock changes"
```
_NB_: You may have to bypass the pre-commit hook with by appending `--no-verify` to the git commit
If you've pushed the commit with the `yarn.lock`:
1. Correct the commit locally using the above method.
2. Carefully force push:
```bash
git push origin <your-branch-name> --force
```
If `yarn.lock` was committed a while ago and there have been several commits since, you can use the following steps to revert just the `yarn.lock` changes without impacting the subsequent changes:
1. **Checkout a Previous Version**:
- Find the commit hash before the `yarn.lock` was unintentionally committed. You can do this by viewing the Git log:
```bash
git log yarn.lock
```
- Once you have identified the commit hash, use it to checkout the previous version of `yarn.lock`:
```bash
git checkout <commit_hash> yarn.lock
```
2. **Commit the Reverted Version**:
- After checking out the previous version of the `yarn.lock`, commit this change:
```bash
git commit -m "Revert yarn.lock to its state before unintended changes"
```
3. **Proceed with Caution**:
- If you need to push this change, first pull the latest changes from your remote branch to ensure you're not overwriting other recent changes:
```bash
git pull origin <your-branch-name>
```
- Then push the updated branch:
```bash
git push origin <your-branch-name>
```
Lastly, make sure to keep the branches updated (e.g. click the `Update branch` button on GitHub PR).
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) when creating your PR. (This option isn't available if you're [contributing from a fork belonging to an organization](https://github.com/orgs/community/discussions/5634))
- If your PR refers to or fixes an issue, add `refs #XXX` or `fixes #XXX` to the PR description. Replace `XXX` with the respective issue number. See more about [linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Fill out the PR template accordingly.
- Review the [App Contribution Guidelines](./packages/app-store/CONTRIBUTING.md) when building integrations.
- Lastly, make sure to keep your branches updated (e.g., click the `Update branch` button on the GitHub PR page).
+15
View File
@@ -0,0 +1,15 @@
Possible issue:
- We won't be able to use already built calendar-cache when Delegation Credential is enabled because existing CalendarCache entries don't have userId set.
## Approach of always using SelectedCalendar.credentialId for CalendarCache even for DelegationCredentials
- Delete Credentials when DelegationCredential is disabled
TO Test
- New members beyond the batch size are processed
+2 -1
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/lib/constants";
import slugify from "@calcom/lib/slugify";
import { _EventTypeModel as EventType, _HostModel } from "@calcom/prisma/zod";
import { customInputSchema, eventTypeBookingFields } from "@calcom/prisma/zod-utils";
@@ -103,7 +104,7 @@ const schemaEventTypeEditParams = z
.transform((s) => slugify(s))
.optional(),
length: z.number().int().optional(),
seatsPerTimeSlot: z.number().optional(),
seatsPerTimeSlot: z.number().min(1).max(MAX_SEATS_PER_TIME_SLOT).nullable().optional(),
seatsShowAttendees: z.boolean().optional(),
seatsShowAvailabilityCount: z.boolean().optional(),
bookingFields: eventTypeBookingFields.optional(),
+14 -4
View File
@@ -213,12 +213,14 @@ import { getAccessibleUsers } from "~/lib/utils/retrieveScopedAccessibleUsers";
* description: Authorization information is missing or invalid.
*/
async function handler(req: NextApiRequest) {
const { userId, isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req;
const { isSystemWideAdmin, isOrganizationOwnerOrAdmin } = req;
let userId = req.userId;
req.body = {
...req.body,
creationSource: CreationSource.API_V1,
};
if (isSystemWideAdmin) req.userId = req.body.userId || userId;
if (isSystemWideAdmin) userId = req.body.userId || userId;
if (isOrganizationOwnerOrAdmin) {
const accessibleUsersIds = await getAccessibleUsers({
@@ -226,11 +228,19 @@ async function handler(req: NextApiRequest) {
memberUserIds: [req.body.userId || userId],
});
const [requestedUserId] = accessibleUsersIds;
req.userId = requestedUserId || userId;
userId = requestedUserId || userId;
}
try {
return await handleNewBooking(req, getBookingDataSchemaForApi);
return await handleNewBooking(
{
bookingData: req.body,
userId,
hostname: req.headers.host || "",
forcedSlug: req.headers["x-cal-force-slug"] as string | undefined,
},
getBookingDataSchemaForApi
);
} catch (error: unknown) {
const knownError = error as Error;
if (knownError?.message === ErrorCode.NoAvailableUsersFound) {
@@ -185,7 +185,12 @@ async function verifyCredentialsAndGetId({
currentCredentialId: number | null;
}) {
if (parsedBody.integration && parsedBody.externalId) {
const calendarCredentials = getCalendarCredentialsWithoutDelegation(userCredentials);
const calendarCredentials = getCalendarCredentialsWithoutDelegation(
userCredentials.map((cred) => ({
...cred,
delegationCredentialId: null,
}))
);
const { connectedCalendars } = await getConnectedCalendars(
calendarCredentials,
+3 -1
View File
@@ -8,6 +8,7 @@ import { createContext } from "@calcom/trpc/server/createContext";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import type { TInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
import { ZInviteMemberInputSchema } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.schema";
import { createCallerFactory } from "@calcom/trpc/server/trpc";
import type { UserProfile } from "@calcom/types/UserProfile";
import { TRPCError } from "@trpc/server";
@@ -38,7 +39,8 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
const ctx = await createContext({ req, res }, sessionGetter);
try {
const caller = viewerTeamsRouter.createCaller(ctx);
const createCaller = createCallerFactory(viewerTeamsRouter);
const caller = createCaller(ctx);
await caller.inviteMember({
role: data.role,
language: data.language,
+6 -1
View File
@@ -24,7 +24,12 @@ import { membershipCreateBodySchema, schemaMembershipPublic } from "~/lib/valida
*/
async function postHandler(req: NextApiRequest) {
const data = membershipCreateBodySchema.parse(req.body);
const args: Prisma.MembershipCreateArgs = { data };
const args: Prisma.MembershipCreateArgs = {
data: {
createdAt: new Date(),
...data,
},
};
await checkPermissions(req);
+1 -9
View File
@@ -3,8 +3,7 @@ import utc from "dayjs/plugin/utc";
import type { NextApiRequest, NextApiResponse } from "next";
import dayjs from "@calcom/dayjs";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { isSupportedTimeZone } from "@calcom/lib/date-fns";
import { isSupportedTimeZone } from "@calcom/lib/dayjs";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { createContext } from "@calcom/trpc/server/createContext";
@@ -18,15 +17,8 @@ import { getHTTPStatusCodeFromError } from "@trpc/server/http";
dayjs.extend(utc);
dayjs.extend(timezone);
let isColdStart = true;
async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
if (isColdStart && IS_PRODUCTION) {
console.log("Cold start of /v1/slots detected");
isColdStart = false;
}
const { usernameList, isTeamEvent, ...rest } = req.query;
const parsedIsTeamEvent = String(isTeamEvent).toLowerCase() === "true";
let slugs = usernameList;
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { createContext } from "@calcom/trpc/server/createContext";
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
import { createCallerFactory } from "@calcom/trpc/server/trpc";
import type { UserProfile } from "@calcom/types/UserProfile";
import { TRPCError } from "@trpc/server";
@@ -39,7 +40,8 @@ const patchHandler = async (req: NextApiRequest, res: NextApiResponse) => {
/** @see https://trpc.io/docs/server-side-calls */
const ctx = await createContext({ req, res }, sessionGetter);
try {
const caller = viewerTeamsRouter.createCaller(ctx);
const createCaller = createCallerFactory(viewerTeamsRouter);
const caller = createCaller(ctx);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await caller.publish(req.query as any /* Let tRPC handle this */);
} catch (cause) {
+6
View File
@@ -32,3 +32,9 @@ DOCS_URL=
# Axiom logging
AXIOM_DATASET=
AXIOM_TOKEN=
# DEBUG: 0
# INFO: 1
# WARN: 2
# ERROR: 3
LOGGER_BRIDGE_LOG_LEVEL="1"
+8
View File
@@ -107,6 +107,14 @@ $ yarn test:e2e:watch --testPathPattern=filePath
$ yarn run test:cov
```
## Conventions
### Guards
1. In case a guard would return "false" for "canActivate" instead throw ForbiddenException with an error message containing guard name and the error.
2. In case a guard would return "false" for "canActivate" DO NOT cache the result in redis, because we don't want that someone is forbidden, updates whatever was the problem, and then has to wait for cache to expire. We only cache in redis guard results where "canAccess" is "true".
3. If you use ApiAuthGuard but want that only specific auth method is allowed, for example, api key, then you also need to add `@ApiAuthGuardOnlyAllow(["API_KEY"])` under the `@UseGuards(ApiAuthGuard)`. Shortly, use `ApiAuthGuardOnlyAllow` to specify which auth methods are allowed by `ApiAuthGuard`. If `ApiAuthGuardOnlyAllow` is not used or nothing is passed to it or empty array it means that
all auth methods are allowed.
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
+1
View File
@@ -11,6 +11,7 @@
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 8
+2 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.164",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.185",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -46,6 +46,7 @@
"@golevelup/ts-jest": "^0.4.0",
"@microsoft/microsoft-graph-types-beta": "^0.42.0-preview",
"@nest-lab/throttler-storage-redis": "1.0.0",
"@nestjs/axios": "^4.0.0",
"@nestjs/bull": "^10.1.1",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^3.1.1",
@@ -17,7 +17,8 @@ import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repo
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
@@ -36,7 +37,6 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiQuery, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { CreationSource } from "@prisma/client";
import { Request } from "express";
import { NextApiRequest } from "next/types";
@@ -104,7 +104,8 @@ export class BookingsController_2024_04_15 {
private readonly config: ConfigService,
private readonly apiKeyRepository: ApiKeysRepository,
private readonly platformBookingsService: PlatformBookingsService,
private readonly usersRepository: UsersRepository
private readonly usersRepository: UsersRepository,
private readonly usersService: UsersService
) {}
@Get("/")
@@ -114,18 +115,19 @@ export class BookingsController_2024_04_15 {
@ApiQuery({ name: "limit", type: "number", required: false })
@ApiQuery({ name: "cursor", type: "number", required: false })
async getBookings(
@GetUser() user: User,
@GetUser() user: UserWithProfile,
@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 profile = this.usersService.getUserMainProfile(user);
const bookings = await getAllUserBookings({
bookingListingByStatus: [bookingListingByStatus],
skip: cursor ?? 0,
take: limit ?? 10,
filters,
ctx: {
user: { email: user.email, id: user.id },
user: { email: user.email, id: user.id, orgId: profile?.organizationId },
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
},
});
@@ -174,11 +176,19 @@ export class BookingsController_2024_04_15 {
const oAuthClientId =
clientId?.toString() || (await this.getOAuthClientIdFromEventType(body.eventTypeId));
const { orgSlug, locationUrl } = body;
req.headers["x-cal-force-slug"] = orgSlug;
try {
const booking = await handleNewBooking(
await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed)
);
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, locationUrl, isEmbed);
const booking = await handleNewBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
forcedSlug: orgSlug,
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
});
if (booking.userId && booking.uid && booking.startTime) {
void (await this.billingService.increaseUsageByUserId(booking.userId, {
uid: booking.uid,
@@ -284,9 +294,18 @@ export class BookingsController_2024_04_15 {
}
}
const createdBookings: BookingResponse[] = await handleNewRecurringBooking(
await this.createNextApiRecurringBookingRequest(req, oAuthClientId, undefined, isEmbed)
);
const bookingRequest = await this.createNextApiBookingRequest(req, oAuthClientId, undefined, isEmbed);
const createdBookings: BookingResponse[] = await handleNewRecurringBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
});
createdBookings.forEach(async (booking) => {
if (booking.userId && booking.uid && booking.startTime) {
@@ -1,6 +1,7 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
@@ -19,9 +20,14 @@ import { CredentialsRepository } from "@/modules/credentials/credentials.reposit
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersModule } from "@/modules/users/users.module";
@@ -38,6 +44,9 @@ import { Module } from "@nestjs/common";
SchedulesModule_2024_04_15,
EventTypesModule_2024_04_15,
EventTypesModule_2024_06_14,
StripeModule,
TeamsModule,
TeamsEventTypesModule,
],
providers: [
TokensRepository,
@@ -57,6 +66,9 @@ import { Module } from "@nestjs/common";
AppsRepository,
CalendarsRepository,
SelectedCalendarsRepository,
OrganizationsTeamsRepository,
OrganizationsRepository,
ErrorsBookingsService_2024_08_13,
],
controllers: [BookingsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
@Injectable()
export class BookingsRepository_2024_08_13 {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
@@ -12,6 +12,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 { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -34,7 +35,6 @@ import {
ApiBody,
ApiExtraModels,
} from "@nestjs/swagger";
import { User } from "@prisma/client";
import { Request } from "express";
import { BOOKING_READ, BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -80,7 +80,10 @@ import {
export class BookingsController_2024_08_13 {
private readonly logger = new Logger("BookingsController_2024_08_13");
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly usersService: UsersService
) {}
@Post("/")
@ApiOperation({
@@ -96,6 +99,14 @@ export class BookingsController_2024_08_13 {
For team event types it is possible to create instant meeting. To do that just pass \`"instant": true\` to the request body.
The start needs to be in UTC aka if the timezone is GMT+2 in Rome and meeting should start at 11, then UTC time should have hours 09:00 aka without time zone.
Finally, there are 2 ways to book an event type belonging to an individual user:
1. Provide \`eventTypeId\` in the request body.
2. Provide \`eventTypeSlug\` and \`username\` and optionally \`organizationSlug\` if the user with the username is within an organization.
And 2 ways to book and event type belonging to a team:
1. Provide \`eventTypeId\` in the request body.
2. Provide \`eventTypeSlug\` and \`teamSlug\` and optionally \`organizationSlug\` if the team with the teamSlug is within an organization.
`,
})
@ApiBody({
@@ -161,9 +172,15 @@ export class BookingsController_2024_08_13 {
@ApiOperation({ summary: "Get all bookings" })
async getBookings(
@Query() queryParams: GetBookingsInput_2024_08_13,
@GetUser() user: User
@GetUser() user: UserWithProfile
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.bookingsService.getBookings(queryParams, user);
const profile = this.usersService.getUserMainProfile(user);
const bookings = await this.bookingsService.getBookings(queryParams, {
email: user.email,
id: user.id,
orgId: profile?.organizationId,
});
return {
status: SUCCESS_STATUS,
@@ -243,7 +260,8 @@ export class BookingsController_2024_08_13 {
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Mark a booking absence - provided authorization header refers to owner of the booking.",
summary: "Mark a booking absence",
description: "The provided authorization header refers to the owner of the booking.",
})
async markNoShow(
@Param("bookingUid") bookingUid: string,
@@ -264,8 +282,8 @@ export class BookingsController_2024_08_13 {
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary:
"Automatically reassign booking to a new host automatically - provided authorization header refers to person who reassigned the booking.",
summary: "Reassign a booking to auto-selected host",
description: "The provided authorization header refers to the owner of the booking.",
})
async reassignBooking(
@Param("bookingUid") bookingUid: string,
@@ -285,8 +303,8 @@ export class BookingsController_2024_08_13 {
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary:
"Reassign a booking to a specific user specified by the :userId - provided authorization header refers to person who reassigned the booking.",
summary: "Reassign a booking to a specific host",
description: "The provided authorization header refers to the owner of the booking.",
})
async reassignBookingToUser(
@Param("bookingUid") bookingUid: string,
@@ -313,8 +331,8 @@ export class BookingsController_2024_08_13 {
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary:
"Confirm booking that requires a confirmation - provided authorization header refers to owner of the booking.",
summary: "Confirm a booking",
description: "The provided authorization header refers to the owner of the booking.",
})
async confirmBooking(
@Param("bookingUid") bookingUid: string,
@@ -334,8 +352,8 @@ export class BookingsController_2024_08_13 {
@UseGuards(ApiAuthGuard, BookingUidGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary:
"Decline booking that requires a confirmation - provided authorization header refers to owner of the booking.",
summary: "Decline a booking",
description: "The provided authorization header refers to the owner of the booking.",
})
async declineBooking(
@Param("bookingUid") bookingUid: string,
@@ -122,7 +122,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -150,11 +149,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(response.body.status).toEqual(SUCCESS_STATUS);
expect(response.body.data).toBeDefined();
const googleCalendarLink = response.body.data.find((item: { id: string }) => item.id === 'googleCalendar').link;
const microsoftOfficeLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOffice').link;
const microsoftOutlookLink = response.body.data.find((item: { id: string }) => item.id === 'microsoftOutlook').link;
const icsLink = response.body.data.find((item: { id: string }) => item.id === 'ics').link;
const googleCalendarLink = response.body.data.find(
(item: { id: string }) => item.id === "googleCalendar"
).link;
const microsoftOfficeLink = response.body.data.find(
(item: { id: string }) => item.id === "microsoftOffice"
).link;
const microsoftOutlookLink = response.body.data.find(
(item: { id: string }) => item.id === "microsoftOutlook"
).link;
const icsLink = response.body.data.find((item: { id: string }) => item.id === "ics").link;
expect(googleCalendarLink).toMatch(/^https:\/\/calendar\.google\.com\//);
expect(microsoftOfficeLink).toMatch(/^https:\/\/outlook\.office\.com\//);
@@ -385,7 +385,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -430,7 +430,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -459,7 +459,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.rescheduledBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -502,7 +502,7 @@ describe("Bookings Endpoints 2024-08-13 confirm emails", () => {
emailsEnabledSetup.createdBookingUid = responseBody.data.uid;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -301,8 +301,8 @@ describe("Managed user bookings 2024-08-13", () => {
attendee: {
name: thirdManagedUser.user.name!,
email: thirdManagedUserEmail,
timeZone: secondManagedUser.user.timeZone,
language: secondManagedUser.user.locale,
timeZone: thirdManagedUser.user.timeZone,
language: thirdManagedUser.user.locale,
},
guests: [secondManagedUserEmail],
location: "https://meet.google.com/abc-def-ghi",
@@ -418,8 +418,8 @@ describe("Managed user bookings 2024-08-13", () => {
attendee: {
name: thirdManagedUser.user.name!,
email: thirdManagedUser.user.email,
timeZone: secondManagedUser.user.timeZone,
language: secondManagedUser.user.locale,
timeZone: thirdManagedUser.user.timeZone,
language: thirdManagedUser.user.locale,
},
guests: [secondManagedUser.user.email],
location: "https://meet.google.com/abc-def-ghi",
@@ -477,6 +477,72 @@ describe("Managed user bookings 2024-08-13", () => {
});
});
describe("fetch bookings by attendeeEmail", () => {
it("should return bookings for the original email when original email is attendee only", async () => {
const response = await request(app.getHttpServer())
.get(`/v2/bookings?attendeeEmail=${thirdManagedUserEmail}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
.expect(200);
const thirdManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const thirdManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
thirdManagedUserAttendeeBookingsResponseBody.data;
expect(thirdManagedUserBookingsCount).toEqual(2);
expect(thirdManagedUserAttendeeBookings.length).toEqual(thirdManagedUserBookingsCount);
});
it("should return bookings for the oAuth email when original oAuth email is attendee only", async () => {
const response = await request(app.getHttpServer())
.get(`/v2/bookings?attendeeEmail=${thirdManagedUser.user.email}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
.expect(200);
const thirdManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const thirdManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
thirdManagedUserAttendeeBookingsResponseBody.data;
expect(thirdManagedUserBookingsCount).toEqual(2);
expect(thirdManagedUserAttendeeBookings.length).toEqual(thirdManagedUserBookingsCount);
});
it("should return bookings for the original email when original email is attendee or guest", async () => {
const response = await request(app.getHttpServer())
.get(`/v2/bookings?attendeeEmail=${secondManagedUserEmail}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
.expect(200);
const secondManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const secondManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
secondManagedUserAttendeeBookingsResponseBody.data;
expect(secondManagedUserBookingsCount).toEqual(4);
expect(secondManagedUserAttendeeBookings.length).toEqual(secondManagedUserBookingsCount);
});
it("should return bookings for the oAuth email when original oAuth email is attendee or guest", async () => {
const response = await request(app.getHttpServer())
.get(`/v2/bookings?attendeeEmail=${secondManagedUser.user.email}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
.expect(200);
const secondManagedUserAttendeeBookingsResponseBody: GetBookingsOutput_2024_08_13 = response.body;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const secondManagedUserAttendeeBookings: BookingOutput_2024_08_13[] =
secondManagedUserAttendeeBookingsResponseBody.data;
expect(secondManagedUserBookingsCount).toEqual(4);
expect(secondManagedUserAttendeeBookings.length).toEqual(secondManagedUserBookingsCount);
});
});
afterAll(async () => {
await userRepositoryFixture.delete(firstManagedUser.user.id);
await userRepositoryFixture.delete(secondManagedUser.user.id);
@@ -58,14 +58,11 @@ describe("Bookings Endpoints 2024-08-13", () => {
let user: User;
let seatedEventTypeId: number;
let recurringSeatedEventTypeId: number;
const maxRecurrenceCount = 3;
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
const nameAttendeeOne = `Attendee One ${randomString()}`;
@@ -126,22 +123,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
);
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: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
slug: recurringSeatedEventSlug,
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);
@@ -321,127 +302,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should book a recurring event type with seats", async () => {
const recurrenceCount = 2;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 11, 0, 0)).toISOString(),
eventTypeId: recurringSeatedEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: googleMeetUrl,
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
recurrenceCount,
metadata: {
userId: "300",
},
};
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: recurringSeatedEventSlug,
});
expect(firstBooking.attendees.length).toEqual(1);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
location: {
optionValue: googleMeetUrl,
value: "link",
},
},
metadata: body.metadata,
});
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: recurringSeatedEventSlug,
});
expect(secondBooking.attendees.length).toEqual(1);
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
location: {
optionValue: googleMeetUrl,
value: "link",
},
},
metadata: body.metadata,
});
expect(secondBooking.location).toEqual(body.location);
expect(secondBooking.absentHost).toEqual(false);
createdRecurringSeatedBooking = data;
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should get a booking for an event type with seats", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${createdSeatedBooking.uid}`)
@@ -469,62 +329,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should get a booking for a recurring event type with seats", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${createdRecurringSeatedBooking[1].recurringBookingUid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetRecurringSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetRecurringSeatedBooking(responseBody.data)) {
const data: GetRecurringSeatedBookingOutput_2024_08_13[] = responseBody.data;
const expected = structuredClone(createdRecurringSeatedBooking);
for (const booking of expected) {
// note(Lauris): seatUid in get response resides only in each attendee object
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete booking.seatUid;
}
expect(data).toEqual(expected);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should get a specific recurrence of a booking for a recurring event type with seats", async () => {
return request(app.getHttpServer())
.get(`/v2/bookings/${createdRecurringSeatedBooking[0].uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsGetSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsGetSeatedBooking(responseBody.data)) {
const data: GetSeatedBookingOutput_2024_08_13 = responseBody.data;
const expected = structuredClone(createdRecurringSeatedBooking[0]);
// note(Lauris): seatUid in get response resides only in each attendee object
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete expected.seatUid;
expect(data).toEqual(expected);
} else {
throw new Error(
"Invalid response data - expected recurring booking but received non array response"
);
}
});
});
it("should get all seated bookings", async () => {
return request(app.getHttpServer())
.get("/v2/bookings?sortCreated=asc")
@@ -534,7 +338,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.length).toEqual(3);
expect(responseBody.data.length).toEqual(1);
const seatedBooking = responseBody.data[0];
const seatedBookingExpected = structuredClone(createdSeatedBooking);
@@ -543,16 +347,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
// @ts-ignore
delete seatedBookingExpected.seatUid;
expect(seatedBooking).toEqual(seatedBookingExpected);
const recurringSeatedBookings = [responseBody.data[1], responseBody.data[2]];
const recurringSeatedBookingsExpected = structuredClone(createdRecurringSeatedBooking);
for (const booking of recurringSeatedBookingsExpected) {
// note(Lauris): seatUid in get response resides only in each attendee object
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete booking.seatUid;
}
expect(recurringSeatedBookings).toEqual(recurringSeatedBookingsExpected);
});
});
@@ -682,424 +476,4 @@ 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-user-${randomString()}@api.com`;
let user: User;
let seatedEventTypeId: number;
let recurringSeatedEventTypeId: number;
const maxRecurrenceCount = 3;
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
const recurringSeatedEventSlug = `seated-bookings-event-type-${randomString()}`;
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
let createdRecurringSeatedBooking: CreateRecurringSeatedBookingOutput_2024_08_13[];
const emailAttendeeOne = `seated-bookings-attendee1-${randomString()}@api.com`;
const nameAttendeeOne = `Attendee One ${randomString()}`;
const emailAttendeeTwo = `seated-bookings-attendee2-${randomString()}@api.com`;
const nameAttendeeTwo = `Attendee Two ${randomString()}`;
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: `seated-bookings-organization-${randomString()}`,
});
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: `seated-bookings-2024-08-13-schedule-${randomString()}`,
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(user.id, userSchedule);
const seatedEvent = await eventTypesRepositoryFixture.create(
{
title: `seated-bookings-2024-08-13-event-type-${randomString()}`,
slug: seatedEventSlug,
length: 60,
seatsPerTimeSlot: 3,
},
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: `seated-bookings-2024-08-13-recurring-event-type-${randomString()}`,
slug: recurringSeatedEventSlug,
length: 60,
seatsPerTimeSlot: 3,
recurringEvent: { freq: 2, count: maxRecurrenceCount, interval: 1 },
},
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: googleMeetUrl,
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "1000",
},
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: recurringSeatedEventSlug,
});
expect(firstBooking.attendees.length).toEqual(1);
expect(firstBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
location: {
optionValue: googleMeetUrl,
value: "link",
},
},
metadata: body.metadata,
});
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: recurringSeatedEventSlug,
});
expect(secondBooking.attendees.length).toEqual(1);
expect(secondBooking.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: body.attendee.name,
...body.bookingFieldsResponses,
location: {
optionValue: googleMeetUrl,
value: "link",
},
},
metadata: body.metadata,
});
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: googleMeetUrl,
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "2000",
},
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: recurringSeatedEventSlug,
});
expect(firstBooking.attendees.length).toEqual(2);
const firstAttendee = firstBooking.attendees.find(
(attendee) => attendee.name === nameAttendeeOne
);
expect(firstAttendee).toEqual({
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: createdRecurringSeatedBooking[0].seatUid,
bookingFieldsResponses: {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
location: {
optionValue: googleMeetUrl,
value: "link",
},
},
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
});
const secondAttendee = firstBooking.attendees.find(
(attendee) => attendee.name === nameAttendeeTwo
);
expect(secondAttendee).toEqual({
name: nameAttendeeTwo,
email: emailAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid,
bookingFieldsResponses: {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
location: {
optionValue: googleMeetUrl,
value: "link",
},
},
metadata: body.metadata,
});
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: recurringSeatedEventSlug,
});
expect(secondBooking.attendees.length).toEqual(2);
expect(secondBooking.attendees[0]).toEqual({
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: createdRecurringSeatedBooking[1].seatUid,
bookingFieldsResponses: {
name: nameAttendeeOne,
...body.bookingFieldsResponses,
location: {
optionValue: googleMeetUrl,
value: "link",
},
},
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
});
expect(secondBooking.attendees[1]).toEqual({
name: nameAttendeeTwo,
email: emailAttendeeTwo,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
seatUid: secondSeatUid,
bookingFieldsResponses: {
name: nameAttendeeTwo,
...body.bookingFieldsResponses,
location: {
optionValue: googleMeetUrl,
value: "link",
},
},
metadata: body.metadata,
});
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();
});
});
});
@@ -39,7 +39,10 @@ import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
describe("Team bookings", () => {
let app: INestApplication;
const organizationSlug = `team-bookings-organization-${randomString()}`;
let organization: Team;
const team1Slug = `team-bookings-team1-${randomString()}`;
const team2Slug = `team-bookings-team2-${randomString()}`;
let team1: Team;
let team2: Team;
@@ -95,12 +98,15 @@ describe("Bookings Endpoints 2024-08-13", () => {
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({
name: `team-bookings-organization-${randomString()}`,
name: organizationSlug,
slug: organizationSlug,
isOrganization: true,
});
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: `team-bookings-team1-${randomString()}`,
name: team1Slug,
slug: team1Slug,
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
@@ -111,7 +117,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
team2 = await teamRepositoryFixture.create({
name: `team-bookings-team2-${randomString()}`,
name: team2Slug,
slug: team2Slug,
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
@@ -591,18 +598,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.get(`/v2/bookings?teamId=${team2.id}&eventTypeId=90909`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
const data: (
| BookingOutput_2024_08_13
| RecurringBookingOutput_2024_08_13
| GetSeatedBookingOutput_2024_08_13
)[] = responseBody.data;
expect(data.length).toEqual(0);
});
.expect(400);
});
it("should get bookings by teamIds", async () => {
@@ -679,6 +675,84 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("book using teamSlug, eventTypeSlug and organizationSlug", () => {
it("should not be able to book if missing organizationSlug", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
teamSlug: team1Slug,
eventTypeSlug: team1EventTypeSlug,
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")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(404);
});
it("should book using teamSlug and eventTypeSlug and organizationSlug", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2040, 0, 9, 13, 0, 0)).toISOString(),
teamSlug: team1Slug,
eventTypeSlug: team1EventTypeSlug,
organizationSlug: organizationSlug,
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")
.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.hosts.length).toEqual(1);
expect(data.hosts[0].id).toEqual(teamUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2040, 0, 9, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(team1EventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.meetingUrl).toEqual(body.meetingUrl);
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
@@ -14,7 +14,7 @@ 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 { advanceTo, clear } from "jest-date-mock";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
@@ -34,6 +34,7 @@ import {
} from "@calcom/platform-constants";
import {
CreateEventTypeInput_2024_06_14,
FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
@@ -66,6 +67,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
let user: User;
let eventTypeId: number;
let eventType: EventType;
const eventTypeSlug = `user-bookings-event-type-${randomString()}`;
let recurringEventTypeId: number;
const recurringEventTypeSlug = `user-bookings-event-type-${randomString()}`;
@@ -128,6 +130,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
user.id
);
eventTypeId = event.id;
eventType = event;
const recurringEvent = await eventTypesRepositoryFixture.create(
// note(Lauris): freq 2 means weekly, interval 1 means every week and count 3 means 3 weeks in a row
@@ -580,7 +583,66 @@ describe("Bookings Endpoints 2024-08-13", () => {
expect(data.rating).toEqual(bookingInThePast.rating);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibily recurring bookings"
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should get a booking with icsUid", async () => {
const mockBooking = await bookingsRepositoryFixture.create({
user: {
connect: {
id: user.id,
},
},
startTime: new Date(Date.UTC(2020, 0, 8, 13, 0, 0)),
endTime: new Date(Date.UTC(2020, 0, 8, 14, 0, 0)),
title: "peer coding lets goo",
uid: `booking-in-the-past-${randomString()}`,
eventType: {
connect: {
id: eventTypeId,
},
},
location: "integrations:daily",
customInputs: {},
metadata: {},
responses: {
name: "Oldie",
email: "oldie@gmail.com",
},
attendees: {
create: {
email: "oldie@gmail.com",
name: "Oldie",
locale: "lv",
timeZone: "Europe/Rome",
},
},
rating: 10,
iCalUID: "ics-uid",
});
return request(app.getHttpServer())
.get(`/v2/bookings/${mockBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.id).toEqual(mockBooking.id);
expect(data.uid).toEqual(mockBooking.uid);
expect(data.icsUid).toEqual(mockBooking.iCalUID);
await bookingsRepositoryFixture.deleteById(mockBooking.id);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
@@ -1604,6 +1666,133 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("book by username and event type slug", () => {
it("should not create a booking by if neither event type id nor username and event type slug provided", async () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
const body = {
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: googleMeetUrl,
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
guests: ["bob@gmail.com"],
};
const response = await request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
expect(response.body.error.message.includes(FAILED_EVENT_TYPE_IDENTIFICATION_ERROR_MESSAGE)).toBe(
true
);
});
it("should create a booking by username and event type slug", async () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2036, 0, 8, 15, 0, 0)).toISOString(),
eventTypeSlug: eventType.slug,
username: user.username || "",
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: googleMeetUrl,
bookingFieldsResponses: {
customField: "customValue",
},
metadata: {
userId: "100",
},
guests: ["bob@gmail.com"],
};
const beforeCreate = new Date();
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const afterCreate = new Date();
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.hosts[0].id).toEqual(user.id);
expect(data.hosts[0].username).toEqual(user.username);
expect(data.hosts[0].email).toEqual(user.email);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2036, 0, 8, 16, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(eventTypeId);
expect(data.eventType).toEqual({
id: eventTypeId,
slug: eventTypeSlug,
});
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: body.attendee.email,
timeZone: body.attendee.timeZone,
language: body.attendee.language,
absent: false,
});
expect(data.location).toEqual(body.location);
expect(data.meetingUrl).toEqual(body.location);
expect(data.absentHost).toEqual(false);
expect(data.bookingFieldsResponses).toEqual({
name: body.attendee.name,
email: body.attendee.email,
...body.bookingFieldsResponses,
guests: body.guests,
location: {
optionValue: googleMeetUrl,
value: "link",
},
});
expect(data.guests).toEqual(body.guests);
// Check createdAt date is between the time of the request and after the request
const createdAtDate = new Date(data.createdAt);
expect(createdAtDate.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(createdAtDate.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
// Check updatedAt date is between the time of the request and after the request
expect(data.updatedAt).toBeDefined();
const updatedAtDate = data.updatedAt ? new Date(data.updatedAt) : null;
expect(updatedAtDate?.getTime()).toBeGreaterThanOrEqual(beforeCreate.getTime());
expect(updatedAtDate?.getTime()).toBeLessThanOrEqual(afterCreate.getTime());
expect(data.metadata).toEqual(body.metadata);
createdBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
describe("booking location", () => {
const address = "123 Main St";
const link = "https://cal.com/join/123456";
@@ -8,7 +8,7 @@ export class BookingUidGuard implements CanActivate {
const bookingUid = request.params.bookingUid;
if (!bookingUid) {
throw new BadRequestException("Booking UID missing in the request path");
throw new BadRequestException("BookingUidGuard - Booking UID missing in the request path");
}
return true;
@@ -1,15 +1,22 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.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 { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-event-types.repository";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
import { z } from "zod";
@@ -43,7 +50,7 @@ import {
CancelBookingInput,
} from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
import { EventType } from "@calcom/prisma/client";
import { EventType, User, Team } from "@calcom/prisma/client";
type CreatedBooking = {
hosts: { id: number }[];
@@ -59,6 +66,8 @@ const eventTypeBookingFieldSchema = z.object({
const eventTypeBookingFieldsSchema = z.array(eventTypeBookingFieldSchema);
export type EventTypeWithOwnerAndTeam = EventType & { owner: User | null; team: Team | null };
@Injectable()
export class BookingsService_2024_08_13 {
private readonly logger = new Logger("BookingsService");
@@ -72,45 +81,96 @@ export class BookingsService_2024_08_13 {
private readonly billingService: BillingService,
private readonly usersService: UsersService,
private readonly usersRepository: UsersRepository,
private readonly platformBookingsService: PlatformBookingsService
private readonly platformBookingsService: PlatformBookingsService,
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly organizationsTeamsRepository: OrganizationsTeamsRepository,
private readonly organizationsRepository: OrganizationsRepository,
private readonly teamsRepository: TeamsRepository,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
) {}
async createBooking(request: Request, body: CreateBookingInput) {
let bookingTeamEventType = false;
try {
if ("instant" in body && body.instant) {
return await this.createInstantBooking(request, body);
const eventType = await this.getBookedEventType(body);
if (eventType?.team) {
bookingTeamEventType = true;
}
if (!eventType) {
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
}
body.eventTypeId = eventType.id;
if ("instant" in body && body.instant) {
return await this.createInstantBooking(request, body, eventType);
}
const eventType = await this.eventTypesRepository.getEventTypeById(body.eventTypeId);
const isRecurring = !!eventType?.recurringEvent;
const isSeated = !!eventType?.seatsPerTimeSlot;
await this.hasRequiredBookingFieldsResponses(body, eventType);
if (isRecurring && isSeated) {
return await this.createRecurringSeatedBooking(request, body);
return await this.createRecurringSeatedBooking(request, body, eventType);
}
if (isRecurring && !isSeated) {
return await this.createRecurringBooking(request, body);
return await this.createRecurringBooking(request, body, eventType);
}
if (isSeated) {
return await this.createSeatedBooking(request, body);
return await this.createSeatedBooking(request, body, eventType);
}
return await this.createRegularBooking(request, body);
return await this.createRegularBooking(request, body, eventType);
} catch (error) {
if (error instanceof Error) {
if (error.message === "no_available_users_found_error") {
throw new BadRequestException("User either already has booking at this time or is not available");
}
}
throw error;
this.errorsBookingsService.handleBookingError(error, bookingTeamEventType);
}
}
async getBookedEventType(body: CreateBookingInput) {
if (body.eventTypeId) {
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
} else if (body.username && body.eventTypeSlug) {
const user = await this.usersRepository.findByUsername(body.username, body.organizationSlug);
if (!user) {
throw new NotFoundException(`User with username ${body.username} not found`);
}
return await this.eventTypesRepository.getUserEventTypeBySlugWithOwnerAndTeam(
user.id,
body.eventTypeSlug
);
} else if (body.teamSlug && body.eventTypeSlug) {
const team = await this.getBookedEventTypeTeam(body.teamSlug, body.organizationSlug);
if (!team) {
throw new NotFoundException(`Team with slug ${body.teamSlug} not found`);
}
return await this.teamsEventTypesRepository.getEventTypeByTeamIdAndSlugWithOwnerAndTeam(
team.id,
body.eventTypeSlug
);
}
return null;
}
async getBookedEventTypeTeam(teamSlug: string, organizationSlug: string | undefined) {
if (!organizationSlug) {
return await this.teamsRepository.findTeamBySlug(teamSlug);
}
const organization = await this.organizationsRepository.findOrgBySlug(organizationSlug);
if (!organization) {
throw new NotFoundException(
`slots-input.service.ts: Organization with slug ${organizationSlug} not found`
);
}
return await this.organizationsTeamsRepository.findOrgTeamBySlug(organization.id, teamSlug);
}
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
const bookingFields = body.bookingFieldsResponses;
if (!bookingFields || !eventType || !eventType.bookingFields) {
const bookingFields = { ...body.bookingFieldsResponses, attendeePhoneNumber: body.attendee.phoneNumber };
if (!eventType?.bookingFields) {
return true;
}
@@ -119,19 +179,38 @@ export class BookingsService_2024_08_13 {
.parse(eventType.bookingFields)
.filter((field) => !field.editable.startsWith("system"));
if (!eventTypeBookingFields.length) {
return true;
}
for (const field of eventTypeBookingFields) {
if (field.required && !(field.name in bookingFields)) {
throw new BadRequestException(`
Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses.
You can fetch the event type with ID ${eventType.id} to see the required fields.`);
if (field.name === "attendeePhoneNumber") {
throw new BadRequestException(
`Missing attendee phone number - it is required by the event type. Pass it as "attendee.phoneNumber" in the request.`
);
}
throw new BadRequestException(
`Missing required booking field response: ${field.name} - it is required by the event type booking fields, but missing in the bookingFieldsResponses. You can fetch the event type with ID ${eventType.id} to see the required fields.`
);
}
}
return true;
}
async createInstantBooking(request: Request, body: CreateInstantBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
async createInstantBooking(
request: Request,
body: CreateInstantBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
) {
if (!eventType.team?.id) {
throw new BadRequestException(
"Instant bookings are only supported for team event types, not individual user event types."
);
}
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleInstantMeeting(bookingRequest);
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
@@ -144,24 +223,64 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputBooking(databaseBooking);
}
async createRecurringBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
const bookings = await handleNewRecurringBooking(bookingRequest);
async createRecurringBooking(
request: Request,
body: CreateRecurringBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
const bookings = await handleNewRecurringBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
noEmail: bookingRequest.noEmail,
});
const ids = bookings.map((booking) => booking.id || 0);
return this.outputService.getOutputRecurringBookings(ids);
}
async createRecurringSeatedBooking(request: Request, body: CreateRecurringBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body);
const bookings = await handleNewRecurringBooking(bookingRequest);
async createRecurringSeatedBooking(
request: Request,
body: CreateRecurringBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createRecurringBookingRequest(request, body, eventType);
const bookings = await handleNewRecurringBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
});
return this.outputService.getOutputCreateRecurringSeatedBookings(
bookings.map((booking) => ({ uid: booking.uid || "", seatUid: booking.seatReferenceUid || "" }))
);
}
async createRegularBooking(request: Request, body: CreateBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
const booking = await handleNewBooking(bookingRequest);
async createRegularBooking(
request: Request,
body: CreateBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleNewBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
});
if (!booking.uid) {
throw new Error("Booking missing uid");
@@ -175,22 +294,43 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputBooking(databaseBooking);
}
async createSeatedBooking(request: Request, body: CreateBookingInput_2024_08_13) {
const bookingRequest = await this.inputService.createBookingRequest(request, body);
const booking = await handleNewBooking(bookingRequest);
async createSeatedBooking(
request: Request,
body: CreateBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
try {
const booking = await handleNewBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
});
if (!booking.uid) {
throw new Error("Booking missing uid");
if (!booking.uid) {
throw new Error("Booking missing uid");
}
const databaseBooking =
await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(booking.uid);
if (!databaseBooking) {
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
} catch (error) {
if (error instanceof Error) {
if (error.message === "booking_seats_full_error") {
throw new ConflictException("No more seats left at this seated booking.");
}
}
throw error;
}
const databaseBooking = await this.bookingsRepository.getByUidWithAttendeesWithBookingSeatAndUserAndEvent(
booking.uid
);
if (!databaseBooking) {
throw new Error(`Booking with uid=${booking.uid} was not found in the database`);
}
return this.outputService.getOutputCreateSeatedBooking(databaseBooking, booking.seatReferenceUid || "");
}
async getBooking(uid: string) {
@@ -225,12 +365,23 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputRecurringBookings(ids);
}
async getBookings(queryParams: GetBookingsInput_2024_08_13, user: { email: string; id: number }) {
async getBookings(
queryParams: GetBookingsInput_2024_08_13,
user: { email: string; id: number; orgId?: number },
userIds?: number[]
) {
if (queryParams.attendeeEmail) {
queryParams.attendeeEmail = await this.getAttendeeEmail(queryParams.attendeeEmail, user);
}
const fetchedBookings: { bookings: { id: number }[] } = await getAllUserBookings({
bookingListingByStatus: queryParams.status || [],
skip: queryParams.skip ?? 0,
take: queryParams.take ?? 100,
filters: this.inputService.transformGetBookingsFilters(queryParams),
filters: {
...this.inputService.transformGetBookingsFilters(queryParams),
...(userIds?.length ? { userIds } : {}),
},
ctx: {
user,
prisma: this.prismaReadService.prisma as unknown as PrismaClient,
@@ -281,6 +432,35 @@ export class BookingsService_2024_08_13 {
return formattedBookings;
}
async getAttendeeEmail(queryParamsAttendeeEmail: string, user: { id: number }) {
// note(Lauris): this is to handle attendees that are managed users - in attendee table their email is one of managed users e.g
// urdasdqinm+clxyyy21o0003sbk7yw5z6tzg@example.com but if attendeeEmail is passed as urdasdqinm@example.com then we check if user whose
// access token is used is a managed user and if attendee with passed email has managed user email composed of passed email without oAuth client id +
// authenticated user oAuth client id.
const oAuthClient = await this.oAuthClientRepository.getByUserId(user.id);
if (!oAuthClient) {
return queryParamsAttendeeEmail;
}
// note(Lauris): query param already contains oAuth client id in the attendeeEmail
if (queryParamsAttendeeEmail.includes(oAuthClient.id)) {
return queryParamsAttendeeEmail;
}
const managedAttendeeEmail = OAuthClientUsersService.getOAuthUserEmail(
oAuthClient.id,
queryParamsAttendeeEmail
);
const [attendee, managedAttendee] = await Promise.all([
this.usersRepository.findByEmail(queryParamsAttendeeEmail),
this.usersRepository.findByEmail(managedAttendeeEmail),
]);
if (!attendee && managedAttendee) {
return managedAttendeeEmail;
}
return queryParamsAttendeeEmail;
}
async rescheduleBooking(request: Request, bookingUid: string, body: RescheduleBookingInput) {
try {
const bookingRequest = await this.inputService.createRescheduleBookingRequest(
@@ -288,7 +468,16 @@ export class BookingsService_2024_08_13 {
bookingUid,
body
);
const booking = await handleNewBooking(bookingRequest);
const booking = await handleNewBooking({
bookingData: bookingRequest.body,
userId: bookingRequest.userId,
hostname: bookingRequest.headers?.host || "",
platformClientId: bookingRequest.platformClientId,
platformRescheduleUrl: bookingRequest.platformRescheduleUrl,
platformCancelUrl: bookingRequest.platformCancelUrl,
platformBookingUrl: bookingRequest.platformBookingUrl,
platformBookingLocation: bookingRequest.platformBookingLocation,
});
if (!booking.uid) {
throw new Error("Booking missing uid");
}
@@ -452,13 +641,24 @@ export class BookingsService_2024_08_13 {
const profile = this.usersService.getUserMainProfile(requestUser);
await roundRobinReassignment({
bookingId: booking.id,
orgId: profile?.organizationId || null,
emailsEnabled,
platformClientParams,
reassignedById: requestUser.id,
});
try {
await roundRobinReassignment({
bookingId: booking.id,
orgId: profile?.organizationId || null,
emailsEnabled,
platformClientParams,
reassignedById: requestUser.id,
});
} catch (error) {
if (error instanceof Error) {
if (error.message === "no_available_users_found_error") {
throw new BadRequestException(
"Can't reassign the booking because no other host is available at that time."
);
}
}
throw error;
}
const reassigned = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!reassigned) {
@@ -492,17 +692,28 @@ export class BookingsService_2024_08_13 {
const profile = this.usersService.getUserMainProfile(user);
const reassigned = await roundRobinManualReassignment({
bookingId: booking.id,
newUserId,
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
emailsEnabled,
platformClientParams,
});
try {
const reassigned = await roundRobinManualReassignment({
bookingId: booking.id,
newUserId,
orgId: profile?.organizationId || null,
reassignReason: body.reason,
reassignedById,
emailsEnabled,
platformClientParams,
});
return this.outputService.getOutputReassignedBooking(reassigned);
return this.outputService.getOutputReassignedBooking(reassigned);
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_round_robin_host") {
throw new BadRequestException(
`User with id=${newUserId} is not a valid Round Robin host - the user to which you reassign this booking must be one of the booking hosts. Fetch the booking using following endpoint and select id of one of the hosts: https://cal.com/docs/api-reference/v2/bookings/get-a-booking`
);
}
}
throw error;
}
}
async confirmBooking(bookingUid: string, requestUser: UserWithProfile) {
@@ -0,0 +1,55 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { Logger } from "@nestjs/common";
import { CreateBookingInput } from "@calcom/platform-types";
@Injectable()
export class ErrorsBookingsService_2024_08_13 {
private readonly logger = new Logger("ErrorsBookingsService_2024_08_13");
handleEventTypeToBeBookedNotFound(body: CreateBookingInput): never {
if (body.username && body.eventTypeSlug && !body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} not found.`
);
}
if (body.username && body.eventTypeSlug && body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to user ${body.username} within organization ${body.organizationSlug} not found.`
);
}
if (body.teamSlug && body.eventTypeSlug && !body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} not found.`
);
}
if (body.teamSlug && body.eventTypeSlug && body.organizationSlug) {
throw new NotFoundException(
`Event type with slug ${body.eventTypeSlug} belonging to team ${body.teamSlug} within organization ${body.organizationSlug} not found.`
);
}
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
}
handleBookingError(error: unknown, bookingTeamEventType: boolean): never {
const hostsUnavaile = "One of the hosts either already has booking at this time or is not available";
if (error instanceof Error) {
if (error.message === "no_available_users_found_error") {
if (bookingTeamEventType) {
throw new BadRequestException(hostsUnavaile);
}
throw new BadRequestException("User either already has booking at this time or is not available");
} else if (error.message === "booking_time_out_of_bounds_error") {
throw new BadRequestException(
`The event type can't be booked at the "start" time provided. This could be because it's too soon (violating the minimum booking notice) or too far in the future (outside the event's scheduling window). Try fetching available slots first using the GET /v2/slots endpoint and then make a booking with "start" time equal to one of the available slots: https://cal.com/docs/api-reference/v2/slots`
);
} else if (error.message === "Attempting to book a meeting in the past.") {
throw new BadRequestException("Attempting to book a meeting in the past.");
} else if (error.message === "hosts_unavailable_for_booking") {
throw new BadRequestException(hostsUnavaile);
}
}
throw error;
}
}
@@ -1,4 +1,5 @@
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { EventTypeWithOwnerAndTeam } from "@/ee/bookings/2024-08-13/services/bookings.service";
import {
bookingResponsesSchema,
seatedBookingDataSchema,
@@ -45,7 +46,10 @@ import {
import { BookingInputLocation_2024_08_13 } from "@calcom/platform-types/bookings/2024-08-13/inputs/location.input";
import { EventType } from "@calcom/prisma/client";
type BookingRequest = NextApiRequest & { userId: number | undefined } & OAuthRequestParams;
type BookingRequest = NextApiRequest & {
userId: number | undefined;
noEmail: boolean | undefined;
} & OAuthRequestParams;
type OAuthRequestParams = {
platformClientId: string;
@@ -92,10 +96,15 @@ export class InputBookingsService_2024_08_13 {
async createBookingRequest(
request: Request,
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13
body: CreateBookingInput_2024_08_13 | CreateInstantBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
): Promise<BookingRequest> {
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId);
const bodyTransformed = await this.transformInputCreateBooking(body, oAuthClientParams?.platformClientId);
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType);
const bodyTransformed = await this.transformInputCreateBooking(
body,
eventType,
oAuthClientParams?.platformClientId
);
const newRequest = { ...request };
const userId = (await this.createBookingRequestOwnerId(request)) ?? undefined;
@@ -121,15 +130,11 @@ export class InputBookingsService_2024_08_13 {
return newRequest as unknown as BookingRequest;
}
async transformInputCreateBooking(inputBooking: CreateBookingInput_2024_08_13, platformClientId?: string) {
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
inputBooking.eventTypeId
);
if (!eventType) {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
async transformInputCreateBooking(
inputBooking: CreateBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam,
platformClientId?: string
) {
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
@@ -196,12 +201,14 @@ export class InputBookingsService_2024_08_13 {
async createRecurringBookingRequest(
request: Request,
body: CreateRecurringBookingInput_2024_08_13
body: CreateRecurringBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam
): Promise<BookingRequest> {
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParams(body.eventTypeId);
const oAuthClientParams = await this.platformBookingsService.getOAuthClientParamsForEventType(eventType);
// note(Lauris): update to this.transformInputCreate when rescheduling is implemented
const bodyTransformed = await this.transformInputCreateRecurringBooking(
body,
eventType,
oAuthClientParams?.platformClientId
);
@@ -223,7 +230,13 @@ export class InputBookingsService_2024_08_13 {
creationSource: CreationSource.API_V2,
}));
return newRequest as unknown as BookingRequest;
return {
...newRequest,
headers: {
hostname: request.headers["host"] || "",
forcedSlug: request.headers["x-cal-force-slug"] as string | undefined,
},
} as unknown as BookingRequest;
}
transformLocation(location: string | BookingInputLocation_2024_08_13): {
@@ -366,14 +379,9 @@ export class InputBookingsService_2024_08_13 {
async transformInputCreateRecurringBooking(
inputBooking: CreateRecurringBookingInput_2024_08_13,
eventType: EventTypeWithOwnerAndTeam,
platformClientId?: string
) {
const eventType = await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(
inputBooking.eventTypeId
);
if (!eventType) {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} not found`);
}
if (!eventType.recurringEvent) {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
}
@@ -136,6 +136,7 @@ export class OutputBookingsService_2024_08_13 {
createdAt: databaseBooking.createdAt,
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -231,6 +232,7 @@ export class OutputBookingsService_2024_08_13 {
createdAt: databaseBooking.createdAt,
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
};
const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, {
@@ -279,6 +281,7 @@ export class OutputBookingsService_2024_08_13 {
createdAt: databaseBooking.createdAt,
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
};
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -388,6 +391,7 @@ export class OutputBookingsService_2024_08_13 {
createdAt: databaseBooking.createdAt,
updatedAt: databaseBooking.updatedAt,
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
};
const parsed = plainToClass(GetRecurringSeatedBookingOutput_2024_08_13, booking, {
@@ -59,4 +59,28 @@ export class PlatformBookingsService {
return undefined;
}
async getOAuthClientParamsForEventType(eventType: {
userId: number | null | undefined;
teamId: number | null | undefined;
}) {
let oAuthClient: PlatformOAuthClient | null = null;
if (eventType.userId) {
oAuthClient = await this.oAuthClientRepository.getByUserId(eventType.userId);
} else if (eventType.teamId) {
oAuthClient = await this.oAuthClientRepository.getByTeamId(eventType.teamId);
}
if (oAuthClient) {
return {
platformClientId: oAuthClient.id,
platformCancelUrl: oAuthClient.bookingCancelRedirectUri,
platformRescheduleUrl: oAuthClient.bookingRescheduleRedirectUri,
platformBookingUrl: oAuthClient.bookingRedirectUri,
arePlatformEmailsEnabled: oAuthClient.areEmailsEnabled,
};
}
return undefined;
}
}
@@ -129,7 +129,7 @@ describe("Platform Calendars Endpoints", () => {
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect?redir=https://cal.com&isDryRun=false`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.set("Origin", CLIENT_REDIRECT_URI)
.expect(200);
@@ -15,6 +15,7 @@ import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { ApiAuthGuardOnlyAllow } from "@/modules/auth/decorators/api-auth-guard-only-allow.decorator";
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";
@@ -34,6 +35,7 @@ import {
BadRequestException,
Post,
Body,
ParseBoolPipe,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiParam, ApiQuery, ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
@@ -50,7 +52,7 @@ import {
APPLE_CALENDAR,
CREDENTIAL_CALENDARS,
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
@Controller({
path: "/v2/calendars",
@@ -134,6 +136,7 @@ export class CalendarsController {
name: "calendar",
})
@UseGuards(ApiAuthGuard)
@ApiAuthGuardOnlyAllow(["API_KEY", "ACCESS_TOKEN"])
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Get("/:calendar/connect")
@HttpCode(HttpStatus.OK)
@@ -148,13 +151,14 @@ export class CalendarsController {
@Req() req: Request,
@Headers("Authorization") authorization: string,
@Param("calendar") calendar: string,
@Query("redir") redir?: string | null
@Query("redir") redir?: string | null,
@Query("isDryRun", new ParseBoolPipe({ optional: true })) isDryRun?: boolean
): Promise<ApiResponse<{ authUrl: string }>> {
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.connect(authorization, req, redir ?? "");
return await this.outlookService.connect(authorization, req, redir ?? "", isDryRun);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.connect(authorization, req, redir ?? "");
return await this.googleCalendarService.connect(authorization, req, redir ?? "", isDryRun);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -171,7 +175,7 @@ export class CalendarsController {
@Get("/:calendar/save")
@HttpCode(HttpStatus.OK)
@Redirect(undefined, 301)
@ApiOperation({ summary: "Save an oAuth calendar credentials" })
@ApiOperation({ summary: "Save Google or Outlook calendar credentials" })
async save(
@Query("state") state: string,
@Query("code") code: string,
@@ -179,18 +183,30 @@ export class CalendarsController {
): Promise<{ url: string }> {
// state params contains our user access token
const stateParams = new URLSearchParams(state);
const { accessToken, origin, redir } = z
.object({ accessToken: z.string(), origin: z.string(), redir: z.string().nullish().optional() })
const { accessToken, origin, redir, isDryRun } = z
.object({
accessToken: z.string(),
origin: z.string(),
redir: z.string().nullish().optional(),
isDryRun: z.string().nullish().optional(),
})
.parse({
accessToken: stateParams.get("accessToken"),
origin: stateParams.get("origin"),
redir: stateParams.get("redir"),
isDryRun: stateParams.get("isDryRun"),
});
switch (calendar) {
case OFFICE_365_CALENDAR:
return await this.outlookService.save(code, accessToken, origin, redir ?? "");
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
case GOOGLE_CALENDAR:
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "");
return await this.googleCalendarService.save(
code,
accessToken,
origin,
redir ?? "",
isDryRun === "true"
);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -207,11 +223,11 @@ export class CalendarsController {
@UseGuards(ApiAuthGuard)
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@Post("/:calendar/credentials")
@ApiOperation({ summary: "Sync credentials" })
@ApiOperation({ summary: "Save Apple calendar credentials" })
async syncCredentials(
@GetUser() user: User,
@Param("calendar") calendar: string,
@Body() body: { username: string; password: string }
@Body() body: CreateCalendarCredentialsInput
): Promise<{ status: string }> {
const { username, password } = body;
@@ -30,7 +30,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
const appleCalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
APPLE_CALENDAR_TYPE,
userId
);
@@ -60,8 +60,9 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
}
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
if (username.length <= 1 || password.length <= 1)
if (!username || !password || username.length <= 1 || password.length <= 1) {
throw new BadRequestException(`Username or password cannot be empty`);
}
const existingAppleCalendarCredentials = await this.credentialRepository.getAllUserCredentialsByTypeAndId(
APPLE_CALENDAR_TYPE,
@@ -94,7 +95,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
}
if (!!hasCalendarWithGivenCredentials && !hasMatchingUsernameAndPassword) {
await this.credentialRepository.upsertAppCredential(
await this.credentialRepository.upsertUserAppCredential(
APPLE_CALENDAR_TYPE,
symmetricEncrypt(JSON.stringify({ username, password }), process.env.CALENDSO_ENCRYPTION_KEY || ""),
userId,
@@ -118,6 +119,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
delegationCredentialId: null,
};
const dav = new CalendarService({
@@ -126,7 +128,7 @@ export class AppleCalendarService implements CredentialSyncCalendarApp {
user: { email: userEmail },
});
await dav?.listCalendars();
await this.credentialRepository.upsertAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
await this.credentialRepository.upsertUserAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
} catch (reason) {
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
}
@@ -172,7 +172,7 @@ export class CalendarsService {
calendarType: keyof typeof APPS_TYPE_ID_MAPPING,
credentialId?: number | null
) {
const credential = await this.credentialsRepository.upsertAppCredential(
const credential = await this.credentialsRepository.upsertUserAppCredential(
calendarType,
key,
userId,
@@ -39,31 +39,38 @@ export class GoogleCalendarService implements OAuthCalendarApp {
async connect(
authorization: string,
req: Request,
redir?: string
redir?: string,
isDryRun?: boolean
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
async save(
code: string,
accessToken: string,
origin: string,
redir?: string,
isDryRun?: boolean
): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: "offline",
scope: CALENDAR_SCOPES,
prompt: "consent",
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
});
return authUrl;
@@ -84,7 +91,10 @@ export class GoogleCalendarService implements OAuthCalendarApp {
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
"google_calendar",
userId
);
if (!gcalCredentials) {
throw new BadRequestException("Credentials for google_calendar not found.");
@@ -112,7 +122,8 @@ export class GoogleCalendarService implements OAuthCalendarApp {
code: string,
accessToken: string,
origin: string,
redir?: string
redir?: string,
isDryRun?: boolean
) {
// User chose not to authorize your app or didn't authorize your app
// redirect directly without oauth code
@@ -120,6 +131,11 @@ export class GoogleCalendarService implements OAuthCalendarApp {
return { url: redir || origin };
}
// if isDryRun is true we know its a dry run so we just redirect straight away
if (isDryRun) {
return { url: redir || origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
@@ -35,6 +35,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
teamId: null,
appId: ICS_CALENDAR,
invalid: false,
delegationCredentialId: null,
};
try {
@@ -52,7 +53,7 @@ export class IcsFeedService implements ICSFeedCalendarApp {
);
}
const credential = await this.credentialRepository.upsertAppCredential(
const credential = await this.credentialRepository.upsertUserAppCredential(
ICS_CALENDAR_TYPE,
data.key,
userId
@@ -75,7 +76,10 @@ export class IcsFeedService implements ICSFeedCalendarApp {
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const icsFeedCredentials = await this.credentialRepository.getByTypeAndUserId(ICS_CALENDAR_TYPE, userId);
const icsFeedCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
ICS_CALENDAR_TYPE,
userId
);
if (!icsFeedCredentials) {
throw new BadRequestException("Credentials for Ics Feed calendar not found.");
@@ -33,24 +33,31 @@ export class OutlookService implements OAuthCalendarApp {
async connect(
authorization: string,
req: Request,
redir?: string
redir?: string,
isDryRun?: boolean
): Promise<{ status: typeof SUCCESS_STATUS; data: { authUrl: string } }> {
const accessToken = authorization.replace("Bearer ", "");
const origin = req.get("origin") ?? req.get("host");
const redirectUrl = await await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir);
const redirectUrl = await this.getCalendarRedirectUrl(accessToken, origin ?? "", redir, isDryRun);
return { status: SUCCESS_STATUS, data: { authUrl: redirectUrl } };
}
async save(code: string, accessToken: string, origin: string, redir?: string): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir);
async save(
code: string,
accessToken: string,
origin: string,
redir?: string,
isDryRun?: boolean
): Promise<{ url: string }> {
return await this.saveCalendarCredentialsAndRedirect(code, accessToken, origin, redir, isDryRun);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string) {
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
@@ -60,7 +67,7 @@ export class OutlookService implements OAuthCalendarApp {
client_id,
prompt: "select_account",
redirect_uri: this.redirectUri,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}`,
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
};
const query = stringify(params);
@@ -71,7 +78,7 @@ export class OutlookService implements OAuthCalendarApp {
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const office365CalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
const office365CalendarCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
"office365_calendar",
userId
);
@@ -148,13 +155,19 @@ export class OutlookService implements OAuthCalendarApp {
code: string,
accessToken: string,
origin: string,
redir?: string
redir?: string,
isDryRun?: boolean
) {
// if code is not defined, user denied to authorize office 365 app, just redirect straight away
if (!code || code === "undefined") {
return { url: redir || origin };
}
// if isDryRun is true we know its a dry run so we just redirect straight away
if (isDryRun) {
return { url: redir || origin };
}
const parsedCode = z.string().parse(code);
const ownerId = await this.tokensRepository.getAccessTokenOwnerId(accessToken);
@@ -10,6 +10,7 @@ import {
IsOptional,
ValidateNested,
Min,
Max,
IsInt,
IsEnum,
IsArray,
@@ -17,6 +18,8 @@ import {
IsNumber,
} from "class-validator";
import { MAX_SEATS_PER_TIME_SLOT } from "@calcom/platform-constants";
// note(Lauris): We will gradually expose more properties if any customer needs them.
// Just uncomment any below when requested. Go to bottom of file to see UpdateEventTypeInput.
@@ -414,9 +417,12 @@ export class UpdateEventTypeInput_2024_04_15 {
@ApiPropertyOptional()
afterEventBuffer?: number;
// @IsInt()
// @IsOptional()
// seatsPerTimeSlot?: number;
@IsInt()
@Min(1)
@Max(MAX_SEATS_PER_TIME_SLOT)
@IsOptional()
@ApiPropertyOptional()
seatsPerTimeSlot?: number;
// @IsBoolean()
// @IsOptional()
@@ -377,6 +377,27 @@ describe("Event types Endpoints", () => {
);
});
it("should not allow creating an event type with integration not installed on user", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class",
slug: "coding-class",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "zoom",
},
],
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it("should create an event type", async () => {
const nameBookingField: NameDefaultFieldInput_2024_06_14 = {
type: "name",
@@ -469,6 +490,7 @@ describe("Event types Endpoints", () => {
requiresBookerEmailVerification: false,
hideCalendarNotes: false,
hideCalendarEventDetails: false,
hideOrganizerEmail: false,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -508,6 +530,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(createdEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(createdEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(createdEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
@@ -589,6 +612,7 @@ describe("Event types Endpoints", () => {
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -988,6 +1012,7 @@ describe("Event types Endpoints", () => {
requiresBookerEmailVerification: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
lockTimeZoneToggleOnBookingPage: true,
color: {
darkThemeHex: "#292929",
@@ -1052,6 +1077,7 @@ describe("Event types Endpoints", () => {
);
expect(updatedEventType.hideCalendarNotes).toEqual(body.hideCalendarNotes);
expect(updatedEventType.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(updatedEventType.hideOrganizerEmail).toEqual(body.hideOrganizerEmail);
expect(updatedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
body.lockTimeZoneToggleOnBookingPage
);
@@ -1072,6 +1098,7 @@ describe("Event types Endpoints", () => {
eventType.requiresBookerEmailVerification = updatedEventType.requiresBookerEmailVerification;
eventType.hideCalendarNotes = updatedEventType.hideCalendarNotes;
eventType.hideCalendarEventDetails = updatedEventType.hideCalendarEventDetails;
eventType.hideOrganizerEmail = updatedEventType.hideOrganizerEmail;
eventType.lockTimeZoneToggleOnBookingPage = updatedEventType.lockTimeZoneToggleOnBookingPage;
eventType.color = updatedEventType.color;
eventType.bookingFields = updatedEventType.bookingFields;
@@ -1090,6 +1117,23 @@ describe("Event types Endpoints", () => {
.expect(404);
});
it("should not allow to update event type with integration not installed on user", async () => {
const body: UpdateEventTypeInput_2024_06_14 = {
locations: [
{
type: "integration",
integration: "office365-video",
},
],
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
@@ -1125,6 +1169,7 @@ describe("Event types Endpoints", () => {
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -1166,6 +1211,7 @@ describe("Event types Endpoints", () => {
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
@@ -75,7 +75,7 @@ export class EventTypesController_2024_06_14 {
@GetUser() user: UserWithProfile
): Promise<CreateEventTypeOutput_2024_06_14> {
const transformedBody = await this.inputEventTypesService.transformAndValidateCreateEventTypeInput(
user.id,
user,
body
);
@@ -150,7 +150,7 @@ export class EventTypesController_2024_06_14 {
): Promise<UpdateEventTypeOutput_2024_06_14> {
const transformedBody = await this.inputEventTypesService.transformAndValidateUpdateEventTypeInput(
body,
user.id,
user,
eventTypeId
);
@@ -8,6 +8,7 @@ import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
@@ -18,7 +19,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, ConferencingModule],
providers: [
EventTypesRepository_2024_06_14,
EventTypesService_2024_06_14,
@@ -2,6 +2,7 @@ import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { InputEventTransformed_2024_06_14 } from "@calcom/platform-types";
@Injectable()
@@ -110,6 +111,18 @@ export class EventTypesRepository_2024_06_14 {
});
}
async getUserEventTypeBySlugWithOwnerAndTeam(userId: number, slug: string) {
return this.dbRead.prisma.eventType.findUnique({
where: {
userId_slug: {
userId: userId,
slug: slug,
},
},
include: { owner: true, team: true },
});
}
async deleteEventType(eventTypeId: number) {
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
}
@@ -1,6 +1,7 @@
import { ConnectedCalendarsData } from "@/ee/calendars/outputs/connected-calendars.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException } from "@nestjs/common";
@@ -49,13 +50,15 @@ interface ValidationContext {
export class InputEventTypesService_2024_06_14 {
constructor(
private readonly eventTypesRepository: EventTypesRepository_2024_06_14,
private readonly calendarsService: CalendarsService
private readonly calendarsService: CalendarsService,
private readonly conferencingService: ConferencingService
) {}
async transformAndValidateCreateEventTypeInput(
userId: UserWithProfile["id"],
user: UserWithProfile,
inputEventType: CreateEventTypeInput_2024_06_14
) {
await this.validateInputLocations(user, inputEventType.locations);
const transformedBody = this.transformInputCreateEventType(inputEventType);
await this.validateEventTypeInputs({
@@ -66,19 +69,21 @@ export class InputEventTypesService_2024_06_14 {
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(userId));
(await this.validateInputUseDestinationCalendarEmail(user.id));
return transformedBody;
}
async transformAndValidateUpdateEventTypeInput(
inputEventType: UpdateEventTypeInput_2024_06_14,
userId: UserWithProfile["id"],
user: UserWithProfile,
eventTypeId: number
) {
await this.validateInputLocations(user, inputEventType.locations);
const transformedBody = await this.transformInputUpdateEventType(inputEventType, eventTypeId);
await this.validateEventTypeInputs({
@@ -90,10 +95,10 @@ export class InputEventTypesService_2024_06_14 {
});
transformedBody.destinationCalendar &&
(await this.validateInputDestinationCalendar(userId, transformedBody.destinationCalendar));
(await this.validateInputDestinationCalendar(user.id, transformedBody.destinationCalendar));
transformedBody.useEventTypeDestinationCalendarEmail &&
(await this.validateInputUseDestinationCalendarEmail(userId));
(await this.validateInputUseDestinationCalendarEmail(user.id));
return transformedBody;
}
@@ -448,4 +453,20 @@ export class InputEventTypesService_2024_06_14 {
return;
}
async validateInputLocations(
user: UserWithProfile,
inputLocations: CreateEventTypeInput_2024_06_14["locations"] | undefined
) {
await Promise.all(
inputLocations?.map(async (location) => {
if (location.type === "integration") {
// cal-video is global, so we can skip this check
if (location.integration !== "cal-video") {
await this.conferencingService.checkAppIsValidAndConnected(user, location.integration);
}
}
}) ?? []
);
}
}
@@ -87,6 +87,7 @@ type Input = Pick<
| "destinationCalendar"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
>;
@Injectable()
@@ -123,6 +124,7 @@ export class OutputEventTypesService_2024_06_14 {
seatsShowAttendees,
useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
hideOrganizerEmail,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
@@ -197,6 +199,7 @@ export class OutputEventTypesService_2024_06_14 {
destinationCalendar,
useDestinationCalendarEmail: useEventTypeDestinationCalendarEmail,
hideCalendarEventDetails,
hideOrganizerEmail,
};
}
+4 -5
View File
@@ -9,8 +9,6 @@ import { Permissions } from "@/modules/auth/decorators/permissions/permissions.d
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import {
BadRequestException,
Controller,
@@ -48,8 +46,6 @@ export class GcalController {
constructor(
private readonly credentialRepository: CredentialsRepository,
private readonly tokensRepository: TokensRepository,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly config: ConfigService,
private readonly gcalService: GCalService,
private readonly calendarsService: CalendarsService
@@ -94,7 +90,10 @@ export class GcalController {
@Permissions([APPS_READ])
@ApiOperation({ summary: "Check a calendar connection status" })
async check(@GetUser("id") userId: number): Promise<GcalCheckOutput> {
const gcalCredentials = await this.credentialRepository.getByTypeAndUserId("google_calendar", userId);
const gcalCredentials = await this.credentialRepository.findCredentialByTypeAndUserId(
"google_calendar",
userId
);
if (!gcalCredentials) {
throw new BadRequestException("Credentials for google_calendar not found.");
+2 -1
View File
@@ -1,11 +1,12 @@
import { MeController } from "@/ee/me/me.controller";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule],
imports: [UsersModule, SchedulesModule_2024_04_15, TokensModule, OAuthClientModule],
controllers: [MeController],
})
export class MeModule {}
@@ -8,6 +8,7 @@ import { MeModule } from "@/ee/me/me.module";
import { ProviderModule } from "@/ee/provider/provider.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
import { SlotsModule_2024_04_15 } from "@/modules/slots/slots-2024-04-15/slots.module";
import { SlotsModule_2024_09_04 } from "@/modules/slots/slots-2024-09-04/slots.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
@@ -33,6 +34,7 @@ import { Module } from "@nestjs/common";
SlotsModule_2024_04_15,
SlotsModule_2024_09_04,
TeamsModule,
RoutingFormsModule,
],
})
export class PlatformEndpointsModule implements NestModule {
+4
View File
@@ -38,6 +38,10 @@ export const getEnv = <K extends keyof Environment>(key: K, fallback?: Environme
const value = process.env[key] as Environment[K] | undefined;
if (value === undefined) {
// handle fallback falsy cases that should still be used as value
if (fallback === false || fallback === "" || fallback === 0) {
return fallback;
}
if (fallback) {
return fallback;
}
@@ -36,7 +36,8 @@ export class CalendarServiceExceptionFilter implements ExceptionFilter {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
this.logger.error(`Calendar Service Exception Filter: ${exception?.message}`, {
exception,
@@ -14,8 +14,8 @@ export class HttpExceptionFilter implements ExceptionFilter<HttpException> {
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const statusCode = exception.getStatus();
const requestId = request.headers["X-Request-Id"];
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
this.logger.error(`Http Exception Filter: ${exception?.message}`, {
exception,
body: request.body,
@@ -40,7 +40,8 @@ export class PrismaExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
this.logger.error(`PrismaError: ${error.message}`, {
error,
@@ -87,7 +87,8 @@ export class TRPCExceptionFilter implements ExceptionFilter {
break;
}
const requestId = request.headers["X-Request-Id"];
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
this.logger.error(`TRPC Exception Filter: ${exception?.message}`, {
exception,
@@ -15,7 +15,8 @@ export class ZodExceptionFilter implements ExceptionFilter {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const requestId = request.headers["X-Request-Id"];
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
response.setHeader("X-Request-Id", requestId.toString());
this.logger.error(`ZodError: ${error.message}`, {
error,
@@ -0,0 +1,4 @@
import { RateLimitType } from "@/lib/throttler-guard";
import { Reflector } from "@nestjs/core";
export const Throttle = Reflector.createDecorator<RateLimitType>();
+152
View File
@@ -0,0 +1,152 @@
import { Injectable, Logger as NestLogger, Scope } from "@nestjs/common";
// 1. Define an interface for the settings
interface IMyLoggerSettings {
minLevel: number; // Example: 0=debug, 1=info, 2=warn, 3=error
displayTimestamp: boolean;
logFormat: "pretty" | "json" | "simple";
// Add any other settings you need, mimicking tslog or your own requirements
// e.g., name?: string; displayFunctionName?: boolean; etc.
}
// Define log level constants (optional but recommended for readability)
const LogLevel = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
};
/**
* This logger acts as a bridge between Nest.js Logger and log calls originating
* from platform libraries. It forwards logs to NestLogger, allowing centralization
* (e.g., sending to Axiom) and adds an optional prefix for context.
*/
@Injectable({ scope: Scope.TRANSIENT }) // TRANSIENT ensures getSubLogger provides truly independent instances if needed elsewhere
export class Logger {
// Use NestLogger for the actual logging output
private readonly nestLogger = new NestLogger("LoggerBridge");
// Prefix to add to messages for this instance
private prefix = "";
// Add a public `settings` property, typed with the interface
public settings: IMyLoggerSettings;
// Define default settings
private static readonly defaultSettings: IMyLoggerSettings = {
minLevel: process?.env?.LOGGER_BRIDGE_LOG_LEVEL
? Number(process.env.LOGGER_BRIDGE_LOG_LEVEL)
: LogLevel.INFO, // Default to INFO level
displayTimestamp: true,
logFormat: "pretty",
};
constructor(userSettings?: Partial<IMyLoggerSettings>) {
// Merge default settings with user-provided settings
// User settings override defaults
this.settings = {
...Logger.defaultSettings,
...userSettings,
};
}
/**
* Creates a new LoggerBridge instance with a specific prefix.
* Useful for providing context from different parts of a library.
* @param options - Configuration for the sub-logger.
* @param options.prefix - An array of strings to join as the log prefix.
* @returns A new LoggerBridge instance with the specified prefix.
*/
getSubLogger(options: { prefix?: string[] }): Logger {
const newLogger = new Logger();
// Set the prefix for the *new* instance
newLogger.prefix = options?.prefix?.join(" ") ?? "";
return newLogger;
}
// --- Public logging methods ---
info(...args: any[]) {
this.settings.minLevel <= 1 && this.logInternal("log", ...args);
}
warn(...args: any[]) {
this.settings.minLevel <= 2 && this.logInternal("warn", ...args);
}
error(...args: any[]) {
this.settings.minLevel <= 3 && this.logInternal("error", ...args);
}
debug(...args: any[]) {
this.settings.minLevel === 0 && this.logInternal("debug", ...args);
}
trace(...args: any[]) {
this.settings.minLevel === 0 && this.logInternal("verbose", ...args);
}
fatal(...args: any[]) {
// Prepend FATAL: to the message and log as error
const fatalMessage = `fatal: ${this.formatArgsAsString(args)}`;
this.settings.minLevel <= 3 && this.logInternal("error", fatalMessage);
}
silly(...args: any[]) {
const sillyMessage = `silly: ${this.formatArgsAsString(args)}`;
this.settings.minLevel === 0 && this.logInternal("verbose", sillyMessage);
}
// --- Internal logging implementation ---
/** Formats arguments into a single string */
private formatArgsAsString(args: any[]): string {
return args
.map((arg) => {
if (typeof arg === "string") {
return arg;
}
// Attempt to stringify non-string arguments
try {
return JSON.stringify(arg);
} catch (e) {
return "[Unserializable Object]";
}
})
.join(" "); // Use space as separator, adjust if needed
}
/** Central method to forward logs to NestLogger */
private logInternal(level: "log" | "warn" | "error" | "debug" | "verbose", ...args: any[]) {
try {
// Format message from potentially multiple arguments
const formattedMessage = this.formatArgsAsString(args);
// Prepend the prefix if it exists
const message = this.prefix ? `${this.prefix} ${formattedMessage}` : formattedMessage;
// Call the corresponding NestLogger method
switch (level) {
case "log":
this.nestLogger.log(message);
break;
case "warn":
this.nestLogger.warn(message);
break;
case "error":
this.nestLogger.error(message);
break;
case "debug":
this.nestLogger.debug(message);
break;
case "verbose":
this.nestLogger.verbose(message);
break;
}
} catch (err) {
this.nestLogger.error(
`Could not bridge log message. Error: ${err instanceof Error ? err.message : String(err)}`
);
}
}
}
+24 -8
View File
@@ -1,5 +1,6 @@
import { getEnv } from "@/env";
import { hashAPIKey, isApiKey, stripApiKey } from "@/lib/api-key";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { ThrottlerStorageRedisService } from "@nest-lab/throttler-storage-redis";
import { Inject, Injectable, Logger, UnauthorizedException } from "@nestjs/common";
@@ -22,7 +23,7 @@ const rateLimitSchema = z.object({
ttl: z.number(),
blockDuration: z.number(),
});
type RateLimitType = z.infer<typeof rateLimitSchema>;
export type RateLimitType = z.infer<typeof rateLimitSchema>;
const rateLimitsSchema = z.array(rateLimitSchema);
const sixtySecondsMs = 60 * 1000;
@@ -52,16 +53,21 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
const { context } = requestProps;
const throttleOptions = this.reflector.get(Throttle, context.getHandler());
const request = context.switchToHttp().getRequest<Request>();
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
const response = context.switchToHttp().getResponse<Response>();
const tracker = await this.getTracker(request);
this.logger.verbose(
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
"Authorization"
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${request.ip}"`
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
);
if (throttleOptions) {
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
}
if (tracker.startsWith("api_key_")) {
return this.handleApiKeyRequest(tracker, response);
} else {
@@ -69,6 +75,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
}
}
private async handleApiEndpointThrottle(tracker: string, options: RateLimitType, response: Response) {
const { isBlocked } = await this.incrementRateLimit(`${tracker}_${options.name}`, options, response);
if (isBlocked) {
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
}
return true;
}
private async handleApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
const rateLimits = await this.getRateLimitsForApiKeyTracker(tracker);
@@ -81,7 +96,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
}
if (allLimitsBlocked) {
throw new ThrottlerException("Too many requests. Please try again later.");
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
}
return true;
@@ -95,7 +110,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
if (isBlocked) {
throw new ThrottlerException("Too many requests. Please try again later.");
throw new ThrottlerException("CustomThrottlerGuard - Too many requests. Please try again later.");
}
return true;
@@ -149,7 +164,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
});
if (!apiKeyRecord) {
throw new UnauthorizedException("Invalid API Key");
throw new UnauthorizedException("CustomThrottlerGuard - Invalid API Key");
}
rateLimits = await this.dbRead.prisma.rateLimit.findMany({
@@ -209,6 +224,7 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
protected async getTracker(request: Request): Promise<string> {
const authorizationHeader = request.get("Authorization")?.replace("Bearer ", "");
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
if (authorizationHeader) {
const apiKeyPrefix = getEnv("API_KEY_PREFIX", "cal_");
@@ -223,8 +239,8 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
return `oauth_client_${oauthClientId}`;
}
if (request.ip) {
return `ip_${request.ip}`;
if (IP) {
return `ip_${IP}`;
}
this.logger.verbose(`no tracker found: ${request.url}`);
@@ -31,6 +31,15 @@ export class AppsRepository {
});
}
async deleteTeamAppCredentials(credentialIdsToDelete: number[], teamId: number) {
return this.dbWrite.prisma.credential.deleteMany({
where: {
id: { in: credentialIdsToDelete },
teamId,
},
});
}
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
@@ -2,12 +2,14 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_0
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 { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
@@ -18,12 +20,14 @@ import { Module } from "@nestjs/common";
providers: [
EventTypesAtomService,
ConferencingAtomsService,
AttributesAtomsService,
MembershipsRepository,
CredentialsRepository,
UsersRepository,
AtomsRepository,
UsersService,
SchedulesRepository_2024_06_11,
RedisService,
],
exports: [EventTypesAtomService],
controllers: [AtomsController],
@@ -3,10 +3,19 @@ import {
BulkUpdateEventTypeToDefaultLocationDto,
EventTypesAppInput,
} from "@/modules/atoms/inputs/event-types-app.input";
import { FindTeamMembersMatchingAttributeQueryDto } from "@/modules/atoms/inputs/find-team-members-matching-attribute.input";
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-admin-api-enabled.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
@@ -27,6 +36,8 @@ import { ConnectedApps } from "@calcom/platform-libraries/app-store";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries/event-types";
import { ApiResponse } from "@calcom/platform-types";
import { FindTeamMembersMatchingAttributeResponseDto } from "../outputs/find-team-members-matching-attribute.output";
/*
Endpoints used only by platform atoms, reusing code from other modules, data is already formatted and ready to be used by frontend atoms
@@ -43,7 +54,8 @@ these endpoints should not be recommended for use by third party and are exclude
export class AtomsController {
constructor(
private readonly eventTypesService: EventTypesAtomService,
private readonly conferencingService: ConferencingAtomsService
private readonly conferencingService: ConferencingAtomsService,
private readonly attributesService: AttributesAtomsService
) {}
@Get("event-types/:eventTypeId")
@@ -60,14 +72,27 @@ export class AtomsController {
};
}
@Get("/organizations/:orgId/teams/:teamId/event-types")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Version(VERSION_NEUTRAL)
async listTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<ApiResponse<unknown>> {
const eventTypes = await this.eventTypesService.getTeamEventTypes(teamId);
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Get("/event-types")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
const eventType = await this.eventTypesService.getUserEventTypes(userId);
async listUserEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
const eventTypes = await this.eventTypesService.getUserEventTypes(userId);
return {
status: SUCCESS_STATUS,
data: eventType,
data: eventTypes,
};
}
@@ -116,6 +141,22 @@ export class AtomsController {
};
}
@Patch("/organizations/:orgId/teams/:teamId/event-types/bulk-update-to-default-location")
@Version(VERSION_NEUTRAL)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
async bulkUpdateAtomTeamEventTypes(
@GetUser() user: UserWithProfile,
@Body() body: BulkUpdateEventTypeToDefaultLocationDto,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
await this.eventTypesService.bulkUpdateTeamEventTypesDefaultLocation(body.eventTypeIds, teamId);
return {
status: SUCCESS_STATUS,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -135,9 +176,11 @@ export class AtomsController {
};
}
@Patch("/organizations/:organizationId/teams/:teamId/event-types/:eventTypeId")
@Patch("/organizations/:orgId/teams/:teamId/event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
async updateAtomTeamEventType(
@GetUser() user: UserWithProfile,
@Param("eventTypeId", ParseIntPipe) eventTypeId: number,
@@ -155,13 +198,54 @@ export class AtomsController {
data: eventType,
};
}
@Get("/organizations/:orgId/teams/:teamId/conferencing")
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@Version(VERSION_NEUTRAL)
async listTeamInstalledConferencingApps(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getTeamConferencingApps(user, teamId);
return {
status: SUCCESS_STATUS,
data: conferencingApps,
};
}
@Get("/conferencing")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getConferencingApps(user);
async listUserInstalledConferencingApps(
@GetUser() user: UserWithProfile
): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getUserConferencingApps(user);
return {
status: SUCCESS_STATUS,
data: conferencingApps,
};
}
@Get("/organizations/:orgId/teams/:teamId/members-matching-attribute")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async findTeamMembersMatchingAttributes(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: FindTeamMembersMatchingAttributeQueryDto
): Promise<FindTeamMembersMatchingAttributeResponseDto> {
const result = await this.attributesService.findTeamMembersMatchingAttribute(teamId, orgId, {
attributesQueryValue: query.attributesQueryValue,
isPreview: query.isPreview,
enablePerf: query.enablePerf,
concurrency: query.concurrency,
});
return { status: SUCCESS_STATUS, data: conferencingApps };
return {
status: SUCCESS_STATUS,
data: result,
};
}
}
@@ -0,0 +1,25 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import type { TFindTeamMembersMatchingAttributeLogicInputSchema } from "@calcom/platform-libraries";
export class FindTeamMembersMatchingAttributeQueryDto {
@ApiPropertyOptional({
nullable: true,
})
attributesQueryValue: TFindTeamMembersMatchingAttributeLogicInputSchema["attributesQueryValue"];
@ApiPropertyOptional({
type: Boolean,
})
isPreview?: boolean;
@ApiPropertyOptional({
type: Boolean,
})
enablePerf?: boolean;
@ApiPropertyOptional({
type: Number,
})
concurrency?: number;
}
@@ -0,0 +1,19 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { FindTeamMembersMatchingAttributeOutputDto } from "@calcom/platform-types";
export class FindTeamMembersMatchingAttributeResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => FindTeamMembersMatchingAttributeOutputDto)
@Expose()
@ApiProperty({ type: FindTeamMembersMatchingAttributeOutputDto })
readonly data!: FindTeamMembersMatchingAttributeOutputDto;
}
@@ -0,0 +1,64 @@
import { UsersRepository } from "@/modules/users/users.repository";
import { Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { findTeamMembersMatchingAttributeLogic } from "@calcom/platform-libraries";
import { FindTeamMembersMatchingAttributeQueryDto } from "../inputs/find-team-members-matching-attribute.input";
@Injectable()
export class AttributesAtomsService {
private logger = new Logger("AttributesAtomService");
constructor(private readonly usersRepository: UsersRepository) {}
async findTeamMembersMatchingAttribute(
teamId: number,
orgId: number,
input: FindTeamMembersMatchingAttributeQueryDto
) {
const {
teamMembersMatchingAttributeLogic: matchingTeamMembersWithResult,
mainAttributeLogicBuildingWarnings: mainWarnings,
fallbackAttributeLogicBuildingWarnings: fallbackWarnings,
troubleshooter,
} = await findTeamMembersMatchingAttributeLogic(
{
teamId,
orgId,
attributesQueryValue: input.attributesQueryValue,
},
{
enablePerf: input.enablePerf,
concurrency: input.concurrency,
enableTroubleshooter: input.enablePerf,
}
);
if (!matchingTeamMembersWithResult) {
return {
troubleshooter,
mainWarnings,
fallbackWarnings,
result: null,
};
}
const matchingTeamMembersIds = matchingTeamMembersWithResult.map(
(member: { userId: number }) => member.userId
);
const matchingTeamMembers = await this.usersRepository.findByIds(matchingTeamMembersIds);
return {
mainWarnings,
fallbackWarnings,
troubleshooter: troubleshooter,
result: matchingTeamMembers.map((user) => ({
id: user.id,
name: user.name,
email: user.email,
})),
};
}
}
@@ -12,7 +12,7 @@ export class ConferencingAtomsService {
constructor(private readonly dbWrite: PrismaWriteService) {}
async getConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
async getUserConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
return getConnectedApps({
user,
input: {
@@ -22,4 +22,17 @@ export class ConferencingAtomsService {
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
async getTeamConferencingApps(user: UserWithProfile, teamId: number): Promise<ConnectedApps> {
return getConnectedApps({
user,
input: {
variant: "conferencing",
onlyInstalled: true,
teamId,
includeTeamInstalledApps: true,
},
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
}
@@ -22,9 +22,11 @@ import type {
LocationOption,
} from "@calcom/platform-libraries/app-store";
import {
getBulkEventTypes,
getEventTypeById,
bulkUpdateEventsToDefaultLocation,
bulkUpdateTeamEventsToDefaultLocation,
getBulkUserEventTypes,
getBulkTeamEventTypes,
} from "@calcom/platform-libraries/event-types";
import {
updateEventType,
@@ -79,11 +81,19 @@ export class EventTypesAtomService {
this.eventTypeService.checkUserOwnsEventType(user.id, eventType.eventType);
}
// note (Lauris): don't show platform owner as one of the people that can be assigned to managed team event type
const onlyManagedTeamMembers = eventType.teamMembers.filter((user) => user.isPlatformManaged);
eventType.teamMembers = onlyManagedTeamMembers;
return eventType;
}
async getUserEventTypes(userId: number) {
return getBulkEventTypes(userId);
return getBulkUserEventTypes(userId);
}
async getTeamEventTypes(teamId: number) {
return getBulkTeamEventTypes(teamId);
}
async updateTeamEventType(
@@ -345,4 +355,12 @@ export class EventTypesAtomService {
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
async bulkUpdateTeamEventTypesDefaultLocation(eventTypeIds: number[], teamId: number) {
return bulkUpdateTeamEventsToDefaultLocation({
eventTypeIds,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
teamId,
});
}
}
@@ -0,0 +1,5 @@
import { Reflector } from "@nestjs/core";
export type AllowedAuthMethod = "OAUTH_CLIENT_CREDENTIALS" | "API_KEY" | "ACCESS_TOKEN" | "NEXT_AUTH";
export const ApiAuthGuardOnlyAllow = Reflector.createDecorator<AllowedAuthMethod[]>();
@@ -1,7 +1,20 @@
import { ExecutionContext, Inject } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
import { ApiAuthGuardOnlyAllow } from "../../decorators/api-auth-guard-only-allow.decorator";
export class ApiAuthGuard extends AuthGuard("api-auth") {
constructor() {
constructor(@Inject(Reflector) private readonly reflector: Reflector) {
super();
}
getRequest(context: ExecutionContext) {
const request = context.switchToHttp().getRequest();
const allowedMethods = this.reflector.get(ApiAuthGuardOnlyAllow, context.getHandler());
request.allowedAuthMethods = allowedMethods;
return request;
}
}
@@ -104,13 +104,6 @@ describe("PlatformPlanGuard", () => {
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
it("should throw ForbiddenException if the result is cached as false in Redis", async () => {
jest.spyOn(reflector, "get").mockReturnValue("ESSENTIALS");
jest.spyOn(redisService.redis, "get").mockResolvedValue(JSON.stringify(false));
await expect(guard.canActivate(mockContext)).rejects.toThrow(ForbiddenException);
});
function createMockExecutionContext(context: Record<string, string | object>): ExecutionContext {
return createMock<ExecutionContext>({
switchToHttp: () => ({
@@ -16,51 +16,63 @@ export class PlatformPlanGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as ApiAuthGuardUser;
const minimumPlan = this.reflector.get(PlatformPlan, context.getHandler()) as PlatformPlanType;
const REDIS_CACHE_KEY = `apiv2:user:${user?.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:platformbilling:${minimumPlan}`;
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return cachedAccess;
if (!minimumPlan) {
return true;
}
let canAccess = false;
if (user && orgId) {
const team = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
const isPlatform = team?.isPlatform;
const hasSubscription = team?.platformBilling?.subscriptionId;
if (!team) {
canAccess = false;
} else if (!isPlatform) {
canAccess = true;
} else if (!hasSubscription) {
canAccess = false;
} else {
canAccess = hasMinimumPlan({
currentPlan: team.platformBilling?.plan as PlatformPlanType,
minimumPlan: minimumPlan,
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
});
}
const request = context.switchToHttp().getRequest<Request>();
const orgId = request.params.orgId;
const user = request.user as ApiAuthGuardUser;
if (!user) {
throw new ForbiddenException("PlatformPlanGuard - No user associated with the request.");
}
if (!orgId) {
throw new ForbiddenException("PlatformPlanGuard - No organization associated with the request.");
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
if (canAccess) {
return canAccess;
return await this.checkPlatformPlanAccess(orgId, minimumPlan);
}
async checkPlatformPlanAccess(orgId: string, minimumPlan: PlatformPlanType) {
const REDIS_CACHE_KEY = `apiv2:org:${orgId}:guard:platformbilling:${minimumPlan}`;
const cachedValue = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (cachedValue !== null) {
return cachedValue === "true";
}
throw new ForbiddenException(
`Platform plan - you do not have required plan for this operation. Minimum plan is ${minimumPlan}.`
);
const organization = await this.organizationsRepository.findByIdIncludeBilling(Number(orgId));
const isPlatform = organization?.isPlatform;
const hasSubscription = organization?.platformBilling?.subscriptionId;
if (!organization) {
throw new ForbiddenException(`PlatformPlanGuard - No organization found with id=${orgId}.`);
}
if (!isPlatform) {
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
return true;
}
if (!hasSubscription) {
throw new ForbiddenException(
`PlatformPlanGuard - No platform subscription found for organization with id=${orgId}.`
);
}
if (
!hasMinimumPlan({
currentPlan: organization.platformBilling?.plan as PlatformPlanType,
minimumPlan: minimumPlan,
plans: ["FREE", "STARTER", "ESSENTIALS", "SCALE", "ENTERPRISE"],
})
) {
throw new ForbiddenException(
`PlatformPlanGuard - organization with id=${orgId} does not have required plan for this operation. Minimum plan is ${minimumPlan} while the organization has ${
organization.platformBilling?.plan || "undefined"
}.`
);
}
await this.redisService.redis.set(REDIS_CACHE_KEY, "true", "EX", 300);
return true;
}
}
@@ -76,7 +88,7 @@ export function hasMinimumPlan(props: HasMinimumPlanProp): boolean {
if (currentPlanIndex === -1 || minimumPlanIndex === -1) {
throw new Error(
`Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
`PlatformPlanGuard - Invalid platform billing plan provided. Current plan: ${props.currentPlan}, Minimum plan: ${props.minimumPlan}`
);
}
@@ -20,11 +20,11 @@ export class IsMembershipInOrg implements CanActivate {
const orgId: string = request.params.orgId;
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
throw new ForbiddenException("IsMembershipInOrg - No org id found in request params.");
}
if (!membershipId) {
throw new ForbiddenException("No membership id found in request params.");
throw new ForbiddenException("IsMembershipInOrg - No membership id found in request params.");
}
const membership = await this.organizationsMembershipRepository.findOrgMembership(
@@ -33,7 +33,7 @@ export class IsMembershipInOrg implements CanActivate {
);
if (!membership) {
throw new NotFoundException(`Membership (${membershipId}) not found.`);
throw new NotFoundException(`IsMembershipInOrg - Membership (${membershipId}) not found.`);
}
request.membership = membership;
@@ -4,7 +4,7 @@ import { NO_AUTH_PROVIDED_MESSAGE } from "@/modules/auth/strategies/api-auth/api
export class OptionalApiAuthGuard extends ApiAuthGuard {
handleRequest(error: Error, user: any) {
// note(Lauris): optional means that auth is not required but if it is invalid then still throw error.
const noAuthProvided = error && error.message === NO_AUTH_PROVIDED_MESSAGE;
const noAuthProvided = error && error.message.includes(NO_AUTH_PROVIDED_MESSAGE);
if (user || noAuthProvided || !error) {
return user || null;
} else {
@@ -18,22 +18,39 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const request = context.switchToHttp().getRequest<Request & { organization?: Team }>();
const organizationId: string = request.params.orgId;
if (!organizationId) {
throw new ForbiddenException("No organization id found in request params.");
throw new ForbiddenException("IsAdminAPIEnabledGuard - No organization id found in request params.");
}
const { canAccess, organization } = await this.checkAdminAPIEnabled(organizationId);
if (organization) {
request.organization = organization;
}
if (!canAccess) {
throw new ForbiddenException(
`IsAdminAPIEnabledGuard - Organization with id=${organizationId} does not have Admin API access. Please contact https://cal.com/sales to upgrade.`
);
}
return true;
}
async checkAdminAPIEnabled(
organizationId: string
): Promise<{ canAccess: boolean; organization?: Team | null }> {
let canAccess = false;
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isAdminAccess`;
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (cachedData) {
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
request.organization = cachedOrg;
return cachedCanAccess;
return {
canAccess: cachedCanAccess,
organization: cachedOrg,
};
}
}
@@ -45,18 +62,21 @@ export class IsAdminAPIEnabledGuard implements CanActivate {
);
if (!adminAPIAccessIsEnabledInOrg) {
throw new ForbiddenException(
`Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
`IsAdminAPIEnabledGuard - Organization does not have Admin API access, please contact https://cal.com/sales to upgrade`
);
}
}
canAccess = true;
org &&
(await this.redisService.redis.set(
if (org && canAccess) {
await this.redisService.redis.set(
REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData),
"EX",
300
));
return canAccess;
);
}
return { canAccess, organization: org };
}
}
@@ -1,11 +1,5 @@
import { ManagedOrganizationsRepository } from "@/modules/organizations/organizations/managed-organizations.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@@ -20,11 +14,11 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
const managerOrgId: string = request.params.orgId;
if (!managerOrgId) {
throw new ForbiddenException("No manager org id found in request params.");
throw new ForbiddenException("IsManagedOrgInManagerOrg - No manager org id found in request params.");
}
if (!managedOrgId) {
throw new ForbiddenException("No managed org id found in request params.");
throw new ForbiddenException("IsManagedOrgInManagerOrg - No managed org id found in request params.");
}
const managedOrganization = await this.managedOrganizationsRepository.getByManagerManagedIds(
@@ -33,8 +27,8 @@ export class IsManagedOrgInManagerOrg implements CanActivate {
);
if (!managedOrganization) {
throw new NotFoundException(
`Managed organization with id=${managedOrgId} within manager organization with id=${managerOrgId} not found.`
throw new ForbiddenException(
`IsManagedOrgInManagerOrg - Managed organization with id=${managedOrgId} is not owned by manager organization with id=${managerOrgId}.`
);
}
@@ -18,41 +18,58 @@ export class IsOrgGuard implements CanActivate {
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const organizationId: string = request.params.orgId;
if (!organizationId) {
throw new ForbiddenException("No organization id found in request params.");
throw new ForbiddenException("IsOrgGuard - No organization id found in request params.");
}
const { canAccess, org } = await this.checkOrgAccess(organizationId);
if (canAccess && org) {
request.organization = org;
}
if (!canAccess) {
throw new ForbiddenException(
`IsOrgGuard - provided organization id=${organizationId} does not represent any existing organization.`
);
}
return true;
}
async checkOrgAccess(organizationId: string): Promise<{ canAccess: boolean; org?: Team | null }> {
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
let canAccess = false;
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (cachedData) {
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
request.organization = cachedOrg;
return cachedCanAccess;
return {
org: cachedOrg,
canAccess: cachedCanAccess,
};
}
}
const org = await this.organizationsRepository.findById(Number(organizationId));
if (org?.isOrganization) {
request.organization = org;
canAccess = true;
}
if (org) {
if (org && canAccess) {
await this.redisService.redis.set(
REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData),
JSON.stringify({ org, canAccess } satisfies CachedData),
"EX",
300
);
}
return canAccess;
return { canAccess, org };
}
}
@@ -26,10 +26,10 @@ export class IsWebhookInOrg implements CanActivate {
const organizationId: string = request.params.orgId;
if (!organizationId) {
throw new ForbiddenException("No organization id found in request params.");
throw new ForbiddenException("IsWebhookInOrg - No organization id found in request params.");
}
if (!webhookId) {
throw new ForbiddenException("No webhook id found in request params.");
throw new ForbiddenException("IsWebhookInOrg - No webhook id found in request params.");
}
const REDIS_CACHE_KEY = `apiv2:org:${webhookId}:guard:isWebhookInOrg`;
@@ -53,7 +53,7 @@ export class IsWebhookInOrg implements CanActivate {
if (isWebhookInOrg) canAccess = true;
}
if (org) {
if (org && canAccess) {
await this.redisService.redis.set(
REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData),
@@ -62,6 +62,12 @@ export class IsWebhookInOrg implements CanActivate {
);
}
return canAccess;
if (!canAccess) {
throw new ForbiddenException(
`IsWebhookInOrg - webhook with id=${webhookId} is not part of the organization with id=${organizationId}.`
);
}
return true;
}
}
@@ -1,4 +1,5 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { createMock } from "@golevelup/ts-jest";
import { ExecutionContext } from "@nestjs/common";
@@ -28,7 +29,21 @@ describe("PermissionsGuard", () => {
}
}),
}),
createMock<OAuthClientRepository>()
createMock<OAuthClientRepository>(),
createMock<OAuthClientsOutputService>({
transformOAuthClientPermission: jest.fn().mockImplementation((permission: number) => {
switch (permission) {
case SCHEDULE_WRITE:
return "SCHEDULE_WRITE";
case SCHEDULE_READ:
return "SCHEDULE_READ";
case APPS_WRITE:
return "APPS_WRITE";
default:
return "UNKNOWN";
}
}),
})
);
});
@@ -36,13 +51,22 @@ describe("PermissionsGuard", () => {
expect(guard).toBeDefined();
});
function getMockOAuthClient(permissions: number) {
return {
id: "100",
permissions,
};
}
describe("when access token is missing", () => {
it("should return false", async () => {
const mockContext = createMockExecutionContext({});
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(0);
jest.spyOn(guard, "getOAuthClientByAccessToken").mockResolvedValue(getMockOAuthClient(0));
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
);
});
});
@@ -53,7 +77,9 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -64,7 +90,9 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -75,7 +103,9 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -85,9 +115,13 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
);
});
it("should return false for a missing permission", async () => {
@@ -96,9 +130,13 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsByAccessToken").mockResolvedValue(oAuthClientPermissions);
jest
.spyOn(guard, "getOAuthClientByAccessToken")
.mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
);
});
});
@@ -109,7 +147,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -120,7 +158,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
oAuthClientPermissions |= SCHEDULE_READ;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -131,7 +169,7 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(true);
});
@@ -141,9 +179,11 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= APPS_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE"
);
});
it("should return false for a missing permission", async () => {
@@ -152,9 +192,11 @@ describe("PermissionsGuard", () => {
let oAuthClientPermissions = 0;
oAuthClientPermissions |= SCHEDULE_WRITE;
jest.spyOn(guard, "getOAuthClientPermissionsById").mockResolvedValue(oAuthClientPermissions);
jest.spyOn(guard, "getOAuthClientById").mockResolvedValue(getMockOAuthClient(oAuthClientPermissions));
await expect(guard.canActivate(mockContext)).resolves.toBe(false);
await expect(guard.canActivate(mockContext)).rejects.toThrow(
"PermissionsGuard - oAuth client with id=100 does not have the required permissions=SCHEDULE_WRITE, SCHEDULE_READ"
);
});
});
@@ -1,14 +1,16 @@
import { isApiKey } from "@/lib/api-key";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Reflector } from "@nestjs/core";
import { getToken } from "next-auth/jwt";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { hasPermissions } from "@calcom/platform-utils";
import { PlatformOAuthClient } from "@calcom/prisma/client";
@Injectable()
export class PermissionsGuard implements CanActivate {
@@ -16,7 +18,8 @@ export class PermissionsGuard implements CanActivate {
private reflector: Reflector,
private tokensRepository: TokensRepository,
private readonly config: ConfigService,
private readonly oAuthClientRepository: OAuthClientRepository
private readonly oAuthClientRepository: OAuthClientRepository,
private readonly oAuthClientsOutputService: OAuthClientsOutputService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
@@ -27,42 +30,61 @@ export class PermissionsGuard implements CanActivate {
}
const request = context.switchToHttp().getRequest();
const authString = request.get("Authorization")?.replace("Bearer ", "");
const bearerToken = request.get("Authorization")?.replace("Bearer ", "");
const nextAuthSecret = this.config.get("next.authSecret", { infer: true });
const nextAuthToken = await getToken({ req: request, secret: nextAuthSecret });
const oAuthClientId = request.params?.clientId || request.get(X_CAL_CLIENT_ID);
const apiKey = bearerToken && isApiKey(bearerToken, this.config.get("api.apiKeyPrefix") ?? "cal_");
if (nextAuthToken) {
// only check permissions for accessTokens attached to an oAuth Client or oAuth credentials, not for next token or api key
if (nextAuthToken || apiKey) {
return true;
}
if (!authString && !oAuthClientId) {
return false;
if (!bearerToken && !oAuthClientId) {
throw new ForbiddenException(
"PermissionsGuard - no authentication provided. Provide either authorization bearer token containing managed user access token or oAuth client id in 'x-cal-client-id' header."
);
}
// only check permissions for accessTokens attached to an oAuth Client
if (isApiKey(authString, this.config.get("api.apiKeyPrefix") ?? "cal_")) {
return true;
const oAuthClient = bearerToken
? await this.getOAuthClientByAccessToken(bearerToken)
: await this.getOAuthClientById(oAuthClientId);
const hasRequiredPermissions = hasPermissions(oAuthClient.permissions, [...requiredPermissions]);
if (!hasRequiredPermissions) {
throw new ForbiddenException(
`PermissionsGuard - oAuth client with id=${
oAuthClient.id
} does not have the required permissions=${requiredPermissions
.map((permission) => this.oAuthClientsOutputService.transformOAuthClientPermission(permission))
.join(
", "
)}. Go to platform dashboard settings and add the required permissions to the oAuth client.`
);
}
const oAuthClientPermissions = authString
? await this.getOAuthClientPermissionsByAccessToken(authString)
: await this.getOAuthClientPermissionsById(oAuthClientId);
if (!oAuthClientPermissions) {
return false;
}
return hasPermissions(oAuthClientPermissions, [...requiredPermissions]);
return true;
}
async getOAuthClientPermissionsByAccessToken(accessToken: string) {
async getOAuthClientByAccessToken(
accessToken: string
): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
const oAuthClient = await this.tokensRepository.getAccessTokenClient(accessToken);
return oAuthClient?.permissions;
if (!oAuthClient) {
throw new ForbiddenException(
`PermissionsGuard - no oAuth client found for access token=${accessToken}`
);
}
return oAuthClient;
}
async getOAuthClientPermissionsById(id: string) {
async getOAuthClientById(id: string): Promise<Pick<PlatformOAuthClient, "id" | "permissions">> {
const oAuthClient = await this.oAuthClientRepository.getOAuthClient(id);
return oAuthClient?.permissions;
if (!oAuthClient) {
throw new ForbiddenException(`PermissionsGuard - no oAuth client found for client id=${id}`);
}
return oAuthClient;
}
}
@@ -24,13 +24,41 @@ export class RolesGuard implements CanActivate {
const orgId = request.params.orgId as string;
const user = request.user as ApiAuthGuardUser;
const allowedRole = this.reflector.get(Roles, context.getHandler());
const { canAccess } = await this.checkUserRoleAccess(user, orgId, teamId, allowedRole);
if (!canAccess) {
this.throwForbiddenError(user, orgId, teamId, allowedRole);
}
return true;
}
throwForbiddenError(user: ApiAuthGuardUser, orgId: string, teamId: string, allowedRole: string) {
let errorMessage = `RolesGuard - user with id=${user.id} does not have the minimum required role=${allowedRole} within`;
if (orgId) {
errorMessage += ` organization with id=${orgId}`;
}
if (teamId) {
errorMessage += ` team with id=${teamId}`;
}
errorMessage += `.`;
throw new ForbiddenException(errorMessage);
}
async checkUserRoleAccess(
user: ApiAuthGuardUser,
orgId: string,
teamId: string,
allowedRole: string
): Promise<{ canAccess: boolean }> {
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:roles:${allowedRole}`;
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return cachedAccess;
return { canAccess: cachedAccess };
}
let canAccess = false;
@@ -58,7 +86,9 @@ export class RolesGuard implements CanActivate {
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not a member of the organization.`);
throw new ForbiddenException(
`RolesGuard - User is not a member of the organization with id=${orgId}.`
);
}
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
@@ -75,7 +105,7 @@ export class RolesGuard implements CanActivate {
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
throw new ForbiddenException(`User is not a member of the team.`);
throw new ForbiddenException(`RolesGuard - User is not a member of the team with id=${teamId}.`);
}
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
canAccess = hasMinimumRole({
@@ -93,7 +123,7 @@ export class RolesGuard implements CanActivate {
if (!orgMembership) {
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not part of the organization.`);
throw new ForbiddenException(`RolesGuard - User is not part of the organization with id=${orgId}.`);
}
// if the role checked is a TEAM role
@@ -107,7 +137,7 @@ export class RolesGuard implements CanActivate {
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
);
throw new ForbiddenException(
"User is not part of the team and/or, is not an admin nor an owner of the organization."
`RolesGuard - User is not part of the team with id=${teamId} and/or, is not an admin nor an owner of the organization with id=${orgId}.`
);
}
@@ -129,8 +159,12 @@ export class RolesGuard implements CanActivate {
});
}
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
return canAccess;
if (canAccess) {
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
}
return { canAccess };
}
}
@@ -156,7 +190,7 @@ export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
// minimum role given does not exist
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
throw new Error("Invalid role");
throw new Error("RolesGuard - Invalid role");
}
return checkedRoleIndex <= requiredRoleIndex;

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