Compare commits

..
108 Commits
Author SHA1 Message Date
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
506 changed files with 16083 additions and 4321 deletions
+2
View File
@@ -441,3 +441,5 @@ 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=
+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)
@@ -1,3 +1,5 @@
# deprecated - use smtp with tasker instead */
name: Cron - scheduleEmailReminders
on:
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@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"
+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).
+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) {
+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,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"
+1 -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.166",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.172",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -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) {
@@ -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,10 @@ 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:
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.
`,
})
@ApiBody({
@@ -161,9 +168,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 +256,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 +278,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 +299,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 +327,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 +348,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,
@@ -591,18 +591,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 () => {
@@ -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";
@@ -66,6 +66,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 +129,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
@@ -586,6 +588,65 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
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 possibily recurring bookings"
);
}
});
});
it("should get 1 recurrence of a recurring booking", async () => {
const recurrenceUid = createdRecurringBooking[0].uid;
return request(app.getHttpServer())
@@ -1604,6 +1665,135 @@ 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(
"Either eventTypeId OR (eventTypeSlug + username) must be provided"
)
).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";
@@ -45,7 +45,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 }[];
@@ -61,6 +61,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");
@@ -80,27 +82,43 @@ export class BookingsService_2024_08_13 {
async createBooking(request: Request, body: CreateBookingInput) {
try {
if ("instant" in body && body.instant) {
return await this.createInstantBooking(request, body);
const eventType = await this.getBookedEventType(body);
if (!eventType) {
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.`
);
}
throw new NotFoundException(`Event type with id ${body.eventTypeId} not found.`);
}
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") {
@@ -111,6 +129,22 @@ export class BookingsService_2024_08_13 {
}
}
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
);
}
return null;
}
async hasRequiredBookingFieldsResponses(body: CreateBookingInput, eventType: EventType | null) {
const bookingFields = body.bookingFieldsResponses;
if (!bookingFields || !eventType || !eventType.bookingFields) {
@@ -133,8 +167,12 @@ export class BookingsService_2024_08_13 {
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
) {
const bookingRequest = await this.inputService.createBookingRequest(request, body, eventType);
const booking = await handleInstantMeeting(bookingRequest);
const databaseBooking = await this.bookingsRepository.getByIdWithAttendeesAndUserAndEvent(
@@ -147,24 +185,52 @@ 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");
@@ -178,9 +244,18 @@ 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);
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");
@@ -228,7 +303,11 @@ 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);
}
@@ -237,7 +316,10 @@ export class BookingsService_2024_08_13 {
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,
@@ -324,7 +406,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");
}
@@ -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;
}
}
@@ -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()
@@ -111,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 } });
}
+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)}`
);
}
}
}
+5 -3
View File
@@ -54,12 +54,13 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
const { context } = requestProps;
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 (tracker.startsWith("api_key_")) {
@@ -209,6 +210,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 +225,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}`);
@@ -2,6 +2,7 @@ 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";
@@ -19,6 +20,7 @@ import { Module } from "@nestjs/common";
providers: [
EventTypesAtomService,
ConferencingAtomsService,
AttributesAtomsService,
MembershipsRepository,
CredentialsRepository,
UsersRepository,
@@ -3,6 +3,8 @@ 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";
@@ -34,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
@@ -50,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")
@@ -222,4 +227,25 @@ export class AtomsController {
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: 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,
})),
};
}
}
@@ -4,7 +4,10 @@ import { BillingModule } from "@/modules/billing/billing.module";
import { ConferencingModule } from "@/modules/conferencing/conferencing.module";
import { DestinationCalendarsModule } from "@/modules/destination-calendars/destination-calendars.module";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { OrganizationsBookingsModule } from "@/modules/organizations/bookings/organizations.bookings.module";
import { OrganizationsRoutingFormsModule } from "@/modules/organizations/routing-forms/organizations-routing-forms.module";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
import { RouterModule } from "@/modules/router/router.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TimezoneModule } from "@/modules/timezones/timezones.module";
@@ -27,6 +30,9 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
StripeModule,
ConferencingModule,
OrganizationsTeamsBookingsModule,
OrganizationsUsersBookingsModule,
OrganizationsBookingsModule,
OrganizationsRoutingFormsModule,
RouterModule,
],
})
@@ -208,6 +208,10 @@ describe("OAuth Client Users Endpoints", () => {
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
bio: "I am a bio",
metadata: {
key: "value",
},
};
const response = await request(app.getHttpServer())
@@ -231,6 +235,8 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.user.avatarUrl).toEqual(requestBody.avatarUrl);
expect(responseBody.data.user.bio).toEqual(requestBody.bio);
expect(responseBody.data.user.metadata).toEqual(requestBody.metadata);
const [emailUser, emailDomain] = responseBody.data.user.email.split("@");
const [domainName, TLD] = emailDomain.split(".");
expect(responseBody.data.user.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`));
@@ -272,6 +278,10 @@ describe("OAuth Client Users Endpoints", () => {
locale: Locales.FR,
name: "Bob Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
bio: "I am a bio",
metadata: {
key: "value",
},
};
const response = await request(app.getHttpServer())
@@ -333,6 +343,10 @@ describe("OAuth Client Users Endpoints", () => {
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
bio: "I am a bio",
metadata: {
key: "value",
},
};
const response = await request(app.getHttpServer())
@@ -479,6 +493,10 @@ describe("OAuth Client Users Endpoints", () => {
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
});
it(`/GET: managed user by original email`, async () => {
@@ -496,6 +514,8 @@ describe("OAuth Client Users Endpoints", () => {
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
expect(userOne?.email).toEqual(postResponseData.user.email);
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
});
it(`/GET: managed users by original emails`, async () => {
@@ -518,6 +538,10 @@ describe("OAuth Client Users Endpoints", () => {
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
});
it(`/GET: managed user by oAuth email`, async () => {
@@ -540,6 +564,8 @@ describe("OAuth Client Users Endpoints", () => {
const userOne = responseBody.data.find((user) => user.email === postResponseData.user.email);
expect(userOne?.email).toEqual(postResponseData.user.email);
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
});
it(`should error /GET if managed user email is invalid`, async () => {
@@ -576,6 +602,10 @@ describe("OAuth Client Users Endpoints", () => {
expect(userOne?.name).toEqual(postResponseData.user.name);
expect(userTwo?.email).toEqual(postResponseDataTwo.user.email);
expect(userTwo?.name).toEqual(postResponseDataTwo.user.name);
expect(userOne?.bio).toEqual(postResponseData.user.bio);
expect(userOne?.metadata).toEqual(postResponseData.user.metadata);
expect(userTwo?.bio).toEqual(postResponseDataTwo.user.bio);
expect(userTwo?.metadata).toEqual(postResponseDataTwo.user.metadata);
});
it(`/GET/:id`, async () => {
@@ -592,6 +622,9 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.email).toEqual(
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userEmail)
);
expect(responseBody.data.name).toEqual(postResponseData.user.name);
expect(responseBody.data.bio).toEqual(postResponseData.user.bio);
expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata);
});
it(`/PUT/:id`, async () => {
@@ -612,6 +645,9 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.email).toEqual(
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, userUpdatedEmail)
);
expect(responseBody.data.name).toEqual(postResponseData.user.name);
expect(responseBody.data.bio).toEqual(postResponseData.user.bio);
expect(responseBody.data.metadata).toEqual(postResponseData.user.metadata);
const [emailUser, emailDomain] = responseBody.data.email.split("@");
const [domainName, TLD] = emailDomain.split(".");
expect(responseBody.data.username).toEqual(slugify(`${emailUser}-${domainName}-${TLD}`));
@@ -12,6 +12,7 @@ import { TOKENS_DOCS } from "@/modules/oauth-clients/controllers/oauth-flow/oaut
import { KeysResponseDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
@@ -34,6 +35,7 @@ import {
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
import { User, MembershipRole } from "@prisma/client";
import { plainToInstance } from "class-transformer";
import { SUCCESS_STATUS, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
@@ -55,7 +57,8 @@ export class OAuthClientUsersController {
private readonly userRepository: UsersRepository,
private readonly oAuthClientUsersService: OAuthClientUsersService,
private readonly oauthRepository: OAuthClientRepository,
private readonly tokensRepository: TokensRepository
private readonly tokensRepository: TokensRepository,
private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService
) {}
@Get("/")
@@ -70,7 +73,7 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: managedUsers.map((user) => this.getResponseUser(user)),
data: managedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)),
};
}
@@ -94,8 +97,11 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: {
user: this.getResponseUser(user),
...tokens,
user: this.oAuthClientUsersOutputService.getResponseUser(user),
accessToken: tokens.accessToken,
accessTokenExpiresAt: tokens.accessTokenExpiresAt.valueOf(),
refreshToken: tokens.refreshToken,
refreshTokenExpiresAt: tokens.refreshTokenExpiresAt.valueOf(),
},
};
}
@@ -112,7 +118,7 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: this.getResponseUser(user),
data: this.oAuthClientUsersOutputService.getResponseUser(user),
};
}
@@ -132,7 +138,7 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: this.getResponseUser(user),
data: this.oAuthClientUsersOutputService.getResponseUser(user),
};
}
@@ -151,7 +157,7 @@ export class OAuthClientUsersController {
return {
status: SUCCESS_STATUS,
data: this.getResponseUser(user),
data: this.oAuthClientUsersOutputService.getResponseUser(user),
};
}
@@ -168,16 +174,19 @@ export class OAuthClientUsersController {
): Promise<KeysResponseDto> {
this.logger.log(`Forcing new access tokens for managed user with ID ${userId}`);
await this.validateManagedUserOwnership(oAuthClientId, userId);
const { id } = await this.validateManagedUserOwnership(oAuthClientId, userId);
const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens(
userId,
oAuthClientId
);
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
await this.tokensRepository.createOAuthTokens(oAuthClientId, id, true);
return {
status: SUCCESS_STATUS,
data: this.oAuthClientUsersService.getResponseOAuthTokens(accessToken, refreshToken),
data: {
accessToken,
refreshToken,
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
},
};
}
@@ -189,20 +198,4 @@ export class OAuthClientUsersController {
return user;
}
private getResponseUser(user: User): ManagedUserOutput {
return {
id: user.id,
email: user.email,
username: user.username,
name: user.name,
timeZone: user.timeZone,
weekStart: user.weekStart,
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
locale: user.locale as Locales,
avatarUrl: user.avatarUrl,
};
}
}
@@ -1,40 +1,54 @@
import { Locales } from "@/lib/enums/locales";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsEnum, IsOptional, IsUrl } from "class-validator";
import { Expose, Transform } from "class-transformer";
import { IsEnum, IsObject, IsOptional, IsUrl } from "class-validator";
export class ManagedUserOutput {
@ApiProperty({ example: 1 })
@Expose()
id!: number;
@ApiProperty({ example: "alice+cluo37fwd0001khkzqqynkpj3@example.com" })
@Expose()
email!: string;
@ApiProperty({ example: "alice", nullable: true })
@Expose()
username!: string | null;
@ApiProperty({ example: "alice", nullable: true })
@Expose()
name!: string | null;
@ApiProperty({ example: "bio", nullable: true })
@Expose()
bio!: string | null;
@ApiProperty({ example: "America/New_York" })
@Expose()
timeZone!: string;
@ApiProperty({ example: "Sunday" })
@Expose()
weekStart!: string;
@ApiProperty({ type: String, example: "2024-04-01T00:00:00.000Z" })
@Transform(({ value }) => value.toISOString())
@Expose()
createdDate!: Date;
@ApiProperty({ type: Number, example: 12, nullable: true })
@Expose()
timeFormat!: number | null;
@ApiProperty({ type: Number, example: null, nullable: true })
@Expose()
defaultScheduleId!: number | null;
@IsEnum(Locales)
@IsOptional()
@ApiPropertyOptional({ type: String, example: Locales.EN, enum: Locales })
@Expose()
locale?: Locales;
@IsUrl()
@@ -45,5 +59,21 @@ export class ManagedUserOutput {
description: `URL of the user's avatar image`,
nullable: true,
})
@Expose()
avatarUrl?: string | null;
@ApiPropertyOptional({
type: Object,
example: { key: "value" },
})
@IsObject()
@IsOptional()
@Expose()
@Transform(
// note(Lauris): added this transform because without it metadata is removed for some reason
({ obj }: { obj: { metadata: Record<string, unknown> | null | undefined } }) => {
return obj.metadata || undefined;
}
)
metadata?: Record<string, string | boolean | number>;
}
@@ -10,6 +10,8 @@ import { CreateOAuthClientResponseDto } from "@/modules/oauth-clients/controller
import { GetOAuthClientResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientResponse.dto";
import { GetOAuthClientsResponseDto } from "@/modules/oauth-clients/controllers/oauth-clients/responses/GetOAuthClientsResponse.dto";
import { OAuthClientGuard } from "@/modules/oauth-clients/guards/oauth-client-guard";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
import { OAuthClientsService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { UsersRepository } from "@/modules/users/users.repository";
@@ -51,6 +53,7 @@ export class OAuthClientsController {
private readonly logger = new Logger("OAuthClientController");
constructor(
private readonly oAuthClientUsersOutputService: OAuthClientUsersOutputService,
private readonly oAuthClientsService: OAuthClientsService,
private readonly userRepository: UsersRepository,
private readonly teamsRepository: OrganizationsRepository
@@ -124,7 +127,10 @@ export class OAuthClientsController {
limit ?? 50
);
return { status: SUCCESS_STATUS, data: existingManagedUsers.map((user) => this.getResponseUser(user)) };
return {
status: SUCCESS_STATUS,
data: existingManagedUsers.map((user) => this.oAuthClientUsersOutputService.getResponseUser(user)),
};
}
@Patch("/:clientId")
@@ -151,18 +157,4 @@ export class OAuthClientsController {
const client = await this.oAuthClientsService.deleteOAuthClient(clientId);
return { status: SUCCESS_STATUS, data: client };
}
private getResponseUser(user: User): ManagedUserOutput {
return {
id: user.id,
email: user.email,
username: user.username,
name: user.name,
timeZone: user.timeZone,
weekStart: user.weekStart,
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
};
}
}
@@ -18,6 +18,7 @@ import * as request from "supertest";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withNextAuth } from "test/utils/withNextAuth";
@@ -204,12 +205,14 @@ describe("OAuthFlow Endpoints", () => {
expect(decodedAccessToken.clientId).toBe(oAuthClient.id);
expect(decodedAccessToken.ownerId).toBe(user.id);
expect(decodedAccessToken.userId).toBe(user.id);
expect(decodedAccessToken.type).toBe("access_token");
expect(decodedAccessToken.expiresAt).toBe(new Date(accessTokenExpiresAt).valueOf());
expect(decodedAccessToken.iat).toBeGreaterThan(0);
expect(decodedRefreshToken.clientId).toBe(oAuthClient.id);
expect(decodedRefreshToken.ownerId).toBe(user.id);
expect(decodedRefreshToken.userId).toBe(user.id);
expect(decodedRefreshToken.type).toBe("refresh_token");
expect(decodedRefreshToken.expiresAt).toBe(new Date(refreshTokenExpiresAt).valueOf());
expect(decodedRefreshToken.iat).toBeGreaterThan(0);
@@ -94,7 +94,7 @@ export class OAuthFlowController {
throw new BadRequestException("Missing 'Bearer' Authorization header.");
}
const tokens = await this.oAuthFlowService.exchangeOAuthClientAuthorizationToken(
const tokens = await this.oAuthFlowService.exchangeAuthorizationToken(
authorizeEndpointCode,
clientId,
body.clientSecret
@@ -125,7 +125,7 @@ export class OAuthFlowController {
@Headers(X_CAL_SECRET_KEY) secretKey: string,
@Body() body: RefreshTokenInput
): Promise<KeysResponseDto> {
const tokens = await this.oAuthFlowService.refreshUserTokens(clientId, secretKey, body.refreshToken);
const tokens = await this.oAuthFlowService.refreshToken(clientId, secretKey, body.refreshToken);
return {
status: SUCCESS_STATUS,
@@ -11,6 +11,7 @@ import { OAuthClientUsersController } from "@/modules/oauth-clients/controllers/
import { OAuthClientsController } from "@/modules/oauth-clients/controllers/oauth-clients/oauth-clients.controller";
import { OAuthFlowController } from "@/modules/oauth-clients/controllers/oauth-flow/oauth-flow.controller";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersOutputService } from "@/modules/oauth-clients/services/oauth-clients-users-output.service";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthClientsInputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-input.service";
import { OAuthClientsOutputService } from "@/modules/oauth-clients/services/oauth-clients/oauth-clients-output.service";
@@ -58,8 +59,9 @@ import { JwtService } from "@nestjs/jwt";
OAuthClientsInputService,
OAuthClientsOutputService,
JwtService,
OAuthClientUsersOutputService,
],
controllers: [OAuthClientUsersController, OAuthClientsController, OAuthFlowController],
exports: [OAuthClientRepository],
exports: [OAuthClientRepository, OAuthClientUsersOutputService],
})
export class OAuthClientModule {}
@@ -0,0 +1,11 @@
import { ManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/managed-user.output";
import { Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { plainToInstance } from "class-transformer";
@Injectable()
export class OAuthClientUsersOutputService {
getResponseUser(user: User) {
return plainToInstance(ManagedUserOutput, user, { strategy: "excludeAll" });
}
}
@@ -3,13 +3,12 @@ import { EventTypesService_2024_04_15 } from "@/ee/event-types/event-types_2024_
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { Locales } from "@/lib/enums/locales";
import { GetManagedUsersInput } from "@/modules/oauth-clients/controllers/oauth-client-users/inputs/get-managed-users.input";
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UpdateManagedUserInput } from "@/modules/users/inputs/update-managed-user.input";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ConflictException, Injectable, Logger } from "@nestjs/common";
import { User, CreationSource, PlatformOAuthClient, AccessToken, RefreshToken } from "@prisma/client";
import { User, CreationSource, PlatformOAuthClient } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries";
@@ -43,7 +42,7 @@ export class OAuthClientUsersService {
);
} else {
const email = OAuthClientUsersService.getOAuthUserEmail(oAuthClientId, body.email);
user = (
const createdUser = (
await createNewUsersConnectToOrgIfExists({
invitations: [
{
@@ -69,21 +68,18 @@ export class OAuthClientUsersService {
language: body.locale ?? Locales.EN,
})
)[0];
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
const updatedUser = await this.userRepository.update(user.id, {
await this.userRepository.addToOAuthClient(createdUser.id, oAuthClientId);
user = await this.userRepository.update(createdUser.id, {
name: body.name,
locale: body.locale,
avatarUrl: body.avatarUrl,
bio: body.bio,
metadata: body.metadata,
});
user.locale = updatedUser.locale;
user.name = updatedUser.name;
user.avatarUrl = updatedUser.avatarUrl;
}
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
user.id,
oAuthClientId
);
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
await this.tokensRepository.createOAuthTokens(oAuthClientId, user.id);
if (oAuthClient.areDefaultEventTypesEnabled) {
await this.eventTypesService.createUserDefaultEventTypes(user.id);
@@ -103,16 +99,12 @@ export class OAuthClientUsersService {
return {
user,
tokens: this.getResponseOAuthTokens(accessToken, refreshToken),
};
}
getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto {
return {
accessToken: accessToken.secret,
refreshToken: refreshToken.secret,
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
tokens: {
accessToken,
accessTokenExpiresAt,
refreshToken,
refreshTokenExpiresAt,
},
};
}
@@ -1,5 +1,8 @@
import { TokenExpiredException } from "@/modules/auth/guards/api-auth/token-expired.exception";
import { KeysDto } from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import {
KeysDto,
KeysResponseDto,
} from "@/modules/oauth-clients/controllers/oauth-flow/responses/KeysResponse.dto";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
@@ -7,7 +10,6 @@ import { BadRequestException, Injectable, Logger, UnauthorizedException } from "
import { DateTime } from "luxon";
import { INVALID_ACCESS_TOKEN } from "@calcom/platform-constants";
import { AccessToken, RefreshToken } from "@calcom/prisma/client";
@Injectable()
export class OAuthFlowService {
@@ -57,8 +59,8 @@ export class OAuthFlowService {
if (!ownerIdFromDb) throw new Error("Invalid Access Token, not present in Redis or DB");
// await in case of race conditions
await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb); // expires in 1 hour
// await in case of race conditions, but void it's return since cache writes shouldn't halt execution.
void (await this.redisService.redis.setex(cacheKey, 3600, ownerIdFromDb)); // expires in 1 hour
return ownerIdFromDb;
}
@@ -83,8 +85,9 @@ export class OAuthFlowService {
}
// we can't use a Promise#all or similar here because we care about execution order
await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() });
await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000));
// however we can't allow caches to fail a validation hence the results are voided.
void (await this.redisService.redis.hmset(cacheKey, { expiresAt: tokenExpiresAt.toJSON() }));
void (await this.redisService.redis.expireat(cacheKey, Math.floor(tokenExpiresAt.getTime() / 1000)));
return true;
}
@@ -100,7 +103,7 @@ export class OAuthFlowService {
return { status: "CACHE_MISS", cacheKey };
}
async exchangeOAuthClientAuthorizationToken(
async exchangeAuthorizationToken(
tokenId: string,
clientId: string,
clientSecret: string
@@ -121,50 +124,46 @@ export class OAuthFlowService {
throw new BadRequestException("Invalid Authorization Token.");
}
const { accessToken, refreshToken } = await this.tokensRepository.createOAuthTokens(
authorizationToken.owner.id,
clientId
);
const { accessToken, refreshToken, accessTokenExpiresAt, refreshTokenExpiresAt } =
await this.tokensRepository.createOAuthTokens(clientId, authorizationToken.owner.id);
await this.tokensRepository.invalidateAuthorizationToken(authorizationToken.id);
await this.propagateAccessToken(accessToken.secret);
void this.propagateAccessToken(accessToken); // void result, ignored.
return this.getResponseOAuthTokens(accessToken, refreshToken);
return {
accessToken,
accessTokenExpiresAt: accessTokenExpiresAt.valueOf(),
refreshToken,
refreshTokenExpiresAt: refreshTokenExpiresAt.valueOf(),
};
}
async refreshUserTokens(
clientId: string,
clientSecret: string,
userRefreshTokenSecret: string
): Promise<KeysDto> {
async refreshToken(clientId: string, clientSecret: string, tokenSecret: string): Promise<KeysDto> {
const oauthClient = await this.oAuthClientRepository.getOAuthClientWithRefreshSecret(
clientId,
clientSecret,
userRefreshTokenSecret
tokenSecret
);
if (!oauthClient) {
throw new BadRequestException("Invalid OAuthClient credentials.");
}
const currentUserRefreshToken = oauthClient.refreshToken[0];
const currentRefreshToken = oauthClient.refreshToken[0];
if (!currentUserRefreshToken) {
throw new BadRequestException("Invalid managed user refresh token");
if (!currentRefreshToken) {
throw new BadRequestException("Invalid refresh token");
}
const { accessToken, refreshToken } = await this.tokensRepository.refreshOAuthTokens(
currentUserRefreshToken.userId,
clientId
clientId,
currentRefreshToken.secret,
currentRefreshToken.userId
);
return this.getResponseOAuthTokens(accessToken, refreshToken);
}
getResponseOAuthTokens(accessToken: AccessToken, refreshToken: RefreshToken): KeysDto {
return {
accessToken: accessToken.secret,
refreshToken: refreshToken.secret,
accessTokenExpiresAt: accessToken.expiresAt.valueOf(),
refreshToken: refreshToken.secret,
refreshTokenExpiresAt: refreshToken.expiresAt.valueOf(),
};
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { ArrayMinSize, IsArray, IsNumber, IsOptional } from "class-validator";
import { GetBookingsInput_2024_08_13 } from "@calcom/platform-types";
export class GetOrganizationsBookingsInput extends GetBookingsInput_2024_08_13 {
@IsArray()
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((userId: string) => parseInt(userId));
}
return value;
})
@IsNumber({}, { each: true })
@ArrayMinSize(1, { message: "userIds must contain at least 1 user id" })
@ApiProperty({
type: String,
required: false,
description: "Filter bookings by ids of users within your organization.",
example: "?userIds=100,200",
})
userIds?: number[];
}
@@ -0,0 +1,703 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_08_13,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations Bookings Endpoints 2024-08-13", () => {
describe("Organization bookings", () => {
let app: INestApplication;
let organization: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const orgUserEmail = "org-user-1-bookings@api.com";
const orgUserEmail2 = "org-user-2-bookings@api.com";
const nonOrgUserEmail1 = "non-org-user-1-bookings@api.com";
let orgUser: User;
let orgUser2: User;
let nonOrgUser1: User;
let team1: Team;
let orgEventTypeId: number;
let orgEventTypeId2: number;
let nonOrgEventTypeId: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
orgUserEmail2,
Test.createTestingModule({
imports: [AppModule, OrganizationsTeamsBookingsModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organization bookings" });
team1 = await teamRepositoryFixture.create({
name: "team orgs booking 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
});
oAuthClient = await createOAuthClient(organization.id);
nonOrgUser1 = await userRepositoryFixture.create({
email: nonOrgUserEmail1,
locale: "it",
name: "NonOrgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
orgUser = await userRepositoryFixture.create({
email: orgUserEmail,
locale: "it",
name: "orgUser1Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
orgUser2 = await userRepositoryFixture.create({
email: orgUserEmail2,
locale: "es",
name: "orgUser2Bookings",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(orgUser.id, userSchedule);
await schedulesService.createUserSchedule(orgUser2.id, userSchedule);
await schedulesService.createUserSchedule(nonOrgUser1.id, userSchedule);
const orgEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "org-bookings-collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
const orgEventType2 = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "ROUND_ROBIN",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "org-bookings-round-robin-event-type",
length: 60,
assignAllTeamMembers: false,
bookingFields: [],
locations: [],
});
orgEventTypeId2 = orgEventType2.id;
await profileRepositoryFixture.create({
uid: `usr-${orgUser.id}`,
username: orgUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: orgUser.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${orgUser2.id}`,
username: orgUserEmail2,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: orgUser2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: orgUser.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: orgUser2.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: orgUser.id } },
team: { connect: { id: team1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: orgUser2.id } },
team: { connect: { id: team1.id } },
accepted: true,
});
const nonOrgEventType = await eventTypesRepositoryFixture.create(
{
title: "Non Org Event Type",
slug: "non-org-event-type",
length: 60,
bookingFields: [],
locations: [],
},
nonOrgUser1.id
);
orgEventTypeId = orgEventType.id;
nonOrgEventTypeId = nonOrgEventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: orgUser.id,
},
},
eventType: {
connect: {
id: orgEventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: orgUser2.id,
},
},
eventType: {
connect: {
id: orgEventType.id,
},
},
});
await hostsRepositoryFixture.create({
isFixed: false,
user: {
connect: {
id: orgUser2.id,
},
},
eventType: {
connect: {
id: orgEventType2.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("create organization bookings", () => {
it("should create an collective organization booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)).toISOString(),
eventTypeId: orgEventTypeId,
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(orgUser.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(orgEventTypeId);
expect(data.attendees.length).toEqual(2);
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"
);
}
});
});
it("should create a round robin organization booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 10, 13, 0, 0)).toISOString(),
eventTypeId: orgEventTypeId2,
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(orgUser2.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 10, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(orgEventTypeId2);
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"
);
}
});
});
it("should create a non organization booking for org-user-1", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: nonOrgEventTypeId,
attendee: {
name: orgUser.name ?? "",
email: orgUserEmail,
timeZone: orgUser.timeZone ?? "Europe/Madrid",
language: "en",
},
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(nonOrgUser1.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(nonOrgEventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: orgUserEmail,
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"
);
}
});
});
it("should create a non organization booking for org-user-2", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 11, 13, 0, 0)).toISOString(),
eventTypeId: nonOrgEventTypeId,
attendee: {
name: orgUser2.name ?? "",
email: orgUserEmail2,
timeZone: orgUser2.timeZone ?? "Europe/Madrid",
language: "en",
},
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(nonOrgUser1.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 11, 14, 0, 0)).toISOString());
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(nonOrgEventTypeId);
expect(data.attendees.length).toEqual(1);
expect(data.attendees[0]).toEqual({
name: body.attendee.name,
email: orgUserEmail2,
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"
);
}
});
});
});
describe("get organization bookings", () => {
it("should get bookings by organizationId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.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(4);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?userIds=${orgUser.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.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(2);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId and userIds", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${organization.id}/bookings?userIds=${orgUser.id},${orgUser2.id}&skip=0&take=250`
)
.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(4);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId and userId", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?userIds=${orgUser2.id}`)
.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(3);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId, orgEventTypeId2, nonOrgEventTypeId].sort()
);
});
});
it("should fail to get bookings by organizationId and Id of a user that does not exist", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?userIds=972930`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
it("should fail to get bookings by organizationId and Id of a user that does not belong to the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?userIds=${nonOrgUser1.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(403);
});
it("should get bookings by organizationId and non org event-type id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?eventTypeIds=${nonOrgEventTypeId}`)
.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(2);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[nonOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
it("should get bookings by organizationId and org event-type id", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/bookings?eventTypeIds=${orgEventTypeId}`)
.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(1);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual([orgEventTypeId].sort());
});
});
it("should get bookings by organizationId and org + non org event-type ids", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${organization.id}/bookings?eventTypeIds=${orgEventTypeId2},${nonOrgEventTypeId}`
)
.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(3);
expect(data.map((booking) => booking.eventTypeId).sort()).toEqual(
[orgEventTypeId2, nonOrgEventTypeId, nonOrgEventTypeId].sort()
);
});
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(orgUser.email);
await userRepositoryFixture.deleteByEmail(orgUser2.email);
await userRepositoryFixture.deleteByEmail(nonOrgUser1.email);
await bookingsRepositoryFixture.deleteAllBookings(orgUser.id, orgUser.email);
await bookingsRepositoryFixture.deleteAllBookings(orgUser2.id, orgUser2.email);
await bookingsRepositoryFixture.deleteAllBookings(nonOrgUser1.id, nonOrgUser1.email);
await app.close();
});
});
});
@@ -0,0 +1,56 @@
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
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 { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
import { GetOrganizationsBookingsInput } from "./inputs/get-org-bookings.input";
@Controller({
path: "/v2/organizations/:orgId/bookings",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Bookings")
export class OrganizationsBookingsController {
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly orgUsersService: OrganizationsUsersService
) {}
@Get("/")
@ApiOperation({ summary: "Get Organization Bookings" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@HttpCode(HttpStatus.OK)
async getAllOrgTeamBookings(
@Query() queryParams: GetOrganizationsBookingsInput,
@Param("orgId", ParseIntPipe) orgId: number,
@GetUser() user: UserWithProfile
): Promise<GetBookingsOutput_2024_08_13> {
const { userIds, ...restParams } = queryParams;
const bookings = await this.bookingsService.getBookings(
{ ...restParams },
{ email: user.email, id: user.id, orgId },
userIds
);
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
}
@@ -0,0 +1,23 @@
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsUsersRepository } from "@/modules/organizations//users/index/organizations-users.repository";
import { OrganizationsBookingsController } from "@/modules/organizations/bookings/organizations-bookings.controller";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
import { OrganizationsUsersService } from "@/modules/organizations/users/index/services/organizations-users-service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
@Module({
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
providers: [
OrganizationsRepository,
OrganizationsTeamsRepository,
OrganizationsUsersService,
OrganizationsUsersRepository,
],
controllers: [OrganizationsBookingsController],
})
export class OrganizationsBookingsModule {}
@@ -56,7 +56,7 @@ export type OAuthCallbackState = {
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@DocsTags("Organizations/Teams Conferencing")
@DocsTags("Orgs / Teams / Conferencing")
export class OrganizationsConferencingController {
constructor(
private readonly conferencingService: ConferencingService,
@@ -1,10 +1,5 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingInput_2024_04_15 } from "@/ee/bookings/2024-04-15/inputs/create-booking.input";
import {
GetBookingsDataEntry,
GetBookingsOutput_2024_04_15,
} from "@/ee/bookings/2024-04-15/outputs/get-bookings.output";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { Locales } from "@/lib/enums/locales";
@@ -15,7 +10,7 @@ import {
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { OrgTeamMembershipOutputResponseDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
@@ -32,18 +27,10 @@ import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import {
CAL_API_VERSION_HEADER,
SUCCESS_STATUS,
VERSION_2024_06_14,
X_CAL_CLIENT_ID,
X_CAL_SECRET_KEY,
} from "@calcom/platform-constants";
import { SUCCESS_STATUS, X_CAL_CLIENT_ID, X_CAL_SECRET_KEY } from "@calcom/platform-constants";
import {
ApiSuccessResponse,
CreateEventTypeInput_2024_06_14,
CreateTeamEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
Host,
OrgTeamOutputDto,
TeamEventTypeOutput_2024_06_14,
@@ -238,7 +225,7 @@ describe("Assign all team members", () => {
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: OrgTeamMembershipOutputResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
});
});
@@ -257,14 +244,14 @@ describe("Assign all team members", () => {
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: OrgTeamMembershipOutputResponseDto = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
});
});
});
describe("should setup event types using assignAllTeamMembers true", () => {
it("should not be able to setup team event type if no hosts nor assignAllTeamMembers provided", async () => {
it("should be able to setup team event type if no hosts nor assignAllTeamMembers provided", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation round robin",
slug: `organizations-event-types-round-robin-${randomString()}`,
@@ -279,7 +266,17 @@ describe("Assign all team members", () => {
.send(body)
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
.set(X_CAL_CLIENT_ID, oAuthClient.id)
.expect(400);
.expect(201);
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.title).toEqual(body.title);
expect(data.hosts).toEqual([]);
expect(data.schedulingType).toEqual("collective");
const eventTypeHosts = await hostsRepositoryFixture.getEventTypeHosts(data.id);
expect(eventTypeHosts.length).toEqual(0);
});
it("should setup collective event type assignAllTeamMembers true", async () => {
@@ -17,7 +17,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_06_14 } from "@calcom/platform-constants";
import {
BookingWindowPeriodInputTypeEnum_2024_06_14,
BookerLayoutsInputEnum_2024_06_14,
@@ -27,6 +27,7 @@ import {
import {
ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
Host,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
@@ -62,6 +63,8 @@ describe("Organizations Event Types Endpoints", () => {
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
let managedEventType: TeamEventTypeOutput_2024_06_14;
const managedEventTypeSlug = `organizations-event-types-managed-${randomString()}`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -101,6 +104,7 @@ describe("Organizations Event Types Endpoints", () => {
org = await organizationsRepositoryFixture.create({
name: `organizations-event-types-organization-${randomString()}`,
isOrganization: true,
slug: `organizations-event-types-organization-${randomString()}`,
});
falseTestOrg = await organizationsRepositoryFixture.create({
@@ -135,6 +139,36 @@ describe("Organizations Event Types Endpoints", () => {
},
});
await profileRepositoryFixture.create({
uid: `usr-${teammate1.id}`,
username: teammate1Email,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: teammate1.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${teammate2.id}`,
username: teammate2Email,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: teammate2.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: userAdmin.id } },
@@ -347,7 +381,7 @@ describe("Organizations Event Types Endpoints", () => {
it("should create a managed team event-type", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation managed",
slug: `organizations-event-types-managed-${randomString()}`,
slug: managedEventTypeSlug,
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
@@ -387,7 +421,9 @@ describe("Organizations Event Types Endpoints", () => {
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate1EventTypes[0].slug).toEqual(managedEventTypeSlug);
expect(teammate2EventTypes.length).toEqual(1);
expect(teammate2EventTypes[0].slug).toEqual(managedEventTypeSlug);
expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual(
1
);
@@ -410,6 +446,42 @@ describe("Organizations Event Types Endpoints", () => {
});
});
it("managed team event types should be returned when fetching event types of users", async () => {
return request(app.getHttpServer())
.get(`/v2/event-types?username=${teammate1.username}&orgSlug=${org.slug}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].slug).toEqual(managedEventTypeSlug);
expect(data[0].ownerId).toEqual(teammate1.id);
expect(data[0].id).not.toEqual(managedEventType.id);
});
});
it("managed team event type should be returned when fetching event types of users", async () => {
return request(app.getHttpServer())
.get(
`/v2/event-types?username=${teammate1.username}&orgSlug=${org.slug}&eventSlug=${managedEventTypeSlug}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(1);
expect(data[0].slug).toEqual(managedEventTypeSlug);
expect(data[0].ownerId).toEqual(teammate1.id);
expect(data[0].id).not.toEqual(managedEventType.id);
});
});
it("should not get an event-type of team outside org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
@@ -108,13 +108,6 @@ export class InputOrganizationsEventTypesService {
teamId: number,
inputEventType: CreateTeamEventTypeInput_2024_06_14
) {
const hasHosts = !!inputEventType.hosts && !!inputEventType.hosts.length;
const hasAssignAllTeamMembers = inputEventType.assignAllTeamMembers === true;
if (!hasHosts && !hasAssignAllTeamMembers) {
throw new BadRequestException("Either hosts must be provided or assignAllTeamMembers must be true");
}
const { hosts, assignAllTeamMembers, locations, ...rest } = inputEventType;
const eventType = this.inputEventTypesService.transformInputCreateEventType(rest);
@@ -2,7 +2,13 @@ import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { UpdateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/update-organization-membership.input";
import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/outputs/create-membership.output";
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
@@ -32,7 +38,7 @@ describe("Organizations Memberships Endpoints", () => {
let org: Team;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: Membership;
let membershipCreatedViaApi: TeamMembershipOutput;
const userEmail = `organizations-memberships-admin-${randomString()}@api.com`;
const userEmail2 = `organizations-memberships-member-${randomString()}@api.com`;
@@ -43,6 +49,11 @@ describe("Organizations Memberships Endpoints", () => {
let userToInviteViaApi: User;
const metadata = {
some: "key",
};
const bio = "This is a bio";
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -58,15 +69,21 @@ describe("Organizations Memberships Endpoints", () => {
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
bio,
metadata,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
bio,
metadata,
});
userToInviteViaApi = await userRepositoryFixture.create({
email: invitedUserEmail,
username: invitedUserEmail,
bio,
metadata,
});
org = await organizationsRepositoryFixture.create({
@@ -104,10 +121,24 @@ describe("Organizations Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/memberships`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
const responseBody: GetAllOrgMemberships = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership.id);
expect(responseBody.data[0].userId).toEqual(user.id);
expect(responseBody.data[0].role).toEqual("ADMIN");
expect(responseBody.data[0].user.bio).toEqual(bio);
expect(responseBody.data[0].user.metadata).toEqual(metadata);
expect(responseBody.data[0].user.email).toEqual(user.email);
expect(responseBody.data[0].user.username).toEqual(user.username);
expect(responseBody.data[0].teamId).toEqual(org.id);
expect(responseBody.data[1].id).toEqual(membership2.id);
expect(responseBody.data[1].userId).toEqual(user2.id);
expect(responseBody.data[1].role).toEqual("MEMBER");
expect(responseBody.data[1].user.bio).toEqual(bio);
expect(responseBody.data[1].user.metadata).toEqual(metadata);
expect(responseBody.data[1].user.email).toEqual(user2.email);
expect(responseBody.data[1].user.username).toEqual(user2.username);
expect(responseBody.data[1].teamId).toEqual(org.id);
});
});
@@ -116,10 +147,16 @@ describe("Organizations Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
const responseBody: GetAllOrgMemberships = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership2.id);
expect(responseBody.data[0].role).toEqual("MEMBER");
expect(responseBody.data[0].user.bio).toEqual(bio);
expect(responseBody.data[0].user.metadata).toEqual(metadata);
expect(responseBody.data[0].user.email).toEqual(user2.email);
expect(responseBody.data[0].user.username).toEqual(user2.username);
expect(responseBody.data[0].userId).toEqual(user2.id);
expect(responseBody.data[0].teamId).toEqual(org.id);
});
});
@@ -132,10 +169,16 @@ describe("Organizations Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/memberships/${membership.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: GetOrgMembership = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membership.id);
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.role).toEqual("ADMIN");
expect(responseBody.data.user.bio).toEqual(bio);
expect(responseBody.data.user.metadata).toEqual(metadata);
expect(responseBody.data.user.email).toEqual(user.email);
expect(responseBody.data.user.username).toEqual(user.username);
expect(responseBody.data.teamId).toEqual(org.id);
});
});
@@ -149,12 +192,16 @@ describe("Organizations Memberships Endpoints", () => {
} satisfies CreateOrgMembershipDto)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: CreateOrgMembershipOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.teamId).toEqual(org.id);
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
expect(membershipCreatedViaApi.user.bio).toEqual(bio);
expect(membershipCreatedViaApi.user.metadata).toEqual(metadata);
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username);
});
});
@@ -166,7 +213,7 @@ describe("Organizations Memberships Endpoints", () => {
} satisfies UpdateOrgMembershipDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: UpdateOrgMembership = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.role).toEqual("OWNER");
@@ -178,7 +225,7 @@ describe("Organizations Memberships Endpoints", () => {
.delete(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
const responseBody: DeleteOrgMembership = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
});
@@ -18,9 +18,9 @@ import { CreateOrgMembershipOutput } from "@/modules/organizations/memberships/o
import { DeleteOrgMembership } from "@/modules/organizations/memberships/outputs/delete-membership.output";
import { GetAllOrgMemberships } from "@/modules/organizations/memberships/outputs/get-all-memberships.output";
import { GetOrgMembership } from "@/modules/organizations/memberships/outputs/get-membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/memberships/outputs/update-membership.output";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
UseGuards,
@@ -71,7 +71,7 @@ export class OrganizationsMembershipsController {
return {
status: SUCCESS_STATUS,
data: memberships.map((membership) =>
plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" })
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
),
};
}
@@ -88,7 +88,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@@ -105,7 +105,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.getOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@@ -122,7 +122,7 @@ export class OrganizationsMembershipsController {
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@@ -144,7 +144,7 @@ export class OrganizationsMembershipsController {
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
}
@@ -1,6 +1,7 @@
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable } from "@nestjs/common";
import { UpdateOrgMembershipDto } from "./inputs/update-organization-membership.input";
@@ -15,6 +16,7 @@ export class OrganizationsMembershipRepository {
id: membershipId,
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -24,6 +26,7 @@ export class OrganizationsMembershipRepository {
teamId: organizationId,
userId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -33,6 +36,7 @@ export class OrganizationsMembershipRepository {
id: membershipId,
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -41,12 +45,14 @@ export class OrganizationsMembershipRepository {
create: { ...data, teamId: organizationId },
update: { role: data.role, accepted: data.accepted, disableImpersonation: data.disableImpersonation },
where: { userId_teamId: { userId: data.userId, teamId: organizationId } },
include: { user: { select: MembershipUserSelect } },
});
}
async updateOrgMembership(organizationId: number, membershipId: number, data: UpdateOrgMembershipDto) {
return this.dbWrite.prisma.membership.update({
data: { ...data },
where: { id: membershipId, teamId: organizationId },
include: { user: { select: MembershipUserSelect } },
});
}
@@ -55,6 +61,7 @@ export class OrganizationsMembershipRepository {
where: {
teamId: organizationId,
},
include: { user: { select: MembershipUserSelect } },
skip,
take,
});
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class CreateOrgMembershipOutput {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgMembershipOutputDto,
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgMembershipOutputDto)
data!: OrgMembershipOutputDto;
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
@@ -9,5 +9,5 @@ export class DeleteOrgMembership {
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data!: OrgMembershipOutputDto;
data!: TeamMembershipOutput;
}
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested, IsArray } from "class-validator";
@@ -11,11 +11,11 @@ export class GetAllOrgMemberships {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgMembershipOutputDto,
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested({ each: true })
@Type(() => OrgMembershipOutputDto)
@Type(() => TeamMembershipOutput)
@IsArray()
data!: OrgMembershipOutputDto[];
data!: TeamMembershipOutput[];
}
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class GetOrgMembership {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgMembershipOutputDto,
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgMembershipOutputDto)
data!: OrgMembershipOutputDto;
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -1,37 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
export class OrgMembershipOutputDto {
@IsInt()
@Expose()
@ApiProperty()
readonly id!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly userId!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly teamId!: number;
@IsBoolean()
@Expose()
@ApiProperty()
readonly accepted!: boolean;
@IsString()
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
@Expose()
readonly role!: MembershipRole;
@IsOptional()
@IsBoolean()
@Expose()
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
}
@@ -1,4 +1,4 @@
import { OrgMembershipOutputDto } from "@/modules/organizations/memberships/outputs/membership.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
@@ -11,10 +11,10 @@ export class UpdateOrgMembership {
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: OrgMembershipOutputDto,
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => OrgMembershipOutputDto)
data!: OrgMembershipOutputDto;
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -37,7 +37,7 @@ import { OrganizationsTeamsService } from "@/modules/organizations/teams/index/s
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms-responses.module";
import { OrganizationsTeamsRoutingFormsModule } from "@/modules/organizations/teams/routing-forms/organizations-teams-routing-forms.module";
import { OrganizationsTeamsSchedulesController } from "@/modules/organizations/teams/schedules/organizations-teams-schedules.controller";
import { OrganizationsUsersController } from "@/modules/organizations/users/index/controllers/organizations-users.controller";
import { OrganizationsUsersRepository } from "@/modules/organizations/users/index/organizations-users.repository";
@@ -0,0 +1,261 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { App_RoutingForms_Form, App_RoutingForms_FormResponse, Team, User } from "@prisma/client";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
describe("OrganizationsRoutingFormsResponsesController", () => {
let app: INestApplication;
let prismaWriteService: PrismaWriteService;
let org: Team;
let team: Team;
let apiKeyString: string;
let routingForm: App_RoutingForms_Form;
let routingFormResponse: App_RoutingForms_FormResponse;
let routingFormResponse2: App_RoutingForms_FormResponse;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let user: User;
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: `OrganizationsRoutingFormsResponsesController-teams-memberships-organization-${randomString()}`,
isOrganization: true,
});
team = await teamRepositoryFixture.create({
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
isOrganization: false,
parent: { connect: { id: org.id } },
});
user = await userRepositoryFixture.create({
email: userEmail,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
const now = new Date();
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `${keyString}`;
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: JSON.stringify([]),
fields: JSON.stringify([]),
settings: JSON.stringify({}),
teamId: team.id,
userId: user.id,
},
});
routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: routingForm.id,
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
},
});
routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: routingForm.id,
response: { question1: "answer1", question2: "answer2" },
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
where: {
formId: routingForm.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await app.close();
});
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
it("should not get routing form responses for non existing org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(403);
});
it("should not get routing form responses for non existing form", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(404);
});
it("should not get routing form responses without authentication", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses`)
.expect(401);
});
it("should get routing form responses", async () => {
return request(app.getHttpServer())
.get(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses?skip=0&take=2&sortUpdatedAt=asc&sortCreatedAt=desc`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const responses = responseBody.data as App_RoutingForms_FormResponse[];
expect(responses).toBeDefined();
expect(responses.length).toBeGreaterThan(0);
expect(responses.find((response) => response.id === routingFormResponse.id)).toBeDefined();
expect(responses.find((response) => response.id === routingFormResponse.id)?.formId).toEqual(
routingFormResponse.formId
);
expect(responses.find((response) => response.id === routingFormResponse2.id)).toBeDefined();
expect(responses.find((response) => response.id === routingFormResponse2.id)?.formId).toEqual(
routingFormResponse2.formId
);
});
});
});
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
it("should not update routing form response for non existing org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/99999/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(403);
});
it("should not update routing form response for non existing form", async () => {
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${routingFormResponse.id}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(404);
});
it("should not update routing form response for non existing response", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/99999`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(404);
});
it("should not update routing form response without authentication", async () => {
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
)
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(401);
});
it("should update routing form response", async () => {
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/${routingForm.id}/responses/${routingFormResponse.id}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ response: updatedResponse })
.expect(200)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.id).toEqual(routingFormResponse.id);
expect(data.formId).toEqual(routingFormResponse.formId);
expect(data.response).toEqual(updatedResponse);
});
});
});
});
@@ -0,0 +1,81 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_HEADER } from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.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 { GetRoutingFormResponsesOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-form-responses.output";
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
import { Body, Controller, Get, Param, Patch, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetRoutingFormResponsesParams } from "../inputs/get-routing-form-responses-params.input";
import { UpdateRoutingFormResponseInput } from "../inputs/update-routing-form-response.input";
import { UpdateRoutingFormResponseOutput } from "../outputs/update-routing-form-response.output";
@Controller({
path: "/v2/organizations/:orgId/routing-forms/:routingFormId/responses",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiTags("Orgs / Routing forms")
@ApiHeader(API_KEY_HEADER)
export class OrganizationsRoutingFormsResponsesController {
constructor(
private readonly organizationsRoutingFormsResponsesService: OrganizationsRoutingFormsResponsesService
) {}
@Get("/")
@ApiOperation({ summary: "Get routing form responses" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async getRoutingFormResponses(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("routingFormId") routingFormId: string,
@Query() queryParams: GetRoutingFormResponsesParams
): Promise<GetRoutingFormResponsesOutput> {
const { skip, take, ...filters } = queryParams;
const responses =
await this.organizationsRoutingFormsResponsesService.getOrganizationRoutingFormResponses(
orgId,
routingFormId,
skip ?? 0,
take ?? 250,
filters
);
return {
status: SUCCESS_STATUS,
data: responses,
};
}
@Patch("/:responseId")
@ApiOperation({ summary: "Update routing form response" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async updateRoutingFormResponse(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("routingFormId") routingFormId: string,
@Param("responseId", ParseIntPipe) responseId: number,
@Body() updateRoutingFormResponseInput: UpdateRoutingFormResponseInput
): Promise<UpdateRoutingFormResponseOutput> {
const updatedResponse = await this.organizationsRoutingFormsResponsesService.updateRoutingFormResponse(
orgId,
routingFormId,
responseId,
updateRoutingFormResponseInput
);
return {
status: SUCCESS_STATUS,
data: updatedResponse,
};
}
}
@@ -0,0 +1,215 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { GetRoutingFormsOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { App_RoutingForms_Form, App_RoutingForms_FormResponse, Team, User } from "@prisma/client";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
describe("OrganizationsRoutingFormController", () => {
let app: INestApplication;
let prismaWriteService: PrismaWriteService;
let org: Team;
let team: Team;
let apiKeyString: string;
let routingForm: App_RoutingForms_Form;
let routingFormResponse: App_RoutingForms_FormResponse;
let routingFormResponse2: App_RoutingForms_FormResponse;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let user: User;
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: `OrganizationsRoutingFormsResponsesController-teams-memberships-organization-${randomString()}`,
isOrganization: true,
});
team = await teamRepositoryFixture.create({
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
isOrganization: false,
parent: { connect: { id: org.id } },
});
user = await userRepositoryFixture.create({
email: userEmail,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `${keyString}`;
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: [{ redirect: "http://google.com" }],
fields: [{ territory: "input" }],
settings: { test: "true" },
teamId: team.id,
userId: user.id,
},
});
routingFormResponse = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: routingForm.id,
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
},
});
routingFormResponse2 = await prismaWriteService.prisma.app_RoutingForms_FormResponse.create({
data: {
formId: routingForm.id,
response: JSON.stringify({ question1: "answer1", question2: "answer2" }),
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await app.close();
});
describe(`GET /v2/organizations/:orgId/routing-forms`, () => {
it("should not get routing forms for non existing org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/99999/routing-forms`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(403);
});
it("should not get routing forms without authentication", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/routing-forms`).expect(401);
});
it("should get organization routing forms", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/routing-forms?skip=0&take=1`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const routingForms = responseBody.data;
expect(routingForms).toBeDefined();
expect(routingForms.length).toBeGreaterThan(0);
expect(routingForms[0].id).toEqual(routingForm.id);
expect(routingForms[0].name).toEqual(routingForm.name);
expect(routingForms[0].description).toEqual(routingForm.description);
expect(routingForms[0].disabled).toEqual(routingForm.disabled);
});
});
it("should filter routing forms by name", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/routing-forms?name=Test`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const routingForms = responseBody.data;
expect(routingForms).toBeDefined();
expect(routingForms.length).toBeGreaterThan(0);
expect(routingForms[0].name).toContain("Test");
});
});
it("should filter routing forms by disabled status", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/routing-forms?disabled=false`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const routingForms = responseBody.data;
expect(routingForms).toBeDefined();
expect(routingForms.length).toBeGreaterThan(0);
expect(routingForms[0].disabled).toEqual(false);
expect(routingForms[0].fields?.[0]).toEqual({ territory: "input" });
expect(routingForms[0].routes?.[0]).toEqual({ redirect: "http://google.com" });
expect(routingForms[0].settings).toEqual({ test: "true" });
});
});
});
});
@@ -0,0 +1,54 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_HEADER } from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.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 { GetRoutingFormsParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
import {
GetRoutingFormsOutput,
RoutingFormOutput,
} from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
import { OrganizationsRoutingFormsService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms.service";
import { Controller, Get, Param, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/organizations/:orgId/routing-forms",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiTags("Orgs / Routing forms")
@ApiHeader(API_KEY_HEADER)
export class OrganizationsRoutingFormsController {
constructor(private readonly organizationsRoutingFormsService: OrganizationsRoutingFormsService) {}
@Get()
@ApiOperation({ summary: "Get organization routing forms" })
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
async getOrganizationRoutingForms(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: GetRoutingFormsParams
): Promise<GetRoutingFormsOutput> {
const { skip, take, ...filters } = queryParams;
const routingForms = await this.organizationsRoutingFormsService.getOrganizationRoutingForms(
orgId,
skip ?? 0,
take ?? 250,
filters
);
return {
status: SUCCESS_STATUS,
data: routingForms.map((form) => plainToClass(RoutingFormOutput, form)),
};
}
}
@@ -0,0 +1,107 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import {
IsOptional,
IsString,
IsEnum,
IsISO8601,
IsDate,
IsNumber,
IsArray,
ArrayMinSize,
} from "class-validator";
enum SortOrder {
ASC = "asc",
DESC = "desc",
}
export class GetRoutingFormResponsesParams {
@ApiPropertyOptional({ type: Number, description: "Number of responses to skip" })
@Transform(({ value }) => value && parseInt(value))
@IsOptional()
skip?: number;
@ApiPropertyOptional({ type: Number, description: "Number of responses to take" })
@Transform(({ value }) => value && parseInt(value))
@IsOptional()
take?: number;
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by creation time" })
@IsOptional()
@IsEnum(SortOrder)
sortCreatedAt?: "asc" | "desc";
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by update time" })
@IsOptional()
@IsEnum(SortOrder)
sortUpdatedAt?: "asc" | "desc";
@ApiPropertyOptional({
type: String,
format: "date-time",
description: "Filter by responses created after this date",
})
@IsOptional()
@IsISO8601()
@Transform(({ value }) => value && new Date(value))
@IsDate()
afterCreatedAt?: Date;
@ApiPropertyOptional({
type: String,
format: "date-time",
description: "Filter by responses created before this date",
})
@IsOptional()
@IsISO8601()
@Transform(({ value }) => value && new Date(value))
@IsDate()
beforeCreatedAt?: Date;
@ApiPropertyOptional({
type: String,
format: "date-time",
description: "Filter by responses created after this date",
})
@IsOptional()
@IsISO8601()
@Transform(({ value }) => value && new Date(value))
@IsDate()
afterUpdatedAt?: Date;
@ApiPropertyOptional({
type: String,
format: "date-time",
description: "Filter by responses updated before this date",
})
@IsOptional()
@IsISO8601()
@Transform(({ value }) => value && new Date(value))
@IsDate()
beforeUpdatedAt?: Date;
@ApiPropertyOptional({ type: String, description: "Filter by responses routed to a specific booking" })
@IsOptional()
@IsString()
routedToBookingUid?: string;
}
export class GetRoutingFormsParams extends GetRoutingFormResponsesParams {
@IsOptional()
@Transform(({ value }) => {
if (typeof value === "string") {
return value.split(",").map((teamId: string) => parseInt(teamId));
}
return value;
})
@ApiPropertyOptional({
type: [Number],
description: "Filter by teamIds. Team ids must be separated by a comma.",
example: "?teamIds=100,200",
})
@IsArray()
@IsNumber({}, { each: true })
@ArrayMinSize(1, { message: "teamIds must contain at least 1 team id" })
teamIds?: number[];
}
@@ -0,0 +1,89 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsOptional, IsBoolean, IsString, IsEnum, IsDate, IsISO8601 } from "class-validator";
enum SortOrder {
ASC = "asc",
DESC = "desc",
}
export class GetRoutingFormsParams {
@ApiPropertyOptional({ type: Number, description: "Number of routing forms to skip" })
@Transform(({ value }) => value && parseInt(value))
@IsOptional()
skip?: number;
@ApiPropertyOptional({ type: Number, description: "Number of routing forms to take" })
@Transform(({ value }) => value && parseInt(value))
@IsOptional()
take?: number;
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by creation time" })
@IsOptional()
@IsEnum(SortOrder)
sortCreatedAt?: "asc" | "desc";
@ApiPropertyOptional({ enum: SortOrder, description: "Sort by update time" })
@IsOptional()
@IsEnum(SortOrder)
sortUpdatedAt?: "asc" | "desc";
@ApiPropertyOptional({ type: Boolean, description: "Filter by disabled status" })
@IsOptional()
@Transform(({ value }) => {
if (value === "true") return true;
if (value === "false") return false;
return value;
})
@IsBoolean()
disabled?: boolean;
@ApiPropertyOptional({ type: String, description: "Filter by name" })
@IsOptional()
@IsString()
name?: string;
@ApiPropertyOptional({
type: String,
format: "date-time",
description: "Filter by forms created after this date",
})
@IsOptional()
@IsISO8601()
@Transform(({ value }) => value && new Date(value))
@IsDate()
afterCreatedAt?: Date;
@ApiPropertyOptional({
type: String,
format: "date-time",
description: "Filter by forms created before this date",
})
@IsOptional()
@IsISO8601()
@Transform(({ value }) => value && new Date(value))
@IsDate()
beforeCreatedAt?: Date;
@ApiPropertyOptional({
type: String,
format: "date-time",
description: "Filter by forms updated after this date",
})
@IsOptional()
@IsISO8601()
@Transform(({ value }) => value && new Date(value))
@IsDate()
afterUpdatedAt?: Date;
@ApiPropertyOptional({
type: String,
format: "date-time",
description: "Filter by forms updated before this date",
})
@IsOptional()
@IsISO8601()
@Transform(({ value }) => value && new Date(value))
@IsDate()
beforeUpdatedAt?: Date;
}
@@ -0,0 +1,8 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional } from "class-validator";
export class UpdateRoutingFormResponseInput {
@ApiPropertyOptional({ type: Object, description: "The updated response data" })
@IsOptional()
response?: Record<string, any>;
}
@@ -0,0 +1,28 @@
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
import { OrganizationsRoutingFormsResponsesController } from "./controllers/organizations-routing-forms-responses.controller";
import { OrganizationsRoutingFormsController } from "./controllers/organizations-routing-forms.controller";
import { OrganizationsRoutingFormsRepository } from "./organizations-routing-forms.repository";
import { OrganizationsRoutingFormsResponsesService } from "./services/organizations-routing-forms-responses.service";
import { OrganizationsRoutingFormsService } from "./services/organizations-routing-forms.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
providers: [
MembershipsRepository,
OrganizationsRepository,
OrganizationsRoutingFormsRepository,
OrganizationsRoutingFormsService,
OrganizationsRoutingFormsResponsesService,
OrganizationsTeamsRoutingFormsResponsesOutputService,
],
controllers: [OrganizationsRoutingFormsController, OrganizationsRoutingFormsResponsesController],
})
export class OrganizationsRoutingFormsModule {}
@@ -0,0 +1,128 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsRoutingFormsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getOrganizationRoutingForms(
orgId: number,
skip: number,
take: number,
options?: {
disabled?: boolean;
name?: string;
sortCreatedAt?: "asc" | "desc";
sortUpdatedAt?: "asc" | "desc";
afterCreatedAt?: Date;
beforeCreatedAt?: Date;
afterUpdatedAt?: Date;
beforeUpdatedAt?: Date;
teamIds?: number[];
}
) {
const {
disabled,
name,
sortCreatedAt,
sortUpdatedAt,
afterCreatedAt,
beforeCreatedAt,
afterUpdatedAt,
beforeUpdatedAt,
teamIds,
} = options || {};
return this.dbRead.prisma.app_RoutingForms_Form.findMany({
where: {
team: { parentId: orgId, ...(teamIds?.length ? { id: { in: teamIds } } : {}) },
...(disabled !== undefined && { disabled }),
...(name && { name: { contains: name, mode: "insensitive" } }),
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
...(afterUpdatedAt && { updatedAt: { gte: afterUpdatedAt } }),
...(beforeUpdatedAt && { updatedAt: { lte: beforeUpdatedAt } }),
},
orderBy: [
...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : []),
...(sortUpdatedAt ? [{ updatedAt: sortUpdatedAt }] : []),
],
skip,
take,
});
}
async getOrganizationRoutingFormResponses(
orgId: number,
routingFormId: string,
skip: number,
take: number,
options?: {
sortCreatedAt?: "asc" | "desc";
sortUpdatedAt?: "asc" | "desc";
afterCreatedAt?: Date;
beforeCreatedAt?: Date;
afterUpdatedAt?: Date;
beforeUpdatedAt?: Date;
routedToBookingUid?: string;
}
) {
const {
sortCreatedAt,
sortUpdatedAt,
afterCreatedAt,
beforeCreatedAt,
routedToBookingUid,
afterUpdatedAt,
beforeUpdatedAt,
} = options || {};
await this.dbRead.prisma.app_RoutingForms_Form.findFirstOrThrow({
where: {
team: { parentId: orgId },
id: routingFormId,
},
});
return this.dbRead.prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: routingFormId,
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
...(afterUpdatedAt && { updatedAt: { gte: afterUpdatedAt } }),
...(beforeUpdatedAt && { updatedAt: { lte: beforeUpdatedAt } }),
...(routedToBookingUid && { routedToBookingUid }),
},
orderBy: [
...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : []),
...(sortUpdatedAt ? [{ updatedAt: sortUpdatedAt }] : []),
],
skip,
take,
});
}
async updateRoutingFormResponse(
orgId: number,
routingFormId: string,
responseId: number,
data: {
response?: Record<string, any>;
}
) {
return this.dbWrite.prisma.app_RoutingForms_FormResponse.update({
where: {
id: responseId,
formId: routingFormId,
form: {
team: {
parentId: orgId,
},
},
},
data: {
...data,
},
});
}
}
@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { RoutingFormResponseOutput } from "@calcom/platform-types";
export class GetRoutingFormResponsesOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({ type: RoutingFormResponseOutput })
@Expose()
@Type(() => RoutingFormResponseOutput)
data!: RoutingFormResponseOutput[];
}
@@ -0,0 +1,62 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class RoutingFormOutput {
@Expose()
id!: string;
@ApiProperty({ example: "My Form" })
@Expose()
name!: string;
@ApiProperty({ example: "This is the description." })
@Expose()
description!: string | null;
@ApiProperty({ example: 0 })
@Expose()
position!: number;
@Expose()
routes!: Record<string, unknown> | null;
@ApiProperty({ example: "2024-03-28T10:00:00.000Z" })
@Expose()
createdAt!: string;
@ApiProperty({ example: "2024-03-28T10:00:00.000Z" })
@Expose()
updatedAt!: string;
@Expose()
fields!: Record<string, unknown> | null;
@ApiProperty({ example: 2313 })
@Expose()
userId!: number;
@ApiProperty({ example: 4214321 })
@Expose()
teamId!: number | null;
@ApiProperty({ example: false })
@Expose()
disabled!: boolean;
@Expose()
settings!: Record<string, unknown> | null;
}
export class GetRoutingFormsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({ type: [RoutingFormOutput] })
@Expose()
@Type(() => RoutingFormOutput)
data!: RoutingFormOutput[];
}
@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsEnum } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { RoutingFormResponseOutput } from "@calcom/platform-types";
export class UpdateRoutingFormResponseOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({ type: RoutingFormResponseOutput })
@Expose()
@Type(() => RoutingFormResponseOutput)
data!: RoutingFormResponseOutput;
}
@@ -0,0 +1,55 @@
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsRoutingFormsResponsesService {
constructor(
private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository,
private readonly outputService: OrganizationsTeamsRoutingFormsResponsesOutputService
) {}
async getOrganizationRoutingFormResponses(
orgId: number,
routingFormId: string,
skip: number,
take: number,
options?: {
sortCreatedAt?: "asc" | "desc";
sortUpdatedAt?: "asc" | "desc";
afterCreatedAt?: Date;
beforeCreatedAt?: Date;
afterUpdatedAt?: Date;
beforeUpdatedAt?: Date;
routedToBookingUid?: string;
}
) {
const responses = await this.organizationsRoutingFormsRepository.getOrganizationRoutingFormResponses(
orgId,
routingFormId,
skip,
take,
options
);
return this.outputService.getRoutingFormResponses(responses);
}
async updateRoutingFormResponse(
orgId: number,
routingFormId: string,
responseId: number,
data: {
response?: Record<string, any>;
}
) {
const updatedResponse = await this.organizationsRoutingFormsRepository.updateRoutingFormResponse(
orgId,
routingFormId,
responseId,
data
);
return this.outputService.getRoutingFormResponses([updatedResponse])[0];
}
}
@@ -0,0 +1,26 @@
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsRoutingFormsService {
constructor(private readonly organizationsRoutingFormsRepository: OrganizationsRoutingFormsRepository) {}
async getOrganizationRoutingForms(
orgId: number,
skip: number,
take: number,
options?: {
disabled?: boolean;
name?: string;
sortCreatedAt?: "asc" | "desc";
sortUpdatedAt?: "asc" | "desc";
afterCreatedAt?: Date;
beforeCreatedAt?: Date;
afterUpdatedAt?: Date;
beforeUpdatedAt?: Date;
teamIds?: number[];
}
) {
return this.organizationsRoutingFormsRepository.getOrganizationRoutingForms(orgId, skip, take, options);
}
}
@@ -313,18 +313,7 @@ describe("Organizations TeamsBookings Endpoints 2024-08-13", () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/teams/${team1.id}/bookings?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 not get bookings by non existing teamId", async () => {
@@ -43,9 +43,13 @@ export class OrganizationsTeamsBookingsController {
async getAllOrgTeamBookings(
@Query() queryParams: GetOrganizationsTeamsBookingsInput_2024_08_13,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("orgId", ParseIntPipe) orgId: number,
@GetUser() user: UserWithProfile
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.bookingsService.getBookings({ ...queryParams, teamId }, user);
const bookings = await this.bookingsService.getBookings(
{ ...queryParams, teamId },
{ email: user.email, id: user.id, orgId }
);
return {
status: SUCCESS_STATUS,
@@ -2,8 +2,8 @@ import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
import { OrgTeamMembershipOutputDto } from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
@@ -43,7 +43,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
let managedEventType: EventType;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: OrgTeamMembershipOutputDto;
let membershipCreatedViaApi: TeamMembershipOutput;
const userEmail = `organizations-teams-memberships-admin-${randomString()}@api.com`;
const userEmail2 = `organizations-teams-memberships-member-${randomString()}@api.com`;
@@ -56,6 +56,11 @@ describe("Organizations Teams Memberships Endpoints", () => {
let userToInviteViaApi: User;
const metadata = {
some: "key",
};
const bio = "This is a bio";
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -75,10 +80,14 @@ describe("Organizations Teams Memberships Endpoints", () => {
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
bio,
metadata,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
bio,
metadata,
});
nonOrgUser = await userRepositoryFixture.create({
@@ -89,6 +98,8 @@ describe("Organizations Teams Memberships Endpoints", () => {
userToInviteViaApi = await userRepositoryFixture.create({
email: invitedUserEmail,
username: invitedUserEmail,
bio,
metadata,
});
org = await organizationsRepositoryFixture.create({
@@ -232,11 +243,25 @@ describe("Organizations Teams Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership.id);
expect(responseBody.data[0].userId).toEqual(user.id);
expect(responseBody.data[0].role).toEqual("ADMIN");
expect(responseBody.data[0].user.bio).toEqual(bio);
expect(responseBody.data[0].user.metadata).toEqual(metadata);
expect(responseBody.data[0].user.email).toEqual(user.email);
expect(responseBody.data[0].user.username).toEqual(user.username);
expect(responseBody.data[1].id).toEqual(membership2.id);
expect(responseBody.data[1].userId).toEqual(user2.id);
expect(responseBody.data[1].role).toEqual("MEMBER");
expect(responseBody.data[1].user.bio).toEqual(user2.bio);
expect(responseBody.data[1].user.metadata).toEqual(user2.metadata);
expect(responseBody.data[1].user.email).toEqual(user2.email);
expect(responseBody.data[1].user.username).toEqual(user2.username);
expect(responseBody.data.length).toEqual(2);
expect(responseBody.data[0].teamId).toEqual(orgTeam.id);
expect(responseBody.data[1].teamId).toEqual(orgTeam.id);
});
});
@@ -251,10 +276,15 @@ describe("Organizations Teams Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto[]> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership2.id);
expect(responseBody.data[0].userId).toEqual(user2.id);
expect(responseBody.data[0].role).toEqual("MEMBER");
expect(responseBody.data[0].user.bio).toEqual(bio);
expect(responseBody.data[0].user.metadata).toEqual(metadata);
expect(responseBody.data[0].user.email).toEqual(user2.email);
expect(responseBody.data[0].user.username).toEqual(user2.username);
expect(responseBody.data.length).toEqual(1);
});
});
@@ -270,11 +300,16 @@ describe("Organizations Teams Memberships Endpoints", () => {
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membership.id);
expect(responseBody.data.userId).toEqual(user.id);
expect(responseBody.data.user.email).toEqual(user.email);
expect(responseBody.data.user.username).toEqual(user.username);
expect(responseBody.data.user.bio).toEqual(bio);
expect(responseBody.data.user.metadata).toEqual(metadata);
expect(responseBody.data.role).toEqual("ADMIN");
expect(responseBody.data.teamId).toEqual(orgTeam.id);
});
});
@@ -305,13 +340,16 @@ describe("Organizations Teams Memberships Endpoints", () => {
} satisfies CreateOrgTeamMembershipDto)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.teamId).toEqual(orgTeam.id);
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.userId).toEqual(userToInviteViaApi.id);
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username);
expect(membershipCreatedViaApi.user.bio).toEqual(userToInviteViaApi.bio);
expect(membershipCreatedViaApi.user.metadata).toEqual(userToInviteViaApi.metadata);
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
});
});
@@ -347,10 +385,14 @@ describe("Organizations Teams Memberships Endpoints", () => {
} satisfies UpdateOrgTeamMembershipDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.role).toEqual("OWNER");
expect(membershipCreatedViaApi.user.email).toEqual(userToInviteViaApi.email);
expect(membershipCreatedViaApi.user.username).toEqual(userToInviteViaApi.username);
expect(membershipCreatedViaApi.user.bio).toEqual(userToInviteViaApi.bio);
expect(membershipCreatedViaApi.user.metadata).toEqual(userToInviteViaApi.metadata);
});
});
@@ -359,7 +401,7 @@ describe("Organizations Teams Memberships Endpoints", () => {
.delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = response.body;
const responseBody: ApiSuccessResponse<TeamMembershipOutput> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
});
@@ -16,12 +16,12 @@ import { OrganizationsRepository } from "@/modules/organizations/index/organizat
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
import {
OrgTeamMembershipOutputDto,
OrgTeamMembershipsOutputResponseDto,
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/teams/memberships/outputs/organization-teams-memberships.output";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/teams/memberships/services/organizations-teams-memberships.service";
import { TeamsEventTypesService } from "@/modules/teams/event-types/services/teams-event-types.service";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import {
Controller,
UseGuards,
@@ -84,7 +84,7 @@ export class OrganizationsTeamsMembershipsController {
return {
status: SUCCESS_STATUS,
data: orgTeamMemberships.map((membership) =>
plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" })
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
),
};
}
@@ -107,7 +107,7 @@ export class OrganizationsTeamsMembershipsController {
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, orgTeamMembership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, orgTeamMembership, { strategy: "excludeAll" }),
};
}
@@ -131,7 +131,7 @@ export class OrganizationsTeamsMembershipsController {
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@@ -168,7 +168,7 @@ export class OrganizationsTeamsMembershipsController {
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, updatedMembership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, updatedMembership, { strategy: "excludeAll" }),
};
}
@@ -198,7 +198,7 @@ export class OrganizationsTeamsMembershipsController {
}
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
}
@@ -2,6 +2,7 @@ import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/member
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { MembershipUserSelect } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable } from "@nestjs/common";
@Injectable()
@@ -16,7 +17,7 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
skip,
take,
});
@@ -31,7 +32,7 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
});
}
async deleteOrgTeamMembershipById(organizationId: number, teamId: number, membershipId: number) {
@@ -43,6 +44,7 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: MembershipUserSelect } },
});
}
@@ -61,14 +63,14 @@ export class OrganizationsTeamsMembershipsRepository {
parentId: organizationId,
},
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
});
}
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
include: { user: { select: MembershipUserSelect } },
});
}
}
@@ -1,74 +1,10 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsArray, IsBoolean, IsEnum, IsInt, IsOptional, IsString, ValidateNested } from "class-validator";
import { IsArray, IsEnum, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class MembershipUserOutputDto {
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly avatarUrl?: string;
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly username?: string;
@IsOptional()
@IsString()
@Expose()
@ApiPropertyOptional()
readonly name?: string;
@IsBoolean()
@Expose()
@ApiProperty()
readonly email!: string;
}
export class OrgTeamMembershipOutputDto {
@IsInt()
@Expose()
@ApiProperty()
readonly id!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly userId!: number;
@IsInt()
@Expose()
@ApiProperty()
readonly teamId!: number;
@IsBoolean()
@Expose()
@ApiProperty()
readonly accepted!: boolean;
@IsString()
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
@Expose()
readonly role!: MembershipRole;
@IsOptional()
@IsBoolean()
@Expose()
@ApiPropertyOptional()
readonly disableImpersonation?: boolean;
@ValidateNested()
@Type(() => MembershipUserOutputDto)
@Expose()
@ApiProperty({ type: MembershipUserOutputDto })
user!: MembershipUserOutputDto;
}
export class OrgTeamMembershipsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
@@ -76,10 +12,10 @@ export class OrgTeamMembershipsOutputResponseDto {
@Expose()
@ValidateNested()
@Type(() => OrgTeamMembershipOutputDto)
@Type(() => TeamMembershipOutput)
@IsArray()
@ApiProperty({ type: [OrgTeamMembershipOutputDto] })
data!: OrgTeamMembershipOutputDto[];
@ApiProperty({ type: [TeamMembershipOutput] })
data!: TeamMembershipOutput[];
}
export class OrgTeamMembershipOutputResponseDto {
@@ -89,7 +25,7 @@ export class OrgTeamMembershipOutputResponseDto {
@Expose()
@ValidateNested()
@Type(() => OrgTeamMembershipOutputDto)
@ApiProperty({ type: OrgTeamMembershipOutputDto })
data!: OrgTeamMembershipOutputDto;
@Type(() => TeamMembershipOutput)
@ApiProperty({ type: TeamMembershipOutput })
data!: TeamMembershipOutput;
}
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
let org: Team;
let orgTeam: Team;
let routingFormResponseId: string;
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
let user: User;
let apiKeyString: string;
@@ -173,7 +173,9 @@ describe("Organizations Teams Routing Forms Responses", () => {
it("should get routing form responses", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses`)
.get(
`/v2/organizations/${org.id}/teams/${orgTeam.id}/routing-forms/${routingFormId}/responses?skip=0&take=1`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
@@ -186,9 +188,65 @@ describe("Organizations Teams Routing Forms Responses", () => {
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
routingFormResponseId = responseData[0].id;
});
});
describe(`PATCH /v2/organizations/:orgId/routing-forms/:routingFormId/responses/:responseId`, () => {
it("should not update routing form response for non existing org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/99999/routing-forms/${routingFormId}/responses/${routingFormResponseId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(403);
});
it("should not update routing form response for non existing form", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/routing-forms/non-existent-id/responses/${routingFormResponseId}`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(404);
});
it("should not update routing form response for non existing response", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/routing-forms/${routingFormId}/responses/99999`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(404);
});
it("should not update routing form response without authentication", async () => {
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/${routingFormId}/responses/${routingFormResponseId}`
)
.send({ response: JSON.stringify({ question1: "updated_answer1" }) })
.expect(401);
});
it("should update routing form response", async () => {
const updatedResponse = { question1: "updated_answer1", question2: "updated_answer2" };
return request(app.getHttpServer())
.patch(
`/v2/organizations/${org.id}/routing-forms/${routingFormId}/responses/${routingFormResponseId}`
)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.send({ response: updatedResponse })
.expect(200)
.then((response) => {
const responseBody = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data).toBeDefined();
expect(data.id).toEqual(routingFormResponseId);
expect(data.formId).toEqual(routingFormId);
expect(data.response).toEqual(updatedResponse);
});
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
@@ -8,14 +8,16 @@ import { IsAdminAPIEnabledGuard } from "@/modules/auth/guards/organizations/is-a
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { IsRoutingFormInTeam } from "@/modules/auth/guards/routing-forms/is-routing-form-in-team.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { Controller, Get, Param, UseGuards } from "@nestjs/common";
import { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
import { UpdateRoutingFormResponseInput } from "@/modules/organizations/routing-forms/inputs/update-routing-form-response.input";
import { UpdateRoutingFormResponseOutput } from "@/modules/organizations/routing-forms/outputs/update-routing-form-response.output";
import { GetRoutingFormResponsesOutput } from "@/modules/organizations/teams/routing-forms/outputs/get-routing-form-responses.output";
import { OrganizationsTeamsRoutingFormsResponsesService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses.service";
import { Controller, Get, Patch, Param, ParseIntPipe, Query, UseGuards, Body } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { RoutingFormsResponsesService } from "../../../../routing-forms-responses/services/routing-forms-responses.service";
import { GetRoutingFormResponsesOutput } from "../outputs/get-routing-form-responses.output";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/routing-forms/:routingFormId/responses",
version: API_VERSIONS_VALUES,
@@ -31,22 +33,58 @@ import { GetRoutingFormResponsesOutput } from "../outputs/get-routing-form-respo
)
@ApiHeader(API_KEY_HEADER)
export class OrganizationsTeamsRoutingFormsResponsesController {
constructor(private readonly routingFormsResponsesService: RoutingFormsResponsesService) {}
constructor(
private readonly organizationsTeamsRoutingFormsResponsesService: OrganizationsTeamsRoutingFormsResponsesService
) {}
@Get()
@ApiOperation({ summary: "Get routing form responses" })
@Roles("ORG_ADMIN")
@Get("/")
@ApiOperation({ summary: "Get organization team routing form responses" })
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
async getRoutingFormResponses(
@Param("routingFormId") routingFormId: string
@Param("routingFormId") routingFormId: string,
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetRoutingFormResponsesParams
): Promise<GetRoutingFormResponsesOutput> {
const routingFormResponses = await this.routingFormsResponsesService.getRoutingFormResponses(
routingFormId
);
const { skip, take, ...filters } = queryParams;
const routingFormResponses =
await this.organizationsTeamsRoutingFormsResponsesService.getTeamRoutingFormResponses(
teamId,
routingFormId,
skip ?? 0,
take ?? 250,
{ ...(filters ?? {}) }
);
return {
status: SUCCESS_STATUS,
data: routingFormResponses,
};
}
@Patch("/:responseId")
@ApiOperation({ summary: "Update routing form response" })
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
async updateRoutingFormResponse(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("routingFormId") routingFormId: string,
@Param("responseId", ParseIntPipe) responseId: number,
@Body() updateRoutingFormResponseInput: UpdateRoutingFormResponseInput
): Promise<UpdateRoutingFormResponseOutput> {
const updatedResponse =
await this.organizationsTeamsRoutingFormsResponsesService.updateTeamRoutingFormResponse(
teamId,
routingFormId,
responseId,
updateRoutingFormResponseInput
);
return {
status: SUCCESS_STATUS,
data: updatedResponse,
};
}
}
@@ -0,0 +1,206 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { GetRoutingFormsOutput } from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { App_RoutingForms_Form, Team, User } from "@prisma/client";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
describe("OrganizationsRoutingFormsResponsesController", () => {
let app: INestApplication;
let prismaWriteService: PrismaWriteService;
let org: Team;
let team: Team;
let apiKeyString: string;
let routingForm: App_RoutingForms_Form;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let user: User;
const userEmail = `OrganizationsRoutingFormsResponsesController-key-bookings-2024-08-13-user-${randomString()}@api.com`;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
}).compile();
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
prismaWriteService = moduleRef.get<PrismaWriteService>(PrismaWriteService);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: `OrganizationsRoutingFormsResponsesController-teams-memberships-organization-${randomString()}`,
isOrganization: true,
});
team = await teamRepositoryFixture.create({
name: "OrganizationsRoutingFormsResponsesController orgs booking 1",
isOrganization: false,
parent: { connect: { id: org.id } },
});
user = await userRepositoryFixture.create({
email: userEmail,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
const now = new Date();
now.setDate(now.getDate() + 1);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `${keyString}`;
routingForm = await prismaWriteService.prisma.app_RoutingForms_Form.create({
data: {
name: "Test Routing Form",
description: "Test Description",
disabled: false,
routes: JSON.stringify([]),
fields: JSON.stringify([]),
settings: JSON.stringify({}),
teamId: team.id,
userId: user.id,
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: team.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await app.close();
});
describe(`GET /v2/organizations/:orgId/teams/:teamId/routing-forms`, () => {
it("should not get team routing forms for non existing org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/99999/teams/${team.id}/routing-forms`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(403);
});
it("should not get team routing forms for non existing team", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/99999/routing-forms`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(404);
});
it("should not get team routing forms without authentication", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/routing-forms`)
.expect(401);
});
it("should get team routing forms", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/routing-forms`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const routingForms = responseBody.data;
expect(routingForms).toBeDefined();
expect(routingForms.length).toBeGreaterThan(0);
expect(routingForms[0].id).toEqual(routingForm.id);
expect(routingForms[0].name).toEqual(routingForm.name);
expect(routingForms[0].description).toEqual(routingForm.description);
expect(routingForms[0].disabled).toEqual(routingForm.disabled);
});
});
it("should filter team routing forms by name", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/routing-forms?name=Team`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const routingForms = responseBody.data;
expect(routingForms).toBeDefined();
expect(routingForms.length).toBeGreaterThan(0);
expect(routingForms[0].name).toContain("Test Routing Form");
});
});
it("should filter team routing forms by disabled status", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/routing-forms?disabled=false`)
.set({ Authorization: `Bearer cal_test_${apiKeyString}` })
.expect(200)
.then((response) => {
const responseBody: GetRoutingFormsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const routingForms = responseBody.data;
expect(routingForms).toBeDefined();
expect(routingForms.length).toBeGreaterThan(0);
expect(routingForms[0].disabled).toEqual(false);
});
});
});
});
@@ -0,0 +1,58 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { API_KEY_HEADER } from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.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 { GetRoutingFormResponsesParams } from "@/modules/organizations/routing-forms/inputs/get-routing-form-responses-params.input";
import {
GetRoutingFormsOutput,
RoutingFormOutput,
} from "@/modules/organizations/routing-forms/outputs/get-routing-forms.output";
import { OrganizationsTeamsRoutingFormsService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms.service";
import { Controller, Get, Param, Query, UseGuards, ParseIntPipe } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/routing-forms",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@ApiTags("Orgs / Teams / Routing forms")
@ApiHeader(API_KEY_HEADER)
export class OrganizationsTeamsRoutingFormsController {
constructor(
private readonly organizationsTeamsRoutingFormsService: OrganizationsTeamsRoutingFormsService
) {}
@Get()
@ApiOperation({ summary: "Get team routing forms" })
@Roles("TEAM_ADMIN")
@PlatformPlan("ESSENTIALS")
async getTeamRoutingForms(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: GetRoutingFormResponsesParams
): Promise<GetRoutingFormsOutput> {
const { skip, take, ...filters } = queryParams;
const routingForms = await this.organizationsTeamsRoutingFormsService.getTeamRoutingForms(
teamId,
skip ?? 0,
take ?? 250,
{ ...(filters ?? {}) }
);
return {
status: SUCCESS_STATUS,
data: routingForms.map((form) => plainToClass(RoutingFormOutput, form)),
};
}
}
@@ -1,17 +0,0 @@
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 { RoutingFormsResponsesModule } from "@/modules/routing-forms-responses/routing-forms-responses.module";
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
import { OrganizationsTeamsRoutingFormsResponsesController } from "./controllers/organizations-teams-routing-forms-responses.controller";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsResponsesModule, RoutingFormsModule],
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
controllers: [OrganizationsTeamsRoutingFormsResponsesController],
})
export class OrganizationsTeamsRoutingFormsModule {}
@@ -0,0 +1,37 @@
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsRoutingFormsRepository } from "@/modules/organizations/routing-forms/organizations-routing-forms.repository";
import { OrganizationsRoutingFormsResponsesService } from "@/modules/organizations/routing-forms/services/organizations-routing-forms-responses.service";
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 { RedisService } from "@/modules/redis/redis.service";
import { RoutingFormsModule } from "@/modules/routing-forms/routing-forms.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { Module } from "@nestjs/common";
import { OrganizationsTeamsRoutingFormsResponsesController } from "./controllers/organizations-teams-routing-forms-responses.controller";
import { OrganizationsTeamsRoutingFormsController } from "./controllers/organizations-teams-routing-forms.controller";
import { OrganizationsTeamsRoutingFormsResponsesRepository } from "./repositories/organizations-teams-routing-forms-responses.repository";
import { OrganizationsTeamsRoutingFormsRepository } from "./repositories/organizations-teams-routing-forms.repository";
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "./services/organizations-teams-routing-forms-responses-output.service";
import { OrganizationsTeamsRoutingFormsResponsesService } from "./services/organizations-teams-routing-forms-responses.service";
import { OrganizationsTeamsRoutingFormsService } from "./services/organizations-teams-routing-forms.service";
@Module({
imports: [PrismaModule, StripeModule, RedisModule, RoutingFormsModule],
providers: [
OrganizationsTeamsRoutingFormsService,
OrganizationsTeamsRoutingFormsResponsesService,
OrganizationsTeamsRoutingFormsResponsesOutputService,
OrganizationsTeamsRoutingFormsResponsesRepository,
OrganizationsTeamsRoutingFormsRepository,
OrganizationsRepository,
OrganizationsTeamsRepository,
MembershipsRepository,
OrganizationsRoutingFormsResponsesService,
OrganizationsRoutingFormsRepository,
],
controllers: [OrganizationsTeamsRoutingFormsResponsesController, OrganizationsTeamsRoutingFormsController],
})
export class OrganizationsTeamsRoutingFormsModule {}
@@ -0,0 +1,60 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsTeamsRoutingFormsResponsesRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getTeamRoutingFormResponses(
teamId: number,
routingFormId: string,
skip: number,
take: number,
options?: {
sortCreatedAt?: "asc" | "desc";
afterCreatedAt?: Date;
beforeCreatedAt?: Date;
routedToBookingUid?: string;
}
) {
const { sortCreatedAt, afterCreatedAt, beforeCreatedAt, routedToBookingUid } = options || {};
return this.dbRead.prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: routingFormId,
form: {
teamId,
},
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
...(routedToBookingUid && { routedToBookingUid }),
},
orderBy: [...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : [])],
skip,
take,
});
}
async updateTeamRoutingFormResponse(
teamId: number,
routingFormId: string,
responseId: number,
data: {
response?: Record<string, any>;
}
) {
return this.dbWrite.prisma.app_RoutingForms_FormResponse.update({
where: {
id: responseId,
formId: routingFormId,
form: {
teamId,
},
},
data: {
...data,
},
});
}
}
@@ -0,0 +1,53 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
@Injectable()
export class OrganizationsTeamsRoutingFormsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getTeamRoutingForms(
teamId: number,
skip: number,
take: number,
options?: {
disabled?: boolean;
name?: string;
sortCreatedAt?: "asc" | "desc";
sortUpdatedAt?: "asc" | "desc";
afterCreatedAt?: Date;
beforeCreatedAt?: Date;
afterUpdatedAt?: Date;
beforeUpdatedAt?: Date;
}
) {
const {
disabled,
name,
sortCreatedAt,
sortUpdatedAt,
afterCreatedAt,
beforeCreatedAt,
afterUpdatedAt,
beforeUpdatedAt,
} = options || {};
return this.dbRead.prisma.app_RoutingForms_Form.findMany({
where: {
teamId,
...(disabled !== undefined && { disabled }),
...(name && { name: { contains: name, mode: "insensitive" } }),
...(afterCreatedAt && { createdAt: { gte: afterCreatedAt } }),
...(beforeCreatedAt && { createdAt: { lte: beforeCreatedAt } }),
...(afterUpdatedAt && { updatedAt: { gte: afterUpdatedAt } }),
...(beforeUpdatedAt && { updatedAt: { lte: beforeUpdatedAt } }),
},
orderBy: [
...(sortCreatedAt ? [{ createdAt: sortCreatedAt }] : []),
...(sortUpdatedAt ? [{ updatedAt: sortUpdatedAt }] : []),
],
skip,
take,
});
}
}
@@ -5,7 +5,7 @@ import { RoutingFormResponseOutput, RoutingFormResponseResponseOutput } from "@c
import { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
@Injectable()
export class RoutingFormsResponsesOutputService {
export class OrganizationsTeamsRoutingFormsResponsesOutputService {
getRoutingFormResponses(
dbRoutingFormResponses: App_RoutingForms_FormResponse[]
): RoutingFormResponseOutput[] {
@@ -0,0 +1,56 @@
import { OrganizationsTeamsRoutingFormsResponsesOutputService } from "@/modules/organizations/teams/routing-forms/services/organizations-teams-routing-forms-responses-output.service";
import { Injectable } from "@nestjs/common";
import { OrganizationsTeamsRoutingFormsResponsesRepository } from "../repositories/organizations-teams-routing-forms-responses.repository";
@Injectable()
export class OrganizationsTeamsRoutingFormsResponsesService {
constructor(
private readonly routingFormsRepository: OrganizationsTeamsRoutingFormsResponsesRepository,
private readonly routingFormsResponsesOutputService: OrganizationsTeamsRoutingFormsResponsesOutputService
) {}
async getTeamRoutingFormResponses(
teamId: number,
routingFormId: string,
skip: number,
take: number,
options?: {
sortCreatedAt?: "asc" | "desc";
sortUpdatedAt?: "asc" | "desc";
afterCreatedAt?: Date;
beforeCreatedAt?: Date;
afterUpdatedAt?: Date;
beforeUpdatedAt?: Date;
routedToBookingUid?: string;
}
) {
const responses = await this.routingFormsRepository.getTeamRoutingFormResponses(
teamId,
routingFormId,
skip,
take,
options
);
return this.routingFormsResponsesOutputService.getRoutingFormResponses(responses);
}
async updateTeamRoutingFormResponse(
teamId: number,
routingFormId: string,
responseId: number,
data: {
response?: Record<string, any>;
}
) {
const updatedResponse = await this.routingFormsRepository.updateTeamRoutingFormResponse(
teamId,
routingFormId,
responseId,
data
);
return this.routingFormsResponsesOutputService.getRoutingFormResponses([updatedResponse])[0];
}
}
@@ -0,0 +1,26 @@
import { Injectable } from "@nestjs/common";
import { OrganizationsTeamsRoutingFormsRepository } from "../repositories/organizations-teams-routing-forms.repository";
@Injectable()
export class OrganizationsTeamsRoutingFormsService {
constructor(private readonly routingFormsRepository: OrganizationsTeamsRoutingFormsRepository) {}
async getTeamRoutingForms(
teamId: number,
skip: number,
take: number,
options?: {
disabled?: boolean;
name?: string;
sortCreatedAt?: "asc" | "desc";
sortUpdatedAt?: "asc" | "desc";
afterCreatedAt?: Date;
beforeCreatedAt?: Date;
afterUpdatedAt?: Date;
beforeUpdatedAt?: Date;
}
) {
return this.routingFormsRepository.getTeamRoutingForms(teamId, skip, take, options);
}
}
@@ -0,0 +1,48 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
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 { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
import { OrganizationUsersBookingsService } from "@/modules/organizations/users/bookings/services/organization-users-bookings.service";
import { Controller, UseGuards, Get, Query, ParseIntPipe, Param } from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { GetBookingsInput_2024_08_13, GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
import { User } from "@calcom/prisma/client";
@Controller({
path: "/v2/organizations/:orgId/users/:userId/bookings",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsUserInOrg, PlatformPlanGuard, IsAdminAPIEnabledGuard)
@DocsTags("Orgs / Users / Bookings")
export class OrganizationsUsersBookingsController {
constructor(private readonly organizationUsersBookingsService: OrganizationUsersBookingsService) {}
@Get("/")
@Roles("ORG_ADMIN")
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Get all bookings of an organization user" })
async getOrganizationUserBookings(
@GetUser() user: User,
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetBookingsInput_2024_08_13
): Promise<GetBookingsOutput_2024_08_13> {
const bookings = await this.organizationUsersBookingsService.getOrganizationUserBookings(
orgId,
user,
query
);
return {
status: SUCCESS_STATUS,
data: bookings,
};
}
}
@@ -0,0 +1,416 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { HostsRepositoryFixture } from "test/fixtures/repository/hosts.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
RecurringBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
GetSeatedBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Organizations UsersBookings Endpoints 2024-08-13", () => {
describe("Organization User bookings", () => {
let app: INestApplication;
let organization: Team;
let team1: Team;
let userRepositoryFixture: UserRepositoryFixture;
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let oAuthClient: PlatformOAuthClient;
let teamRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let hostsRepositoryFixture: HostsRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const teamUserEmail = `organizations-users-team-user-${randomString()}@api.com`;
let teamUser: User;
let team1EventTypeId: number;
let personalEventTypeId: number;
const personalEventTypeSlug = `organizations-users-bookings-personal-event-type-${randomString()}`;
let createdPersonalBooking: BookingOutput_2024_08_13;
let createdPersonalBookingUsingUsername: BookingOutput_2024_08_13;
let createdTeamBooking: BookingOutput_2024_08_13;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamUserEmail,
Test.createTestingModule({
imports: [AppModule, OrganizationsTeamsBookingsModule],
})
)
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
})
.compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
hostsRepositoryFixture = new HostsRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
organization = await organizationsRepositoryFixture.create({ name: "organizations user bookings" });
oAuthClient = await createOAuthClient(organization.id);
team1 = await teamRepositoryFixture.create({
name: "team 1",
isOrganization: false,
parent: { connect: { id: organization.id } },
createdByOAuthClient: {
connect: {
id: oAuthClient.id,
},
},
});
teamUser = await userRepositoryFixture.create({
email: teamUserEmail,
username: teamUserEmail,
locale: "it",
name: "orgUser1team1",
platformOAuthClients: {
connect: {
id: oAuthClient.id,
},
},
});
const personalEvent = await eventTypesRepositoryFixture.create(
{
title: `user-bookings-2024-08-13-event-type-${randomString()}`,
slug: personalEventTypeSlug,
length: 15,
},
teamUser.id
);
personalEventTypeId = personalEvent.id;
const userSchedule: CreateScheduleInput_2024_04_15 = {
name: "working time",
timeZone: "Europe/Rome",
isDefault: true,
};
await schedulesService.createUserSchedule(teamUser.id, userSchedule);
await profileRepositoryFixture.create({
uid: `usr-${teamUser.id}`,
username: teamUserEmail,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: teamUser.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: team1.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: teamUser.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
const team1EventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team1.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
team1EventTypeId = team1EventType.id;
await hostsRepositoryFixture.create({
isFixed: true,
user: {
connect: {
id: teamUser.id,
},
},
eventType: {
connect: {
id: team1EventType.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
describe("create bookings", () => {
it("should create a personal booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
eventTypeId: personalEventTypeId,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "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[0].id).toEqual(teamUser.id);
expect(data.hosts[0].username).toEqual(teamUser.username);
expect(data.hosts[0].email).toEqual(teamUser.email);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 13, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(personalEventTypeId);
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.email).toEqual(body.attendee.email);
createdPersonalBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create a personal booking using username and event slug and organization slug", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 14, 0, 0)).toISOString(),
eventTypeSlug: personalEventTypeSlug,
username: teamUser.username!,
organizationSlug: organization.slug!,
attendee: {
name: "Mr Proper",
email: "mr_proper@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
location: "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[0].id).toEqual(teamUser.id);
expect(data.hosts[0].username).toEqual(teamUser.username);
expect(data.hosts[0].email).toEqual(teamUser.email);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(new Date(Date.UTC(2030, 0, 8, 14, 15, 0)).toISOString());
expect(data.duration).toEqual(15);
expect(data.eventTypeId).toEqual(personalEventTypeId);
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.email).toEqual(body.attendee.email);
createdPersonalBookingUsingUsername = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
it("should create a team booking", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 8, 15, 0, 0)).toISOString(),
eventTypeId: team1EventTypeId,
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(2030, 0, 8, 16, 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);
createdTeamBooking = data;
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
describe("get bookings", () => {
it("should get individual and team organization user bookings", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${organization.id}/users/${teamUser.id}/bookings`)
.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(3);
expect(data.find((booking) => booking.id === createdPersonalBooking.id)).toBeDefined();
expect(data.find((booking) => booking.id === createdTeamBooking.id)).toBeDefined();
expect(
data.find((booking) => booking.id === createdPersonalBookingUsingUsername.id)
).toBeDefined();
});
});
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:5555"],
permissions: 32,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
function responseDataIsBooking(data: any): data is BookingOutput_2024_08_13 {
return !Array.isArray(data) && typeof data === "object" && data && "id" in data;
}
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
await userRepositoryFixture.deleteByEmail(teamUser.email);
await bookingsRepositoryFixture.deleteAllBookings(teamUser.id, teamUser.email);
await app.close();
});
});
});
@@ -0,0 +1,24 @@
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsUsersBookingsController } from "@/modules/organizations/users/bookings/controllers/organizations-users-bookings-controller";
import { OrganizationUsersBookingsService } from "@/modules/organizations/users/bookings/services/organization-users-bookings.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [
BookingsModule_2024_08_13,
UsersModule,
PrismaModule,
StripeModule,
RedisModule,
MembershipsModule,
],
providers: [OrganizationUsersBookingsService, OrganizationsRepository],
controllers: [OrganizationsUsersBookingsController],
})
export class OrganizationsUsersBookingsModule {}
@@ -0,0 +1,18 @@
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { Injectable } from "@nestjs/common";
import { GetBookingsInput_2024_08_13 } from "@calcom/platform-types";
import { User } from "@calcom/prisma/client";
@Injectable()
export class OrganizationUsersBookingsService {
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
async getOrganizationUserBookings(
orgId: number,
user: Pick<User, "id" | "email">,
queryParams: GetBookingsInput_2024_08_13
) {
return await this.bookingsService.getBookings(queryParams, { orgId, id: user.id, email: user.email });
}
}
@@ -90,7 +90,6 @@ export class OrganizationsUsersController {
@PlatformPlan("ESSENTIALS")
@ApiOperation({ summary: "Create a user" })
async createOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@GetOrg() org: Team,
@Body() input: CreateOrganizationUserInput,
@GetUser() inviter: UserWithProfile
@@ -118,7 +117,6 @@ export class OrganizationsUsersController {
async updateOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number,
@GetOrg() org: Team,
@Body() input: UpdateOrganizationUserInput
): Promise<GetOrganizationUserOutput> {
const user = await this.organizationsUsersService.updateUser(orgId, userId, input);
@@ -4,6 +4,7 @@ import { EmailService } from "@/modules/email/email.service";
import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/users/index/outputs/get-organization-users.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
@@ -22,6 +23,9 @@ import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, EventType } from "@calcom/prisma/client";
describe("Organizations Users Endpoints", () => {
const bio = "I am a bio";
const metadata = { foo: "bar" };
describe("Member role", () => {
let app: INestApplication;
@@ -56,6 +60,8 @@ describe("Organizations Users Endpoints", () => {
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
bio,
metadata,
});
await profileRepositoryFixture.create({
@@ -170,6 +176,8 @@ describe("Organizations Users Endpoints", () => {
email: member.email,
username: member.username,
organization: { connect: { id: org.id } },
bio,
metadata,
})
)
);
@@ -197,6 +205,8 @@ describe("Organizations Users Endpoints", () => {
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
bio,
metadata,
});
await profileRepositoryFixture.create({
@@ -244,9 +254,31 @@ describe("Organizations Users Endpoints", () => {
{ userData },
userData.map((u) => u.profile)
);
expect(userData.find((u) => u.profile.username === orgMembersData[0].username)).toBeDefined();
expect(userData.find((u) => u.profile.username === orgMembersData[1].username)).toBeDefined();
expect(userData.find((u) => u.profile.username === orgMembersData[2].username)).toBeDefined();
// Find and verify each member's data
const member0 = userData.find((u) => u.profile.username === orgMembersData[0].username);
const member1 = userData.find((u) => u.profile.username === orgMembersData[1].username);
const member2 = userData.find((u) => u.profile.username === orgMembersData[2].username);
// Verify member 0
expect(member0).toBeDefined();
expect(member0?.email).toBe(orgMembersData[0].email);
expect(member0?.profile.username).toBe(orgMembersData[0].username);
expect(member0?.bio).toBe(bio);
expect(member0?.metadata).toEqual(metadata);
// Verify member 1
expect(member1).toBeDefined();
expect(member1?.email).toBe(orgMembersData[1].email);
expect(member1?.profile.username).toBe(orgMembersData[1].username);
expect(member1?.bio).toBe(bio);
expect(member1?.metadata).toEqual(metadata);
// Verify member 2
expect(member2).toBeDefined();
expect(member2?.email).toBe(orgMembersData[2].email);
expect(member2?.profile.username).toBe(orgMembersData[2].username);
expect(member2?.bio).toBe(bio);
expect(member2?.metadata).toEqual(metadata);
expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0);
});
@@ -265,8 +297,12 @@ describe("Organizations Users Endpoints", () => {
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(1);
expect(userData.filter((user) => user.email === userEmail).length).toBe(1);
expect(userData.find((u) => u.profile.username === user.username)).toBeDefined();
const foundUser = userData.find((u) => u.email === userEmail);
expect(foundUser).toBeDefined();
expect(foundUser?.email).toBe(userEmail);
expect(foundUser?.profile.username).toBe(user.username);
expect(foundUser?.bio).toBe(bio);
expect(foundUser?.metadata).toEqual(metadata);
});
it("should get users within the specified emails array", async () => {
@@ -285,8 +321,20 @@ describe("Organizations Users Endpoints", () => {
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(2);
expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
expect(userData.filter((user: { email: string }) => user.email === orgMemberEmail).length).toBe(1);
const adminUser = userData.find((u: GetOrgUsersWithProfileOutput) => u.email === userEmail);
const orgMember = userData.find((u: GetOrgUsersWithProfileOutput) => u.email === orgMemberEmail);
expect(adminUser).toBeDefined();
expect(adminUser?.email).toBe(userEmail);
expect(adminUser?.profile.username).toBe(user.username);
expect(adminUser?.bio).toBe(bio);
expect(adminUser?.metadata).toEqual(metadata);
expect(orgMember).toBeDefined();
expect(orgMember?.email).toBe(orgMemberEmail);
expect(orgMember?.profile.username).toBe(orgMembersData[0].username);
expect(orgMember?.bio).toBe(bio);
expect(orgMember?.metadata).toEqual(metadata);
});
it("should update an org user", async () => {
@@ -304,10 +352,10 @@ describe("Organizations Users Endpoints", () => {
});
it("should create a new org user", async () => {
const newOrgUser = {
const newOrgUser: CreateUserInput = {
email: `organizations-users-new-member-${randomString()}@api.com`,
organizationRole: "MEMBER",
autoAccept: true,
bio,
metadata,
};
const emailSpy = jest
@@ -315,15 +363,15 @@ describe("Organizations Users Endpoints", () => {
.mockImplementation(() => Promise.resolve());
const { body } = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users`)
.send({
email: newOrgUser.email,
})
.send(newOrgUser)
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.email).toBe(newOrgUser.email);
expect(userData.bio).toBe(newOrgUser.bio);
expect(userData.metadata).toEqual(newOrgUser.metadata);
expect(emailSpy).toHaveBeenCalledWith({
usernameOrEmail: newOrgUser.email,
orgName: org.name,

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