Compare commits

...
146 Commits
Author SHA1 Message Date
Keith Williams 84be4dba8d chore: v4.3.2 (#15879)
release.yml / release (push) Canceled after 0s
2024-07-23 16:52:12 +02:00
Peer Richelsen 17655cd1c8 chore: updated feature_request.md template (#15877)
* Update feature_request.md

* Update .github/ISSUE_TEMPLATE/feature_request.md
2024-07-23 15:10:25 +02:00
Amit SharmaandSomay Chauhan 010f5349f6 feat: Link to rescheduled booking from the old one (#15211)
* feat: Link to rescheduled booking from the old one

* add original comment

* use fromReschedule

* fix: type check

* review updates

* Update common.json

* change booking page text

* Delete packages/features/bookings/lib/getRescheduledToBooking.tsx

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2024-07-23 12:39:50 +00:00
Lauris Skraucis 01dead9a1d refactor: auto-accept membership for v2 org team creator (#15866) 2024-07-23 12:34:33 +00:00
Peer Richelsen 900ea37b43 chore: updated bug_report.md template (#15876)
* Update bug_report.md

* Update .github/ISSUE_TEMPLATE/bug_report.md

* Update .github/ISSUE_TEMPLATE/bug_report.md

* Update .github/ISSUE_TEMPLATE/bug_report.md
2024-07-23 12:24:42 +00:00
Morgan c209c22b98 chore: add cleanup to api auth strategy e2e test apiv2 (#15875) 2024-07-23 12:22:46 +00:00
b8610dc2d1 test: Create 4 test cases to POST Attendees (#15856)
* Create 4 test cases to POST Attendees

* Adjusted commented tips on PR

* Adjusted isAdmin to isSystemWideAdmin

* Removed req.isAdmin

* Delete test-insights.csv

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-23 14:19:36 +02:00
Anik Dhabal BabuandAmit Sharma 28f84d3570 feat: skype app added (#14751)
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-23 09:39:34 +00:00
b46a902a3d feat: api endpoints for calendar settings atom (#15761)
* calendars repository

* add disconnect endpoint

* update platform libraries to include getCalendar fn

* cleanup

* fix typings

* update calendars service to include delete credentials handler

* update typings

* add missing return

* refactor delete calendar credentials handler

* update disconnect endpoint

* fix typing

* fixup

* add handlers to insert and remove selected calendar

* init selected calendars controller

* fix naming

* output response dto for deleting calendar credentials

* fix typing

* fix merge conflicts

* resolve merge conflicts

* fixup

* cleanup

* capitalize controller name

* include selected calendars controller in selected calendars module

* fix type error

* cleanup

* fixup

* add calendars repository

* custom hook for calendar credentials

* fix typing

* cleanup

* take input from query params instead of body in delete request

* custom hook to add selected calendar

* custom hook to remove selected calendar

* better naming

* fix typo

* fixup

* update input for delete calendar query params

* address PR feedback

* add method to check calendar credentials

* abstract logic to check calendar credentials in calendars service

* resolve module errors

* export custom hooks for calendar settings

* e2e for deleting calendar credentials endpoint

* fix typo

* set authorization header to calendars post

* e2e tests for selected calendars controller

* fix output typing

* better error messages

* restructuring

* fix imports

* remove unused not found exception

* fixup! remove unused not found exception

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-07-22 17:26:39 +00:00
Lauris Skraucis de2f51e104 feat: v2 API enable updating event type schedule (#15803)
* refactor: event-types CREATE, UPDATE accept scheduleId and return scheduleId

* chore: enable passing scheduleId to event-types create handler

* chore: use platform-libraries at version 0.0.20

* fix: remove team output schedule

* finish merge main into this branch

* fix: dont allow adding scheduleId to event-type if user does not own schedule
2024-07-22 20:09:54 +03:00
Hariom Balhara 35110892c1 fix: Fix React cal api namespaced usage (#15834)
* fix: Fix React cal api namespaced usage

* Update e2e
2024-07-22 12:01:05 +02:00
Hichem FantarandAnik Dhabal Babu c870df7027 chore: Add transition effects in UI components (#15841)
* chore: Add transition to hover effects in UI components

* chore: Add transition to hover effects in UI components

* Update ToggleGroup component transition

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-22 11:50:44 +02:00
Krishnendu SukumarandAnik Dhabal Babu a4b0fdba7b fix: Text in React Embed Code Becomes Invisible on Selection (#15835)
* fix: #15832

* fix: #15832

* Revert yarn.lock to its state before unintended changes

* Update EmbedTabs.tsx

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-22 09:26:13 +02:00
Punit Kumar Ojha b530d31b35 fix: added fix for email title for issue# 15848 (#15855) 2024-07-22 09:22:08 +02:00
Anik Dhabal Babu a69f9c5fa3 fix: broken ui of availability setup in onboarding (#15854) 2024-07-22 09:20:48 +02:00
73b8569a16 fix: added translations for et (#15762)
* added translations for et

* estonian support

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-07-22 08:42:07 +02:00
Hariom BalharaandUdit Takkar 5becc95fc4 feat: Add a fresh signup to matching organization after email verification (#15705)
* Support automatically adding user to organization

* Use Repository

* Fix unit test

* Code Improvements

* Fix type error

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-07-20 12:22:42 +05:30
SuPrabhuandsumantha a74522af3d fix: TimeZone select should show the selected timezone (#15838)
* bugfix - CAL4052 TimeZone select should show the selected timezone

* Pusheding the code for type - check failure

* Updating MenuPosition as absolute

---------

Co-authored-by: sumantha <sumantha-ullasa-prabhu@hpe.com>
2024-07-19 23:49:12 +05:30
Lauris Skraucis 8b23e41954 feat: v2 organizations event types endpoints (#15775)
* fix: event-types input service transform length property

* feat: create organizations event-types controller, service and repository

* feat: enable organizations event types

* fix: create team request user isOrgAdmin check

* refactor: hosts array required in team input and output

* chore: use platform-libraries at version 0.0.19

* tests: test organizations event-types

* feat: enable managed event-types

* chore: fix main merge into this branch

* feat: assign all team members for managed event-types

* refactor: gathering managed event-type users ids

* chore: include assignAllTeamMembers in team event response

* test: assign all team members db event

* chore: delete unused files

* docs: swagger create and update request body objects

* revert: event-types platform-libraries update

* refactor: organisations event-types endpoints accessible by TEAM_ADMIN and up

* fix: managed event types output hosts array empty

* fix: dont allow creating event-type for non-team member

* chore: delete unused variables

* tests: negative tests

* chore: finish main merge
2024-07-19 15:12:05 +00:00
Morgan 94873ea3cb fix: api-auth-strategy tests are e2e apiv2 (#15837) 2024-07-19 14:51:50 +00:00
0c0cac28fd fix: multiple getSchedule requests on email change in booker (#15616)
* fix: multiple getSchedule requests on email change in booker

* fix: use debounce for formEmail

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-19 12:30:41 +00:00
Richard Poelderl 769ca9002e fix(website): fix sitemap by adding prisma env var (#15827)
* update docs for `yarn.lock` management

* fix(website): Add `DATABASE_URL` as required environment variable to `website`
2024-07-19 11:56:37 +00:00
Morgan 03366d2337 feat: api v2 org team memberships endpoints (#15820)
* feat: api v2 org team memberships endpoints

* fixup! feat: api v2 org team memberships endpoints
2024-07-19 11:44:16 +00:00
159fe3a87d fix: system default theme for dashboard and booking page (#15291)
* fix: system default theme

* update

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2024-07-19 13:42:21 +02:00
Morgan 5b97d3bf52 fix: revert libraries version event-types controller apiv2 (#15831) 2024-07-19 08:50:15 +00:00
Morgan 7cdecf57e0 fix: PATCH /me formatted timezone incorrectly (#15830) 2024-07-19 08:49:59 +00:00
Udit Takkar 1c8d0eda45 chore: update translation (#15818)
* chore: change title and description

* chore: update
2024-07-19 07:40:59 +00:00
7ee3f9409c feat: [CAL-3939] fix org banner (#15557)
* update documentation for proper hostile command

* add OrgBanner component and replace it in ee organization profile settings

* add width and height defaults, with option to override

* updated sizing with image loaded

* fix: add alt to OrgBanner in test

* fix: failing OrgBanner test, added neg test

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-19 05:30:50 +00:00
Keith Williams 0d1dd036eb revert: "chore: removed cal.ai email assistant (#15793)" (#15823) 2024-07-18 11:57:40 +00:00
Omar López 2af27170cd chore: v4.3.1 (#15817)
release.yml / release (push) Canceled after 0s
2024-07-18 13:09:30 +02:00
Joe Au-Yeungandzomars 218f6d6eaf feat: Salesforce - Disable creating new contacts (#15780)
Co-authored-by: zomars <zomars@me.com>
2024-07-18 10:50:42 +00:00
Hariom Balhara f0464e72c7 fix: Check URL (#15815) 2024-07-18 10:20:03 +00:00
Anik Dhabal BabuandPeer Richelsen 2ad3592eb3 chore: Introduce a new class WebAppURL that extends URL (#14982)
* chore: Introduce a new class WebAppURL that extends URL

* update

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-18 07:27:34 +00:00
Syed Ali ShahbazandMorgan Vernay 22fc47f69f feat: V2 Org Memberships (#15578)
* init

* --WIP

* WIP

* ...

* more wip

* doc

* address feedback --WIP

* further requested changes

* fix

* add missing parseIntPipe

* chore: code review comments + e2e tests

* fixup! chore: code review comments + e2e tests

* fixup! fixup! chore: code review comments + e2e tests

* fix: don't allow passing teamId for org membership create/update

* fix: improve docs and DTOs

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-07-18 03:43:20 +00:00
Morgan af3d2e6879 chore: set managed user locale via api v2 (#15794) 2024-07-17 12:56:37 +00:00
Morgan 58e61cb18e fix: findOrgUser use profiles instead of organizationId apiv2 (#15808) 2024-07-17 12:51:42 +00:00
NitinPSingh 9e3f8a2dc0 fix: Add signout button on onboarding screen (#15807)
* fix #15787 created signoutbtn on onboarding screen

* fix: Add signout button on onboarding screen and changes done as per feedback

* small error removed
2024-07-17 14:32:13 +02:00
Udit Takkar e960b8ad10 fix: fetch workflows on the backend (#15796)
* fix: fetch workflows on the router

* chore: remove workflows

* chore: import zod

* chore: move to repository
2024-07-17 07:02:22 +00:00
Peer Richelsen b7bfd77f95 readded border (#15798) 2024-07-16 15:46:40 +00:00
Peer Richelsen 2707adbb7b chore: removed cal.ai email assistant (#15793)
* removed cal-ai

* removed apps/ai
2024-07-16 14:21:00 +00:00
Morgan 5ef1b22c87 fix: remove permission blocking public apiv2 event-types endpoint (#15801) 2024-07-16 14:05:51 +00:00
Mohit YadavandKeith Williams 7e29db7a1b docs(contributing): change fork link (#15792)
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-16 14:25:06 +02:00
41cde8e384 feat: API org user scope (#15739)
* Init organizations users GET endpoint

* Fix get users endpoint

* Add create user input

* Add POST to organization user endpoint

* Creating a user make email mandatory

* Move DTOs out of platform types

* Use plainToInstance for data filtering

* POST create user and org membership

* Add email check

* Filter update user call

* Add getOrg decorator to endpoints

* Add update endpoint

* Add delete endpoint

* Fix merge changes to create new org user

* Init tests

* Send org signup email

* Abstract username check

* Implement email service

* WIP E2E test

* Rename methods

* Update update org user DTO

* Remove unused inputs

* chore: add ApiProperty decorator to users class validators

* Type fix

* Update tests

* chore: code review comments

* chore: code review comments

* chore: code review comments

---------

Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-07-16 10:50:46 +00:00
MorganandLauris Skraucis 9fec77e345 fix: creating organization team should add owner (#15779)
* chore: platform libraries versions cleanup api v2

* fix: create membership to created org team apiv2

* chore: add test check owner membership

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2024-07-16 10:31:29 +00:00
7046f6f80a feat: Fix Proton Calendar with ICS (#15454)
* feat: fix proton calendar with ics

* ics-feed: add note & clarify checkbox

* fix review

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-16 12:08:54 +02:00
Hariom Balhara 3f4a0eff7f Enable embed namespacing again (#15783) 2024-07-16 12:07:37 +02:00
Peer Richelsen d2768c4dc5 chore: Update /ee README.md (#15765)
* Update README.md

* Update README.md
2024-07-16 09:52:12 +00:00
470e0f53d8 feat: allow passing initial data props to calendar connectors (#15760)
* feat: allow passing init data to AvailabilitySettings, OAuthConnect wrappers

* chore: gitignore idea

* chore: minor fixes

* chore: use yarn.lock from main branch

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-16 09:47:50 +00:00
Lauris Skraucis 003af9a69d chore: specify platform-libraries version for event-types (#15785) 2024-07-16 09:47:37 +00:00
VijayandAmit Sharma 283ca9fc74 fix: #15749 Display availability page as per weekstart setting (#15753)
* update to display availability page as per weekstart setting

* update to accomodate any day

* update for AvailabilityTab

* reusable and tests added

* update test description

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-16 11:31:02 +02:00
Pranav RajveerandAmit Sharma 13ec930a61 fix: filter admin_api if org is present (#15386)
* fix: filter admin_api if org is present

* remove admin_api from developers tab when org is present

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
2024-07-16 11:30:39 +02:00
Lauris Skraucis cada4a58ac chore: add platform-libraries change log (#15786) 2024-07-16 10:53:26 +02:00
Lauris Skraucis 77519a077c refactor: allow system admins to create event-type without membership (#15774) 2024-07-16 06:08:28 +00:00
Nick Holden 1a5a539106 fix: higher resolution Campsite icon (#15777) 2024-07-16 04:21:34 +00:00
Morgan 539a2ea301 chore: platform libraries versions cleanup api v2 (#15768)
* chore: platform libraries versions cleanup api v2

* fixup! chore: platform libraries versions cleanup api v2
2024-07-15 17:03:15 +00:00
Alex van Andel c8b577fb7a chore: Release v4.3.0 (#15771)
release.yml / release (push) Canceled after 0s
2024-07-15 13:55:09 +00:00
Peer Richelsen 7f97d27c20 chore: Update README.md (#15766)
* Update README.md

* Update README.md

* Update README.md
2024-07-15 13:40:28 +02:00
Morgan 9ff10877e7 chore: fix apiv2 org teams doc (#15746) 2024-07-15 10:27:33 +00:00
Morgan eb0768917c chore: readme platform libraries (#15737)
* chore: readme platform libraries

* fixup! chore: readme platform libraries

* fixup! fixup! chore: readme platform libraries

* Update README.md

* Update README.md
2024-07-15 10:13:41 +00:00
ec65cd6333 fix: icon visibility and add up and down icons (#15543)
* fixed the icons visibility and added up and down icon

* wip: TimeUnitAddonSuffix

* chore: Quick pass for rerender-optimisation

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-07-15 09:35:48 +00:00
Udit Takkar 563141b295 chore: add check for booking (#15740)
* chore: add check

* chore: missting no shoe
2024-07-15 08:59:29 +00:00
Hariom Balhara 7b24972900 fix: Ensure unique username in an organization (#15730) 2024-07-15 06:11:24 +00:00
Somay Chauhan 6d2ec71df3 add missing getServerSideProps to /event-types (#15731) 2024-07-13 09:51:00 +00:00
492304fe1d feat: BookerEmbed atom (#15689)
* init: booker embed atom

* update booker platform wrapper

* export booker embed for atoms

* update booker embed wrapper

* fixup: refresh url not needed

* rename booker embed

* add global styles

* export not needed

* add booker embed atoms in examples app

* fixup

* fixup! fixup

* fixup! fixup! fixup

---------

Co-authored-by: Rajiv Sahal <rajivsahal@Rajivs-MacBook-Pro.local>
Co-authored-by: Morgan Vernay <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-07-12 11:10:32 +00:00
c7c5cdf133 feat: Campsite conferencing app (#15713)
* feat: Campsite conferencing app

* Improve redirect after OAuth flow

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2024-07-11 14:20:31 +00:00
Keith Williams 0dd3b25624 chore: v4.2.5 (#15735)
release.yml / release (push) Canceled after 0s
2024-07-11 15:03:37 +02:00
Morgan a633e35c66 fix: api v2 unit tests (#15733)
* fix: permissions guard spec

* fix: api auth strategy spec
2024-07-11 11:27:40 +00:00
sean-brydon c618e69d8e fix: event type back button (#15722) 2024-07-11 10:44:37 +00:00
Morgan 844dd7e3af fix: remove console.log and tsignore apiv2 (#15732) 2024-07-11 10:40:11 +00:00
Morgan b6c512e5d7 fix: api-v2 controllers e2e tests (#15724)
* fix: bookings controllers e2e tests

* fixup! fix: bookings controllers e2e tests

* fix: calendars  controllers e2e tests

* chore: move org teams controller files into folder

* fix: gcal controller e2e tests

* fix: oauth-clients.controller.e2e-spec

* fix: calendars  controllers e2e tests

* fix: gcal controller e2e tests

* fix: oauth-flow.controller.e2e-spec.ts
2024-07-11 13:14:45 +03:00
Ankit SinghandKeith Williams 6e0035689a fix: Text in Embed Code Visibility Fixed (#15711)
* Fix:#15707

* bg-subtle fixed

* Update packages/features/embed/lib/EmbedTabs.tsx

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-07-10 16:18:31 +00:00
58540af9ce chore: sort calendar crendentials in event manager (#15448)
* chore_sort_calendar_credentials_in_desc_for_reliability

* chore: create reusable function

* refactor: rename functions

* Update EventManager.ts

Variable name change

---------

Co-authored-by: Shaik-Sirajuddin <sirajudddinshaik30@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-10 16:16:26 +00:00
Morgan 21f30fc47a chore: cache org guard and fix roles guard apiv2 (#15719) 2024-07-10 15:46:04 +00:00
Amit Sharma 92845d3adc Revert "fix: Autodetection of time zone only updated default time zone (#15392)" (#15720)
This reverts commit 285d1ec4c9.
2024-07-10 15:28:41 +00:00
8ee27d3997 feat: add POST end point to mark calls as no-shows (#15690)
* feat: add POST end point to mark calls as no-shows

* fix: type

* feat: add v2 endpoint

* wip

* chore: type err

* chore: add permission

* chore: remove packae

* chore: remove v1 API endpoint

* fix: endpoint

* feat: add new version

* chore: change route

* chore: error msg

* fix: message

* fix: type err

* chore: output

* fix: type err

* chore: bump libraries to 0.0.17

* chore: add noShowHost

---------

Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Morgan Vernay <morgan@cal.com>
2024-07-10 13:01:10 +00:00
Morgan 1263519973 chore: add i18n to atoms (#15698)
* chore: add i18n to atoms

* fixup! chore: add i18n to atoms

* fixup! fixup! chore: add i18n to atoms

* fixup! Merge branch 'platform-atoms-i18n' of github.com:calcom/cal.com into platform-atoms-i18n

* fixup! Merge branch 'main' into platform-atoms-i18n
2024-07-10 12:34:41 +00:00
caf7943c91 fix: set conferencing apps as default (#15376)
* fix: set conferencing apps as default

* fix in other oauth conferencing apps

* creating default event-types should set the user's default location

* Update getDefaultLocations.ts

* fix: unit test

* default install only for first oauth app

* fix: type-check

* update default on success

* fix: pass callback prop

* chore: code cleanup

* Fix condition

* no longer needed

- as we reverted https://github.com/calcom/cal.com/commit/20fdb50081ae684723a5af78436068c0b09509a4

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-07-10 11:00:59 +00:00
Somay ChauhanandHariom Balhara fed3987028 fix: disable google-meet in user onboarding if google-calendar is not installed (#15702)
* fix: disable google-meet in user onboarding if google-calendar is not installed

* add tooltip

* fix: type check

* fix: type check for tooltip

- allowed `ReactNode` for tooltip content prop

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2024-07-10 10:34:14 +00:00
Hariom Balhara ad61d48706 fix: Rescheduling email when there is broken calendar integration (#15669) 2024-07-10 10:32:56 +01:00
Omar López aa20b9248a chore: v4.2.4 (#15703)
release.yml / release (push) Canceled after 0s
2024-07-09 14:09:04 +00:00
Keith Williams fa40f1ead5 fix: 404s becoming 500s (#15696) 2024-07-09 12:35:34 +02:00
Morgan 86676eb8af fix: delete reserved slot on booker unmount (#15700) 2024-07-09 10:08:06 +00:00
Morgan 3f9d1c1abf chore: add caching apiv2 roles guard (#15694) 2024-07-09 09:39:06 +00:00
5f8d090d3c refactor: handleNewBooking #3 (#15612)
* refactor: handleNewBooking #3

* fix: type error

* fix: type err

* fix: type err

* refactor: create booking factor

* chore: refactor availusers

* chore: type err

---------

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

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

---------

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

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

---------

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

* fix type-check

---------

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

* add save button for testing in dev mode

* add select all to multi select dropdown

* fix select all

* pass isOrg to WorkflowDetailsPage

* add checkbox to apply to all including future

* fix count text on select

* WIP schema

* shows teams in dropdown

* add team option to UI

* [WIP] refactor for update handler

* filter out "all" from activeOn

* fix type error

* create more helper functions

* create reusable function for scheduling all booking reminders

* finish update workflows for orgs (without testing)

* fix isAuthorized check for orgs

* fix getting newActiveOn

* move all helper functions to util file

* more code clean up

* fix deleting reminders for org workflows

* fix adding and editing steps

* update form data when workflow is saved

* code clean up

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

* fix setting activeOn for teams in useffect

* add on delete cascade

* make multiSelectSchenbox searchable

* set activeOn correctly when checkbox true

* WIP

* fix type errors in MultiSelectCheckboxes

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

* fix type errors

* type error

* add missing changes from merge conflict

* remote not needed include statement

* fix type errors

* code clean up + some todo comments

* support org workflows for cancelled workflows

* delete reminders from removed members

* remove reminders if isActiveOnAll is turned off

* fix unti test and type error

* code clean up

* create basis for testing to book org team event

* create org workflow with active team

* fix getting active org workflows on team + test setup

* fix creating workflow step for tests

* fix first org test

* add test for user event type with org workflow active

* use deleteAllWorkflowReminders everywhere

* add test for deleteRemindersFromRemovedActiveOn

* fix type errors

* make all tests pass

* fix type error

* fix getSchedule test

* code clean up

* add missing import

* fix type error

* fix tests

* code clean up

* fix imports

* update reminders when trigger was changed

* check for teamId before userId in reminderScheduler

* move getOrgIdFromMemberOrTeamId to different folder

* code clean up

* fix tests

* test setup for scheduleBookingReminders

* fix typo

* add tests for scheduleBookingReminders

* fix prisma default import

* fix workflowStep type

* add scheduleBookingReminders test for sms

* return dummy sid for scheduleSMS testMode

* clean up + fixes

* add lost changes from merge

* get teamId and userId from incoming evt object

* removing not needed select

* add org support for scheduleMandatory email reminder

* add other teams to dropdown

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

* include org wide workflows in createNewSeat

* some fixes + code clean up

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

* fix upsert and remove sms reminder field

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

* list active Org workflows in event workflows settings

* fix sms reminder field in all handlers

* add helper function to check if step was edited

* fix active on badge on workflow

* fix type error

* fix double reminders

* add teamId: null for userWorkflow query

* fix activeOnAll with managed event types

* add missing teamId in getAllWorkflows

* add a dafaut to prisma param

* fix managed event types on select all user workflows

* code clean up

* better variable name

* small fixes in update handler

* fix test name to match function name

* add info badge

* fix workflow count in event type settings

* fix getting bookings from children manged event types

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

* implement feedback

* fix disbale workflow in event type settings

* fix remove member

* create new function getAllWorkflowsFromEventType

* add some removed code

* use promise.allSettled when deleting workflow reminders

* create new function deleteWorkflowRemindersOfRemovedMember.ts

* fix userId param

* delete org worklfows when team is disbanded

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

* fix active on count badge

* add test for deleteWorkflowRemidnersOfRemovedMember

* trigger workflow also if not member of any subteam

* fix failing test

* remove unused code

* use testId for go back button

* fixes for managed event types & activateEventTypeHandler

* code clean up

* don't activate workflow on locked managed event type

* fix type error

* type error

* more type fixes

* feedback

---------

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

* Update

---------

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

* revert the changes

* redirect to google calendar page

* add translation key for "requires_google_calendar_to_install"

* use key and add correct message

* use key

* fix

* revert

* correct message

* refactor event type steup tab UI

* small fix

---------

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

* refactor: split into diff files and restructuring

* feat: finish batch processor finished

* chore: types

* fix: type error

* chore: change name of triggger

* test: add unit test

* fix: type

* fix: type err

* chore: test

* feat: add booking reference repository and other imp

* chore: log

* fix: import

* chore: type error

* fix: update test

* fix: test

* fix: test

* fix: add timeout

* chore: move beforeEach

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

---------

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

* fix: minor fix

* Update SettingsLayout.tsx

* Update SettingsLayout.tsx

* Update profile.tsx

* some fixes and e2e test

* fix: e2e test

---------

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

* fix: use single preview tab

* fix e2e

* add border radius

* final e2e fix

* fix: don't show preview tab component

---------

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

* fix: Regenerate yarn.lock with right deps

* update formbricks-js version to 2.1

* Update yarn.lock

---------

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

* update for true to false scenario

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

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

---------

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

* fix: use readonly variable

* fix: hiding for smaller devices

* update test cases

---------

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

* Update common.json

---------

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

* Use default colour

* Fix typo in german translation

* fix_the_conflict

---------

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

* Update bookingSuccessRedirect.ts

* Update bookingSuccessRedirect.ts

* fix: url params

* chore: remove unused

* chore: remove comment

* Update

---------

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

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

Fixes #15628

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

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

Fixes #15628

* fix: signup debounce not working properly

---------

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

* use requires_google_calendar translation key

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

* update

* update

* add checker function

* refactor and update

* update

* fix type error

* Update bookings-single-view.tsx

---------

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

* Update yarn.lock

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

* Update yarn.lock

* Fix git-setup.sh

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

* Added the types back so PR update triggers when labeled

---------

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

* fix: description text

---------

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

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

* chore: remove inline function

* chore: type error

* fix: type error

---------

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

* update env declaration file

---------

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

* Fix the test that was supposed to verify the scenario

---------

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

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

* WIP

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

* WIP

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

* Type fixes

* Update webhook.e2e.ts

* Update noShow.handler.ts

* Log failed results

* Updates tests

* Show generic error on frontend.

* test: add basic webhook service test

* fix: webhook end to end test

* fix: type error

* fix: test

---------

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

* refactor: add IsUserInOrg guard

* refactor: separate schedules controller folder

* tests

* chore: add roles guard and test it

* refactor: dont use Promise.all

* refactor: rely on profiles instead of user.organizationId

* feat: add pagination to getOrganizationSchedules

* refactor: default skip=0 and take=250

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

* update apple calendar service logic

* update typing for CalendarApp interface

* add id and type for apple calendar

* fixup: logic for apple calendar service

* update logic for checkIfCalendarConnected function

* add apple calendar service to calendars module

* fixup

* update calendars to include cases for apple calendar

* fix imports

* init frontend for apple calendar connect

* add apple connect atom to connect atom

* fixup

* fixup

* add alias for latest version of platform library

* fix import paths

* custom hook for saving apple calendar credentials

* add apple calendar to examples app

* add interface for credentials syncing calendars

* bring calendars controller to orginal state

* refactor

* update custom hook to sync credentials

* update atom

* resolve merge conflicts

* add endpoint for syncing calendar credentials

* fixup

* update atom

* update useCheck to invalidate queries onSubmit

* fix typo

* fixup

* remove unused tokens respository

* rename controller

* cleanup

* fix: reset react queryClient on access token change

---------

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

* chore: bitrate

* chore: add enable recording ui

* fix: genereate meeting token

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

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

* chore: add roles guard to endpoints

* add tests

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

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

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

* Cleanup code a little

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

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

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

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

---------

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

* Add invitationMemberHandler tests

* Add unit tests

* Improve tests and refactor

* Improve tests and refactor

* Fix type issue

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

* Revert "fix: office#65Video expiry_date"

This reverts commit 6142287805b5c6a40e746126735745a5617334c7.

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

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

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

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

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

* fix: type err

* chore: remove type

* chore: update label

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

* Fix types
2024-06-26 11:43:02 +00:00
Lauris Skraucis 9613b9da6d chore: IsUserInOrd guard (#15579) 2024-06-26 11:21:39 +00:00
Morgan ec755b142c chore: IsTeamInOrg guard and decorator apiv2 (#15567) 2024-06-26 07:32:49 +00:00
577 changed files with 26288 additions and 6379 deletions
+1
View File
@@ -180,6 +180,7 @@ API_KEY_PREFIX=cal_
# allow access to the nodemailer transports from the .env file. E-mail templates are accessible within lib/emails/
# Configures the global From: header whilst sending emails.
EMAIL_FROM='notifications@yourselfhostedcal.com'
EMAIL_FROM_NAME='Cal.com'
# Configure SMTP settings (@see https://nodemailer.com/smtp/).
# Configuration to receive emails locally (mailhog)
+79 -37
View File
@@ -1,39 +1,81 @@
---
name: Bug report
about: Report any issues with the platform
description: "Found a bug? Please fill out the sections below. \U0001F44D"
title: ""
labels: bug
assignees: ""
---
Found a bug? Please fill out the sections below. 👍
### Issue Summary
A summary of the issue. This needs to be a clear detailed-rich summary.
### Steps to Reproduce
1. (for example) Went to ...
2. Clicked on...
3. ...
Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?
### Actual Results
- What's happening right now that is different from what is expected
### Expected Results
- This is an ideal result that the system should get after the tests are performed
### Technical details
- Browser version, screen recording, console logs, network requests: You can make a recording with [Bird Eats Bug](https://birdeatsbug.com/).
- Node.js version
- Anything else that you think could be an issue.
### Evidence
- How was this tested? This is quite mandatory in terms of bugs. Providing evidence of your testing with screenshots or/and videos is an amazing way to prove the bug and a troubleshooting chance to find the solution.
labels: 🐛 bug
assignees: []
body:
- type: textarea
id: issue-summary
attributes:
label: Issue Summary
description: A summary of the issue. This needs to be a clear detailed-rich summary.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
value: |
1. (for example) Went to ...
2. Clicked on...
3. ...
validations:
required: true
- type: textarea
id: expected-behavior
attributes:
label: Expected behavior
description: A clear and concise description of what you expected to happen.
validations:
required: true
- type: textarea
id: other-information
attributes:
label: Other information
validations:
required: false
- type: textarea
id: screenshots
attributes:
label: Screenshots
description: If applicable, add screenshots to help explain your problem.
validations:
required: false
- type: checkboxes
id: environment
attributes:
label: Environment
options:
- label: app.cal.com
- label: Self-hosted Cal
- type: textarea
id: desktop-version
attributes:
label: Desktop (please complete the following information)
description: |
examples:
- **OS**: [e.g. iOS]
- **Browser**: [e.g. chrome, safari]
- **Version**: [e.g. 22]
value: |
- OS:
- Node:
- npm:
render: markdown
validations:
required: true
- type: markdown
id: nodejs-version
attributes:
value: |
#### Node.JS version
[e.g. v18.15.0]
- type: markdown
id: anything-else
attributes:
value: |
#### Anything else?
- Screen recording, console logs, network requests: You can make a recording with [Loom](https://www.loom.com).
- Anything else that you think could be an issue?
+38 -52
View File
@@ -1,55 +1,41 @@
---
name: Feature request
about: Suggest a feature or idea
description: "Suggest an idea for this project \U0001F680"
title: ""
labels: feature
assignees: ""
---
labels: ["✨ feature", "🚨 needs approval"],
assignees: []
body:
- type: textarea
id: problem-description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution-description
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternate-solution-description
attributes:
label: Describe alternatives you've considered
description: A clear and concise description of any alternative solutions or features you've considered.
validations:
required: false
- type: textarea
id: additional-context
attributes:
label: Additional context
description: Add any other context or screenshots about the feature request here.
validations:
required: false
- type: markdown
id: cal-info
attributes:
value: |
### How we code at Cal.com
### 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)
+3 -3
View File
@@ -11,9 +11,9 @@
## Mandatory Tasks (DO NOT REMOVE)
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected)
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com)
- [ ] I have added or modified automated tests that prove my fix is effective or that my feature works (PRs might be rejected if logical changes are not properly tested)
- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have added a Docs issue [here](https://github.com/calcom/docs/issues/new) if this PR makes changes that would require a [documentation change](https://docs.cal.com). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.
## How should this be tested?
+2 -1
View File
@@ -17,6 +17,7 @@ runs:
cache-name: cache-db
key-1: ${{ hashFiles('packages/prisma/schema.prisma', 'packages/prisma/migrations/**/**.sql', 'packages/prisma/*.ts') }}
key-2: ${{ github.event.pull_request.number || github.ref }}
key-3: ${{ github.event.pull_request.head.sha }}
DATABASE_URL: ${{ inputs.DATABASE_URL }}
DATABASE_DIRECT_URL: ${{ inputs.DATABASE_URL }}
E2E_TEST_CALCOM_QA_EMAIL: ${{ inputs.E2E_TEST_CALCOM_QA_EMAIL }}
@@ -24,7 +25,7 @@ runs:
E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS: ${{ inputs.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }}
with:
path: ${{ inputs.path }}
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}
key: ${{ runner.os }}-${{ env.cache-name }}-${{ inputs.path }}-${{ env.key-1 }}-${{ env.key-2 }}-${{ env.key-3 }}
- run: echo ${{ env.E2E_TEST_CALCOM_QA_GCAL_CREDENTIALS }} && yarn db-seed
if: steps.cache-db.outputs.cache-hit != 'true'
shell: bash
-54
View File
@@ -1,54 +0,0 @@
name: PR Labeled with ready-for-e2e
on:
pull_request_target:
types: [labeled]
branches:
- main
- gh-actions-test-branch
workflow_dispatch:
jobs:
run-e2e-jobs:
name: Run E2E Jobs
runs-on: buildjet-2vcpu-ubuntu-2204
permissions:
pull-requests: read
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/dangerous-git-checkout
- name: Check Label and Retrigger Checks
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const labelName = context.payload.label.name;
const prNumber = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
if (labelName === 'ready-for-e2e') {
console.log(`Running E2E jobs for PR #${prNumber}...`);
const checkRuns = await github.rest.checks.listForRef({
owner,
repo,
ref: context.payload.pull_request.head.sha,
});
for (const check of checkRuns.data.check_runs) {
console.log('check.name', check.name);
if (check.name.includes('e2e')) {
await github.rest.actions.reRunJob({
owner,
repo,
job_id: check.id,
});
console.log(`Triggering job #${check.id}`);
}
}
console.log(`Triggered E2E checks for PR #${prNumber}`);
} else {
console.log(`Label ${labelName} does not trigger re-running checks.`);
}
+17 -15
View File
@@ -2,6 +2,7 @@ name: PR Update
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
branches:
- main
- gh-actions-test-branch
@@ -105,13 +106,6 @@ jobs:
uses: ./.github/workflows/unit-tests.yml
secrets: inherit
integration-test:
name: Tests
needs: [changes, check-label]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
build-api-v1:
name: Production builds
needs: [changes, check-label]
@@ -133,30 +127,37 @@ jobs:
uses: ./.github/workflows/production-build-without-database.yml
secrets: inherit
integration-test:
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/integration-tests.yml
secrets: inherit
e2e:
name: e2e
needs: [changes, check-label, build]
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e.yml
secrets: inherit
e2e-app-store:
name: e2e-app-store
needs: [changes, check-label, build]
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-app-store.yml
secrets: inherit
e2e-embed:
name: e2e-embed
needs: [changes, check-label, build]
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed.yml
secrets: inherit
e2e-embed-react:
name: e2e-embed-react
needs: [changes, check-label, build]
name: Tests
needs: [changes, check-label, build, build-api-v1, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
@@ -169,6 +170,7 @@ jobs:
required:
needs: [changes, lint, type-check, unit-test, integration-test, check-label, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
- name: fail if conditional jobs failed
+1 -1
View File
@@ -74,7 +74,7 @@ branch are tagged into a release monthly.
To develop locally:
1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your
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:
+31 -1
View File
@@ -392,7 +392,37 @@ Currently Vercel Pro Plan is required to be able to Deploy this application with
See the [roadmap project](https://cal.com/roadmap) for a list of proposed features (and known issues). You can change the view to see planned tagged releases.
<!-- RORADMAP -->
<!-- LICENSE -->
## License
Cal.com, Inc. is a commercial open source company, which means some parts of this open source repository require a commercial license. The concept is called "Open Core" where the core technology (99%) is fully open source, licensed under [AGPLv3](https://opensource.org/license/agpl-v3) and the last 1% is covered under a commercial license (["/ee" Enterprise Edition](https://github.com/calcom/cal.com/tree/main/packages/features/ee)) which we believe is entirely relevant for larger organisations that require enterprise features. Enterprise features are built by the core engineering team of Cal.com, Inc. which is hired in full-time. Find their compensation on https://cal.com/open.
> [!NOTE]
> Our philosophy is simple, all "Singleplayer APIs" are open-source under AGPLv3. All commercial "Multiplayer APIs" are under a commercial license.
| | AGPLv3 | EE |
|---|---|---|
| Self-host for commercial purposes | ✅ | ✅ |
| Clone privately | ✅ | ✅ |
| Fork publicly | ✅ | ✅ |
| Requires CLA | ✅ | ✅ |
| Official Support| ❌ | ✅ |
| Derivative work privately | ❌ | ✅ |
| SSO | ❌ | ✅ |
| Admin Panel | ❌ | ✅ |
| Impersonation | ❌ | ✅ |
| Managed Event Types | ❌ | ✅ |
| Organizations | ❌ | ✅ |
| Payments | ❌ | ✅ |
| Platform | ❌ | ✅ |
| Teams | ❌ | ✅ |
| Users | ❌ | ✅ |
| Video | ❌ | ✅ |
| Workflows | ❌ | ✅ |
> [!TIP]
> We work closely with the community and always invite feedback about what should be open and what is fine to be commercial. This list is not set and stone and we have moved things from commercial to open in the past. Please open a [discussion](https://github.com/calcom/cal.com/discussions) if you feel like something is wrong.
## Repo Activity
+2
View File
@@ -21,6 +21,8 @@ export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(sch
export const schemaBookingGetParams = z.object({
dateFrom: iso8601.optional(),
dateTo: iso8601.optional(),
order: z.enum(["asc", "desc"]).default("asc"),
sortBy: z.enum(["createdAt", "updatedAt"]).optional(),
});
const schemaBookingEditParams = z
+25 -1
View File
@@ -51,6 +51,18 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
* type: string
* format: email
* example: [john.doe@example.com, jane.doe@example.com]
* - in: query
* name: order
* required: false
* schema:
* type: string
* enum: [asc, desc]
* - in: query
* name: sortBy
* required: false
* schema:
* type: string
* enum: [createdAt, updatedAt]
* operationId: listBookings
* tags:
* - bookings
@@ -177,7 +189,7 @@ export async function handler(req: NextApiRequest) {
isOrganizationOwnerOrAdmin,
pagination: { take, skip },
} = req;
const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query);
const { dateFrom, dateTo, order, sortBy } = schemaBookingGetParams.parse(req.query);
const args: Prisma.BookingFindManyArgs = {};
if (req.query.take && req.query.page) {
@@ -251,6 +263,18 @@ export async function handler(req: NextApiRequest) {
};
}
if (sortBy === "updatedAt") {
args.orderBy = {
updatedAt: order,
};
}
if (sortBy === "createdAt") {
args.orderBy = {
createdAt: order,
};
}
const data = await prisma.booking.findMany(args);
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
}
-1
View File
@@ -43,7 +43,6 @@ async function postHandler(req: NextApiRequest, res: NextApiResponse) {
language: data.language,
teamId: data.teamId,
usernameOrEmail: data.usernameOrEmail,
isOrg: data.isOrg,
});
return { success: true, message: `${data.usernameOrEmail} has been invited.` };
@@ -0,0 +1,112 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prismaMock";
import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, test } from "vitest";
import handler from "../../../pages/api/attendees/_post";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
describe("POST /api/attendees", () => {
describe("Errors", () => {
test("Returns 403 if user is not admin and has no booking", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
bookingId: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
},
});
prismaMock.booking.findFirst.mockResolvedValue(null);
req.userId = 123;
// req.isAdmin = false;
await handler(req, res);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res._getData()).message).toBe("Forbidden");
});
test("Returns 200 if user is admin", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
bookingId: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
},
});
const attendeeData = {
id: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
bookingId: 1,
};
prismaMock.attendee.create.mockResolvedValue(attendeeData);
req.isSystemWideAdmin = true;
req.userId = 123;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData);
expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully");
});
test("Returns 200 if user is not admin but has a booking", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
bookingId: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
},
});
const userBooking = { id: 1 };
prismaMock.booking.findFirst.mockResolvedValue(userBooking);
const attendeeData = {
id: 1,
email: "test@example.com",
name: "Test User",
timeZone: "UTC",
bookingId: 1,
};
prismaMock.attendee.create.mockResolvedValue(attendeeData);
req.userId = 123;
await handler(req, res);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res._getData()).attendee).toEqual(attendeeData);
expect(JSON.parse(res._getData()).message).toBe("Attendee created successfully");
});
test("Returns 400 if request body is invalid", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
// Missing required fields
},
});
req.userId = 123;
await handler(req, res);
expect(res.statusCode).toBe(400);
});
});
});
+1 -2
View File
@@ -25,9 +25,8 @@
},
"dependencies": {
"@calcom/platform-constants": "*",
"@calcom/platform-libraries-0.0.13": "npm:@calcom/platform-libraries@0.0.13",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-libraries-0.0.4": "npm:@calcom/platform-libraries@0.0.4",
"@calcom/platform-libraries-0.0.20": "npm:@calcom/platform-libraries@0.0.20",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -19,7 +19,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.2";
import { handleNewBooking } from "@calcom/platform-libraries-0.0.20";
import { ApiSuccessResponse, ApiResponse } from "@calcom/platform-types";
describe("Bookings Endpoints", () => {
@@ -139,7 +139,6 @@ describe("Bookings Endpoints", () => {
return request(app.getHttpServer())
.get("/v2/bookings?filters[status]=upcoming")
.then((response) => {
console.log("asap responseBody", JSON.stringify(response.body, null, 2));
const responseBody: GetBookingsOutput = response.body;
const fetchedBooking = responseBody.data.bookings[0];
@@ -240,7 +239,8 @@ describe("Bookings Endpoints", () => {
// });
// });
it("should cancel a booking", async () => {
// cancelling a booking hangs the test for some reason
it.skip("should cancel a booking", async () => {
const bookingId = createdBooking.id;
const body = {
@@ -1,7 +1,9 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { CreateRecurringBookingInput } from "@/ee/bookings/inputs/create-recurring-booking.input";
import { MarkNoShowInput } from "@/ee/bookings/inputs/mark-no-show.input";
import { GetBookingOutput } from "@/ee/bookings/outputs/get-booking.output";
import { GetBookingsOutput } from "@/ee/bookings/outputs/get-bookings.output";
import { MarkNoShowOutput } from "@/ee/bookings/outputs/mark-no-show.output";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
@@ -32,20 +34,19 @@ import { Request } from "express";
import { NextApiRequest } from "next/types";
import { X_CAL_CLIENT_ID } from "@calcom/platform-constants";
import { BOOKING_READ, SUCCESS_STATUS } from "@calcom/platform-constants";
import {
getAllUserBookings,
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries-0.0.2";
import { BOOKING_READ, SUCCESS_STATUS, BOOKING_WRITE } from "@calcom/platform-constants";
import {
handleNewBooking,
BookingResponse,
HttpError,
handleNewRecurringBooking,
handleInstantMeeting,
} from "@calcom/platform-libraries-0.0.2";
handleMarkNoShow,
getAllUserBookings,
getBookingInfo,
handleCancelBooking,
getBookingForReschedule,
} from "@calcom/platform-libraries-0.0.20";
import { GetBookingsInput, CancelBookingInput, Status } from "@calcom/platform-types";
import { ApiResponse } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -194,6 +195,27 @@ export class BookingsController {
throw new InternalServerErrorException("Could not cancel booking.");
}
@Post("/:bookingUid/mark-no-show")
@Permissions([BOOKING_WRITE])
@UseGuards(ApiAuthGuard)
async markNoShow(
@Body() body: MarkNoShowInput,
@Param("bookingUid") bookingUid: string
): Promise<MarkNoShowOutput> {
try {
const markNoShowResponse = await handleMarkNoShow({
bookingUid: bookingUid,
attendees: body.attendees,
noShowHost: body.noShowHost,
});
return { status: SUCCESS_STATUS, data: markNoShowResponse };
} catch (err) {
this.handleBookingErrors(err, "no-show");
}
throw new InternalServerErrorException("Could not mark no show.");
}
@Post("/recurring")
async createRecurringBooking(
@Req() req: BookingRequest,
@@ -287,8 +309,14 @@ export class BookingsController {
return req as unknown as NextApiRequest & { userId?: number } & OAuthRequestParams;
}
private handleBookingErrors(err: Error | HttpError | unknown, type?: "recurring" | `instant`): void {
const errMsg = `Error while creating ${type ? type + " " : ""}booking.`;
private handleBookingErrors(
err: Error | HttpError | unknown,
type?: "recurring" | `instant` | "no-show"
): void {
const errMsg =
type === "no-show"
? `Error while marking no-show.`
: `Error while creating ${type ? type + " " : ""}booking.`;
if (err instanceof HttpError) {
const httpError = err as HttpError;
throw new HttpException(httpError?.message ?? errMsg, httpError?.statusCode ?? 500);
@@ -1,7 +1,7 @@
import { CreateBookingInput } from "@/ee/bookings/inputs/create-booking.input";
import { IsBoolean, IsNumber, IsOptional } from "class-validator";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.2";
import type { AppsStatus } from "@calcom/platform-libraries-0.0.20";
export class CreateRecurringBookingInput extends CreateBookingInput {
@IsBoolean()
@@ -0,0 +1,22 @@
import { Type } from "class-transformer";
import { IsOptional, IsArray, IsEmail, IsBoolean, ValidateNested } from "class-validator";
class Attendee {
@IsEmail()
email!: string;
@IsBoolean()
noShow!: boolean;
}
export class MarkNoShowInput {
@IsBoolean()
@IsOptional()
noShowHost?: boolean;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
attendees?: Attendee[];
}
@@ -0,0 +1,45 @@
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsString, IsEnum, IsOptional, ValidateNested, IsArray, IsEmail, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
class Attendee {
@IsEmail()
email!: string;
@IsBoolean()
noShow!: boolean;
}
class HandleMarkNoShowData {
@IsString()
message!: string;
@IsBoolean()
@IsOptional()
noShowHost?: boolean;
@IsString()
@IsOptional()
messageKey?: string;
@ValidateNested()
@Type(() => Attendee)
@IsArray()
@IsOptional()
attendees?: Attendee[];
}
export class MarkNoShowOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: HandleMarkNoShowData,
})
@ValidateNested()
@Type(() => HandleMarkNoShowData)
data!: HandleMarkNoShowData;
}
@@ -7,6 +7,11 @@ export interface CalendarApp {
check(userId: number): Promise<ApiResponse>;
}
export interface CredentialSyncCalendarApp {
save(userId: number, userEmail: string, username: string, password: string): Promise<{ status: string }>;
check(userId: number): Promise<ApiResponse>;
}
export interface OAuthCalendarApp extends CalendarApp {
connect(authorization: string, req: Request): Promise<ApiResponse<{ authUrl: string }>>;
}
@@ -1,4 +1,6 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
@@ -17,8 +19,10 @@ import { Module } from "@nestjs/common";
CalendarsService,
OutlookService,
GoogleCalendarService,
AppleCalendarService,
SelectedCalendarsRepository,
AppsRepository,
CalendarsRepository,
],
controllers: [CalendarsController],
exports: [CalendarsService],
@@ -0,0 +1,51 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { Prisma } from "@prisma/client";
const credentialForCalendarRepositorySelect = Prisma.validator<Prisma.CredentialSelect>()({
id: true,
appId: true,
type: true,
userId: true,
user: {
select: {
email: true,
},
},
teamId: true,
key: true,
invalid: true,
});
@Injectable()
export class CalendarsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async getCalendarCredentials(credentialId: number, userId: number) {
return await this.dbRead.prisma.credential.findFirst({
where: {
id: credentialId,
userId,
},
select: {
...credentialForCalendarRepositorySelect,
app: {
select: {
slug: true,
categories: true,
dirName: true,
},
},
},
});
}
async deleteCredentials(credentialId: number) {
return await this.dbWrite.prisma.credential.delete({
where: {
id: credentialId,
},
});
}
}
@@ -1,5 +1,6 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { DeletedCalendarCredentialsOutputResponseDto } from "@/ee/calendars/outputs/delete-calendar-credentials.output";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
@@ -16,7 +17,9 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
@@ -27,20 +30,6 @@ import { OFFICE_365_CALENDAR_ID, OFFICE_365_CALENDAR_TYPE } from "@calcom/platfo
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Calendars Endpoints", () => {
let app: INestApplication;
@@ -66,8 +55,7 @@ describe("Platform Calendars Endpoints", () => {
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
@@ -85,6 +73,9 @@ describe("Platform Calendars Endpoints", () => {
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
await app.init();
jest
.spyOn(CalendarsService.prototype, "getCalendars")
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
});
async function createOAuthClient(organizationId: number) {
@@ -200,6 +191,26 @@ describe("Platform Calendars Endpoints", () => {
.expect(200);
});
it.skip(`/POST/v2/calendars/${OFFICE_365_CALENDAR}/disconnect: it should respond with a 201 returning back the user deleted calendar credentials`, async () => {
const body = {
id: 10,
};
return request(app.getHttpServer())
.post(`/v2/calendars/${OFFICE_365_CALENDAR}/disconnect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: Promise<DeletedCalendarCredentialsOutputResponseDto> = response.body;
expect((await responseBody).status).toEqual(SUCCESS_STATUS);
expect((await responseBody).data).toBeDefined();
expect((await responseBody).data.id).toEqual(body.id);
expect((await responseBody).data.userId).toEqual(user.id);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -1,5 +1,11 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { GetBusyTimesOutput } from "@/ee/calendars/outputs/busy-times.output";
import { ConnectedCalendarsOutput } from "@/ee/calendars/outputs/connected-calendars.output";
import {
DeletedCalendarCredentialsOutputResponseDto,
DeletedCalendarCredentialsOutputDto,
} from "@/ee/calendars/outputs/delete-calendar-credentials.output";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { OutlookService } from "@/ee/calendars/services/outlook.service";
@@ -21,13 +27,23 @@ import {
Headers,
Redirect,
BadRequestException,
Post,
Body,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { User } from "@prisma/client";
import { plainToClass } from "class-transformer";
import { Request } from "express";
import { z } from "zod";
import { APPS_READ } from "@calcom/platform-constants";
import { SUCCESS_STATUS, CALENDARS, GOOGLE_CALENDAR, OFFICE_365_CALENDAR } from "@calcom/platform-constants";
import {
SUCCESS_STATUS,
CALENDARS,
GOOGLE_CALENDAR,
OFFICE_365_CALENDAR,
APPLE_CALENDAR,
} from "@calcom/platform-constants";
import { ApiResponse, CalendarBusyTimesInput } from "@calcom/platform-types";
@Controller({
@@ -39,7 +55,9 @@ export class CalendarsController {
constructor(
private readonly calendarsService: CalendarsService,
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService
private readonly googleCalendarService: GoogleCalendarService,
private readonly appleCalendarService: AppleCalendarService,
private readonly calendarsRepository: CalendarsRepository
) {}
@UseGuards(ApiAuthGuard)
@@ -133,6 +151,26 @@ export class CalendarsController {
}
}
@UseGuards(ApiAuthGuard)
@Post("/:calendar/credentials")
async syncCredentials(
@GetUser() user: User,
@Param("calendar") calendar: string,
@Body() body: { username: string; password: string }
): Promise<{ status: string }> {
const { username, password } = body;
switch (calendar) {
case APPLE_CALENDAR:
return await this.appleCalendarService.save(user.id, user.email, username, password);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
CALENDARS.join(", ")
);
}
}
@Get("/:calendar/check")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard, PermissionsGuard)
@@ -143,6 +181,8 @@ export class CalendarsController {
return await this.outlookService.check(userId);
case GOOGLE_CALENDAR:
return await this.googleCalendarService.check(userId);
case APPLE_CALENDAR:
return await this.appleCalendarService.check(userId);
default:
throw new BadRequestException(
"Invalid calendar type, available calendars are: ",
@@ -150,4 +190,29 @@ export class CalendarsController {
);
}
}
@UseGuards(ApiAuthGuard)
@Post("/:calendar/disconnect")
@HttpCode(HttpStatus.OK)
async deleteCalendarCredentials(
@Param("calendar") calendar: string,
@Body() body: { id: number },
@GetUser() user: UserWithProfile
): Promise<DeletedCalendarCredentialsOutputResponseDto> {
const { id: credentialId } = body;
await this.calendarsService.checkCalendarCredentials(credentialId, user.id);
const { id, type, userId, teamId, appId, invalid } = await this.calendarsRepository.deleteCredentials(
credentialId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(
DeletedCalendarCredentialsOutputDto,
{ id, type, userId, teamId, appId, invalid },
{ strategy: "excludeAll" }
),
};
}
}
@@ -0,0 +1,42 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum, IsInt, IsBoolean } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class DeletedCalendarCredentialsOutputDto {
@IsInt()
@Expose()
readonly id!: number;
@IsString()
@Expose()
readonly type!: string;
@IsInt()
@Expose()
readonly userId!: number | null;
@IsInt()
@Expose()
readonly teamId!: number | null;
@IsString()
@Expose()
readonly appId!: string | null;
@IsBoolean()
@Expose()
readonly invalid!: boolean | null;
}
export class DeletedCalendarCredentialsOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => DeletedCalendarCredentialsOutputDto)
data!: DeletedCalendarCredentialsOutputDto;
}
@@ -0,0 +1,92 @@
import { CredentialSyncCalendarApp } from "@/ee/calendars/calendars.interface";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { SUCCESS_STATUS, APPLE_CALENDAR_TYPE, APPLE_CALENDAR_ID } from "@calcom/platform-constants";
import { symmetricEncrypt, CalendarService } from "@calcom/platform-libraries-0.0.20";
@Injectable()
export class AppleCalendarService implements CredentialSyncCalendarApp {
constructor(
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository
) {}
async save(
userId: number,
userEmail: string,
username: string,
password: string
): Promise<{ status: string }> {
return await this.saveCalendarCredentials(userId, userEmail, username, password);
}
async check(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
return await this.checkIfCalendarConnected(userId);
}
async checkIfCalendarConnected(userId: number): Promise<{ status: typeof SUCCESS_STATUS }> {
const appleCalendarCredentials = await this.credentialRepository.getByTypeAndUserId(
APPLE_CALENDAR_TYPE,
userId
);
if (!appleCalendarCredentials) {
throw new BadRequestException("Credentials for apple calendar not found.");
}
if (appleCalendarCredentials.invalid) {
throw new BadRequestException("Invalid apple calendar credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
const appleCalendar = connectedCalendars.find(
(cal: { integration: { type: string } }) => cal.integration.type === APPLE_CALENDAR_TYPE
);
if (!appleCalendar) {
throw new UnauthorizedException("Apple calendar not connected.");
}
if (appleCalendar.error?.message) {
throw new UnauthorizedException(appleCalendar.error?.message);
}
return {
status: SUCCESS_STATUS,
};
}
async saveCalendarCredentials(userId: number, userEmail: string, username: string, password: string) {
if (username.length <= 1 || password.length <= 1)
throw new BadRequestException(`Username or password cannot be empty`);
const data = {
type: APPLE_CALENDAR_TYPE,
key: symmetricEncrypt(
JSON.stringify({ username, password }),
process.env.CALENDSO_ENCRYPTION_KEY || ""
),
userId: userId,
teamId: null,
appId: APPLE_CALENDAR_ID,
invalid: false,
};
try {
const dav = new CalendarService({
id: 0,
...data,
user: { email: userEmail },
});
await dav?.listCalendars();
await this.credentialRepository.createAppCredential(APPLE_CALENDAR_TYPE, data.key, userId);
} catch (reason) {
throw new BadRequestException(`Could not add this apple calendar account: ${reason}`);
}
return {
status: SUCCESS_STATUS,
};
}
}
@@ -1,3 +1,4 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { AppsRepository } from "@/modules/apps/apps.repository";
import {
CredentialsRepository,
@@ -17,8 +18,7 @@ import { User } from "@prisma/client";
import { DateTime } from "luxon";
import { z } from "zod";
import { getConnectedDestinationCalendars } from "@calcom/platform-libraries-0.0.2";
import { getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.2";
import { getConnectedDestinationCalendars, getBusyCalendarTimes } from "@calcom/platform-libraries-0.0.20";
import { Calendar } from "@calcom/platform-types";
import { PrismaClient } from "@calcom/prisma";
@@ -30,6 +30,7 @@ export class CalendarsService {
private readonly usersRepository: UsersRepository,
private readonly credentialsRepository: CredentialsRepository,
private readonly appsRepository: AppsRepository,
private readonly calendarsRepository: CalendarsRepository,
private readonly dbRead: PrismaReadService,
private readonly dbWrite: PrismaWriteService,
private readonly config: ConfigService
@@ -137,4 +138,11 @@ export class CalendarsService {
return { client_id, client_secret };
}
async checkCalendarCredentials(credentialId: number, userId: number) {
const credential = await this.calendarsRepository.getCalendarCredentials(credentialId, userId);
if (!credential) {
throw new NotFoundException("Calendar credentials not found");
}
}
}
@@ -35,7 +35,7 @@ import {
EventTypesPublic,
eventTypeBookingFields,
eventTypeLocations,
} from "@calcom/platform-libraries-0.0.2";
} from "@calcom/platform-libraries-0.0.20";
import { ApiSuccessResponse } from "@calcom/platform-types";
describe("Event types Endpoints", () => {
@@ -39,8 +39,8 @@ import {
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { EVENT_TYPE_READ, EVENT_TYPE_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
import { getPublicEvent } from "@calcom/platform-libraries-0.0.2";
import { getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import { getPublicEvent, getEventTypesByViewer } from "@calcom/platform-libraries-0.0.2";
import { PrismaClient } from "@calcom/prisma";
@Controller({
@@ -4,7 +4,7 @@ import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.2";
import { getEventTypeById } from "@calcom/platform-libraries-0.0.20";
import type { PrismaClient } from "@calcom/prisma";
@Injectable()
@@ -9,8 +9,12 @@ import { SelectedCalendarsRepository } from "@/modules/selected-calendars/select
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.2";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.2";
import {
createEventType,
updateEventType,
EventTypesPublic,
getEventTypesPublic,
} from "@calcom/platform-libraries-0.0.20";
import { EventType } from "@calcom/prisma/client";
@Injectable()
@@ -56,6 +60,7 @@ export class EventTypesService_2024_04_15 {
const profileId = user.movedToProfile?.id || null;
return {
id: user.id,
role: user.role,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
@@ -9,10 +9,11 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { PlatformOAuthClient, Team, User, Schedule } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.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";
@@ -63,12 +64,18 @@ describe("Event types Endpoints", () => {
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
const userEmail = "event-types-test-e2e@api.com";
const falseTestEmail = "false-event-types@api.com";
const name = "bob-the-builder";
const username = name;
let eventType: EventTypeOutput_2024_06_14;
let user: User;
let falseTestUser: User;
let firstSchedule: Schedule;
let secondSchedule: Schedule;
let falseTestSchedule: Schedule;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -91,6 +98,7 @@ describe("Event types Endpoints", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepostoryFixture = new SchedulesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
oAuthClient = await createOAuthClient(organization.id);
@@ -100,6 +108,30 @@ describe("Event types Endpoints", () => {
username,
});
falseTestUser = await userRepositoryFixture.create({
email: falseTestEmail,
name: "false-test",
username: falseTestEmail,
});
firstSchedule = await schedulesRepostoryFixture.create({
userId: user.id,
name: "work",
timeZone: "Europe/Rome",
});
secondSchedule = await schedulesRepostoryFixture.create({
userId: user.id,
name: "chill",
timeZone: "Europe/Rome",
});
falseTestSchedule = await schedulesRepostoryFixture.create({
userId: falseTestUser.id,
name: "work",
timeZone: "Europe/Rome",
});
await app.init();
});
@@ -123,6 +155,40 @@ describe("Event types Endpoints", () => {
expect(user).toBeDefined();
});
it("should not allow creating an event type with schedule user does not own", async () => {
const scheduleId = falseTestSchedule.id;
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class",
slug: "coding-class",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
bookingFields: [
{
type: "select",
label: "select which language you want to learn",
slug: "select-language",
required: true,
placeholder: "select language",
options: ["javascript", "python", "cobol"],
},
],
scheduleId,
};
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(404);
});
it("should create an event type", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class",
@@ -145,6 +211,7 @@ describe("Event types Endpoints", () => {
options: ["javascript", "python", "cobol"],
},
],
scheduleId: firstSchedule.id,
};
return request(app.getHttpServer())
@@ -162,6 +229,7 @@ describe("Event types Endpoints", () => {
expect(createdEventType.locations).toEqual(body.locations);
expect(createdEventType.bookingFields).toEqual(body.bookingFields);
expect(createdEventType.ownerId).toEqual(user.id);
expect(createdEventType.scheduleId).toEqual(firstSchedule.id);
eventType = responseBody.data;
});
@@ -172,6 +240,7 @@ describe("Event types Endpoints", () => {
const body: UpdateEventTypeInput_2024_06_14 = {
title: newTitle,
scheduleId: secondSchedule.id,
};
return request(app.getHttpServer())
@@ -191,11 +260,25 @@ describe("Event types Endpoints", () => {
expect(updatedEventType.locations).toEqual(eventType.locations);
expect(updatedEventType.bookingFields).toEqual(eventType.bookingFields);
expect(updatedEventType.ownerId).toEqual(user.id);
expect(updatedEventType.scheduleId).toEqual(secondSchedule.id);
eventType.title = newTitle;
eventType.scheduleId = secondSchedule.id;
});
});
it("should not allow to update event type with scheduleId user does not own", async () => {
const body: UpdateEventTypeInput_2024_06_14 = {
scheduleId: falseTestSchedule.id,
};
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(404);
});
it(`/GET/:id`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
@@ -272,7 +355,7 @@ describe("Event types Endpoints", () => {
.expect(404);
});
it("should delete schedule", async () => {
it("should delete event type", async () => {
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
});
@@ -289,6 +372,11 @@ describe("Event types Endpoints", () => {
} catch (e) {
// User might have been deleted by the test
}
try {
await userRepositoryFixture.delete(falseTestUser.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
@@ -77,7 +77,6 @@ export class EventTypesController_2024_06_14 {
}
@Get("/")
@Permissions([EVENT_TYPE_READ])
async getEventTypes(
@Query() queryParams: GetEventTypesQuery_2024_06_14
): Promise<GetEventTypesOutput_2024_06_14> {
@@ -3,6 +3,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
@@ -20,8 +21,14 @@ import { Module } from "@nestjs/common";
OutputEventTypesService_2024_06_14,
UsersRepository,
UsersService,
SchedulesRepository_2024_06_11,
],
controllers: [EventTypesController_2024_06_14],
exports: [EventTypesService_2024_06_14, EventTypesRepository_2024_06_14],
exports: [
EventTypesService_2024_06_14,
EventTypesRepository_2024_06_14,
InputEventTypesService_2024_06_14,
OutputEventTypesService_2024_06_14,
],
})
export class EventTypesModule_2024_06_14 {}
@@ -7,7 +7,7 @@ import {
getEventTypeById,
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.13";
} from "@calcom/platform-libraries-0.0.20";
import { CreateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
import type { PrismaClient } from "@calcom/prisma";
@@ -2,6 +2,7 @@ import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_06_14/con
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
@@ -9,9 +10,9 @@ import { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile, UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { createEventType, updateEventType } from "@calcom/platform-libraries";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.13";
import { createEventType, updateEventType } from "@calcom/platform-libraries-0.0.20";
import { getEventTypesPublic, EventTypesPublic } from "@calcom/platform-libraries-0.0.20";
import { dynamicEvent } from "@calcom/platform-libraries-0.0.20";
import {
CreateEventTypeInput_2024_06_14,
UpdateEventTypeInput_2024_06_14,
@@ -30,7 +31,8 @@ export class EventTypesService_2024_06_14 {
private readonly usersRepository: UsersRepository,
private readonly usersService: UsersService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly dbWrite: PrismaWriteService
private readonly dbWrite: PrismaWriteService,
private readonly schedulesRepository: SchedulesRepository_2024_06_11
) {}
async createUserEventType(user: UserWithProfile, body: CreateEventTypeInput_2024_06_14) {
@@ -61,6 +63,7 @@ export class EventTypesService_2024_06_14 {
if (existsWithSlug) {
throw new BadRequestException("User already has an event type with this slug.");
}
await this.checkUserOwnsSchedule(userId, body.scheduleId);
}
async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
@@ -94,6 +97,7 @@ export class EventTypesService_2024_06_14 {
const profileId = user.movedToProfile?.id || null;
return {
id: user.id,
role: user.role,
organizationId: user.organizationId,
organization: { isOrgAdmin },
profile: { id: profileId },
@@ -202,7 +206,7 @@ export class EventTypesService_2024_06_14 {
}
async updateEventType(eventTypeId: number, body: UpdateEventTypeInput_2024_06_14, user: UserWithProfile) {
this.checkCanUpdateEventType(user.id, eventTypeId);
await this.checkCanUpdateEventType(user.id, eventTypeId, body.scheduleId);
const eventTypeUser = await this.getUserToUpdateEvent(user);
const bodyTransformed = this.inputEventTypesService.transformInputUpdateEventType(body);
await updateEventType({
@@ -224,12 +228,13 @@ export class EventTypesService_2024_06_14 {
return this.outputEventTypesService.getResponseEventType(user.id, eventType);
}
async checkCanUpdateEventType(userId: number, eventTypeId: number) {
async checkCanUpdateEventType(userId: number, eventTypeId: number, scheduleId: number | undefined) {
const existingEventType = await this.getUserEventType(userId, eventTypeId);
if (!existingEventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
this.checkUserOwnsEventType(userId, { id: eventTypeId, userId: existingEventType.ownerId });
await this.checkUserOwnsSchedule(userId, scheduleId);
}
async getUserToUpdateEvent(user: UserWithProfile) {
@@ -254,4 +259,16 @@ export class EventTypesService_2024_06_14 {
throw new ForbiddenException(`User with ID=${userId} does not own event type with ID=${eventType.id}`);
}
}
async checkUserOwnsSchedule(userId: number, scheduleId: number | null | undefined) {
if (!scheduleId) {
return;
}
const schedule = await this.schedulesRepository.getScheduleByIdAndUserId(scheduleId, userId);
if (!schedule) {
throw new NotFoundException(`User with ID=${userId} does not own schedule with ID=${scheduleId}`);
}
}
}
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiEventTypeBookingFields,
transformApiEventTypeLocations,
} from "@calcom/platform-libraries-0.0.13";
} from "@calcom/platform-libraries-0.0.20";
import { CreateEventTypeInput_2024_06_14, UpdateEventTypeInput_2024_06_14 } from "@calcom/platform-types";
@Injectable()
@@ -29,12 +29,14 @@ export class InputEventTypesService_2024_06_14 {
}
transformInputUpdateEventType(inputEventType: UpdateEventTypeInput_2024_06_14) {
const { locations, bookingFields, ...rest } = inputEventType;
const { lengthInMinutes, locations, bookingFields, scheduleId, ...rest } = inputEventType;
const eventType = {
...rest,
length: lengthInMinutes,
locations: locations ? this.transformInputLocations(locations) : undefined,
bookingFields: bookingFields ? this.transformInputBookingFields(bookingFields) : undefined,
schedule: scheduleId,
};
return eventType;
@@ -9,7 +9,7 @@ import {
parseRecurringEvent,
TransformedLocationsSchema,
BookingFieldsSchema,
} from "@calcom/platform-libraries-0.0.13";
} from "@calcom/platform-libraries-0.0.20";
type EventTypeRelations = { users: User[]; schedule: Schedule | null };
type DatabaseEventType = EventType & EventTypeRelations;
@@ -41,7 +41,7 @@ type Input = Pick<
| "recurringEvent"
| "metadata"
| "users"
| "schedule"
| "scheduleId"
>;
@Injectable()
@@ -68,13 +68,13 @@ export class OutputEventTypesService_2024_06_14 {
successRedirectUrl,
seatsShowAvailabilityCount,
isInstantEvent,
scheduleId,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
const bookingFields = this.transformBookingFields(databaseEventType.bookingFields);
const recurringEvent = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const schedule = await this.getSchedule(databaseEventType);
const users = this.transformUsers(databaseEventType.users);
return {
@@ -104,7 +104,7 @@ export class OutputEventTypesService_2024_06_14 {
seatsShowAvailabilityCount,
isInstantEvent,
users,
schedule,
scheduleId,
};
}
@@ -128,10 +128,6 @@ export class OutputEventTypesService_2024_06_14 {
return EventTypeMetaDataSchema.parse(metadata);
}
async getSchedule(databaseEventType: Input) {
return databaseEventType.schedule || null;
}
transformUsers(users: User[]) {
return users.map((user) => {
const metadata = user.metadata ? userMetadata.parse(user.metadata) : {};
@@ -16,23 +16,10 @@ import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-cli
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { CalendarsServiceMock } from "test/mocks/calendars-service-mock";
const CLIENT_REDIRECT_URI = "http://localhost:5555";
class CalendarsServiceMock {
async getCalendars() {
return {
connectedCalendars: [
{
integration: {
type: "google_calendar",
},
},
],
};
}
}
describe("Platform Gcal Endpoints", () => {
let app: INestApplication;
@@ -57,8 +44,6 @@ describe("Platform Gcal Endpoints", () => {
.useValue({
canActivate: () => true,
})
.overrideProvider(CalendarsService)
.useClass(CalendarsServiceMock)
.compile();
app = moduleRef.createNestApplication();
@@ -76,6 +61,9 @@ describe("Platform Gcal Endpoints", () => {
accessTokenSecret = tokens.accessToken;
refreshTokenSecret = tokens.refreshToken;
await app.init();
jest
.spyOn(CalendarsService.prototype, "getCalendars")
.mockImplementation(CalendarsServiceMock.prototype.getCalendars);
});
async function createOAuthClient(organizationId: number) {
@@ -100,14 +88,14 @@ describe("Platform Gcal Endpoints", () => {
expect(user).toBeDefined();
});
it(`/GET/ee/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
it(`/GET/gcal/oauth/auth-url: it should respond 401 with invalid access token`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer invalid_access_token`)
.expect(401);
});
it(`/GET/ee/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
it(`/GET/gcal/oauth/auth-url: it should auth-url to google oauth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/gcal/oauth/auth-url`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -117,42 +105,42 @@ describe("Platform Gcal Endpoints", () => {
expect(data.authUrl).toBeDefined();
});
it(`/GET/ee/gcal/oauth/save: without oauth code`, async () => {
it(`/GET/gcal/oauth/save: without oauth code`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&origin%3D${CLIENT_REDIRECT_URI}&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
.expect(301);
});
it(`/GET/ee/gcal/oauth/save: without access token`, async () => {
it(`/GET/gcal/oauth/save: without access token`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=origin%3D${CLIENT_REDIRECT_URI}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
.expect(301);
});
it(`/GET/ee/gcal/oauth/save: without origin`, async () => {
it(`/GET/gcal/oauth/save: without origin`, async () => {
await request(app.getHttpServer())
.get(
`/v2/gcal/oauth/save?state=accessToken=${accessTokenSecret}&code=4/0AfJohXmBuT7QVrEPlAJLBu4ZcSnyj5jtDoJqSW_riPUhPXQ70RPGkOEbVO3xs-OzQwpPQw&scope=https://www.googleapis.com/auth/calendar.readonly%20https://www.googleapis.com/auth/calendar.events`
)
.expect(400);
.expect(301);
});
it(`/GET/ee/gcal/check with access token but without origin`, async () => {
it(`/GET/gcal/check with access token but without origin`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
.expect(400);
});
it(`/GET/ee/gcal/check without access token`, async () => {
it(`/GET/gcal/check without access token`, async () => {
await request(app.getHttpServer()).get(`/v2/gcal/check`).expect(401);
});
it(`/GET/ee/gcal/check with access token and origin but no credentials`, async () => {
it(`/GET/gcal/check with access token and origin but no credentials`, async () => {
await request(app.getHttpServer())
.get(`/v2/gcal/check`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -160,7 +148,7 @@ describe("Platform Gcal Endpoints", () => {
.expect(400);
});
it(`/GET/ee/gcal/check with access token and origin and gcal credentials`, async () => {
it(`/GET/gcal/check with access token and origin and gcal credentials`, async () => {
gcalCredentials = await credentialsRepositoryFixture.create(
"google_calendar",
{},
+2
View File
@@ -1,3 +1,4 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GcalController } from "@/ee/gcal/gcal.controller";
import { AppsRepository } from "@/modules/apps/apps.repository";
@@ -21,6 +22,7 @@ import { ConfigService } from "@nestjs/config";
GCalService,
CalendarsService,
UsersRepository,
CalendarsRepository,
],
controllers: [GcalController],
})
@@ -103,6 +103,21 @@ describe("Me Endpoints", () => {
});
});
it("should update user associated with access token given badly formatted timezone", async () => {
const bodyWithBadlyFormattedTimeZone: UpdateManagedUserInput = { timeZone: "America/New_york" };
return request(app.getHttpServer())
.patch("/v2/me")
.send(bodyWithBadlyFormattedTimeZone)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<UserResponse> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.timeZone).toEqual("America/New_York");
});
});
it("should not update user associated with access token given invalid timezone", async () => {
const bodyWithIncorrectTimeZone: UpdateManagedUserInput = { timeZone: "Narnia/Woods" };
+2 -2
View File
@@ -23,7 +23,7 @@ import { userSchemaResponse } from "@calcom/platform-types";
export class MeController {
constructor(
private readonly usersRepository: UsersRepository,
private readonly schedulesRepository: SchedulesService_2024_04_15
private readonly schedulesService: SchedulesService_2024_04_15
) {}
@Get("/")
@@ -45,7 +45,7 @@ export class MeController {
): Promise<UpdateMeOutput> {
const updatedUser = await this.usersRepository.update(user.id, bodySchedule);
if (bodySchedule.timeZone && user.defaultScheduleId) {
await this.schedulesRepository.updateUserSchedule(user, user.defaultScheduleId, {
await this.schedulesService.updateUserSchedule(user, user.defaultScheduleId, {
timeZone: bodySchedule.timeZone,
});
}
@@ -17,6 +17,6 @@ import { Module } from "@nestjs/common";
OutputSchedulesService_2024_06_11,
],
controllers: [SchedulesController_2024_06_11],
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11],
exports: [SchedulesService_2024_06_11, SchedulesRepository_2024_06_11, OutputSchedulesService_2024_06_11],
})
export class SchedulesModule_2024_06_11 {}
@@ -96,6 +96,17 @@ export class SchedulesRepository_2024_06_11 {
return schedule;
}
async getScheduleByIdAndUserId(scheduleId: number, userId: number) {
const schedule = await this.dbRead.prisma.schedule.findUnique({
where: {
id: scheduleId,
userId,
},
});
return schedule;
}
async updateSchedule(
userId: number,
scheduleId: number,
@@ -3,7 +3,7 @@ import { Injectable } from "@nestjs/common";
import {
transformApiScheduleOverrides,
transformApiScheduleAvailability,
} from "@calcom/platform-libraries-0.0.4";
} from "@calcom/platform-libraries-0.0.20";
import { CreateScheduleInput_2024_06_11, ScheduleAvailabilityInput_2024_06_11 } from "@calcom/platform-types";
import { ScheduleOverrideInput_2024_06_11 } from "@calcom/platform-types";
@@ -2,7 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, Logger } from "@nestjs/common";
import { Request } from "express";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { TRPCError } from "@calcom/platform-libraries-0.0.2";
import { TRPCError } from "@calcom/platform-libraries-0.0.20";
import { Response } from "@calcom/platform-types";
@Catch(TRPCError)
+44
View File
@@ -0,0 +1,44 @@
export enum Locales {
AR = "ar",
CA = "ca",
DE = "de",
ES = "es",
EU = "eu",
HE = "he",
ID = "id",
JA = "ja",
LV = "lv",
PL = "pl",
RO = "ro",
SR = "sr",
TH = "th",
VI = "vi",
AZ = "az",
CS = "cs",
EL = "el",
ES_419 = "es-419",
FI = "fi",
HR = "hr",
IT = "it",
KM = "km",
NL = "nl",
PT = "pt",
RU = "ru",
SV = "sv",
TR = "tr",
ZH_CN = "zh-CN",
BG = "bg",
DA = "da",
EN = "en",
ET = "et",
FR = "fr",
HU = "hu",
IW = "iw",
KO = "ko",
NO = "no",
PT_BR = "pt-BR",
SK = "sk",
TA = "ta",
UK = "uk",
ZH_TW = "zh-TW",
}
+13
View File
@@ -0,0 +1,13 @@
import { MembershipRole } from "@prisma/client";
export const SYSTEM_ADMIN_ROLE = "SYSADMIN";
export const ORG_ROLES = [
`ORG_${MembershipRole.OWNER}`,
`ORG_${MembershipRole.ADMIN}`,
`ORG_${MembershipRole.MEMBER}`,
] as const;
export const TEAM_ROLES = [
`TEAM_${MembershipRole.OWNER}`,
`TEAM_${MembershipRole.ADMIN}`,
`TEAM_${MembershipRole.MEMBER}`,
] as const;
@@ -0,0 +1,33 @@
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
import { Membership } from "@calcom/prisma/client";
export type GetMembershipReturnType = Membership;
export const GetMembership = createParamDecorator<
keyof GetMembershipReturnType | (keyof GetMembershipReturnType)[],
ExecutionContext
>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const membership = request.membership as GetMembershipReturnType;
if (!membership) {
throw new Error("GetMembership decorator : Membership not found");
}
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
...prev,
[curr]: membership[curr],
};
}, {});
}
if (data) {
return membership[data];
}
return membership;
});
@@ -0,0 +1,33 @@
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
import { Team } from "@calcom/prisma/client";
export type GetTeamReturnType = Team;
export const GetTeam = createParamDecorator<
keyof GetTeamReturnType | (keyof GetTeamReturnType)[],
ExecutionContext
>((data, ctx) => {
const request = ctx.switchToHttp().getRequest();
const team = request.team as GetTeamReturnType;
if (!team) {
throw new Error("GetTeam decorator : Team not found");
}
if (Array.isArray(data)) {
return data.reduce((prev, curr) => {
return {
...prev,
[curr]: team[curr],
};
}, {});
}
if (data) {
return team[data];
}
return team;
});
@@ -0,0 +1,4 @@
import { Reflector } from "@nestjs/core";
import { MembershipRole } from "@prisma/client";
export const MembershipRoles = Reflector.createDecorator<MembershipRole[]>();
@@ -1,4 +1,6 @@
import { SYSTEM_ADMIN_ROLE, ORG_ROLES, TEAM_ROLES } from "@/lib/roles/constants";
import { Reflector } from "@nestjs/core";
import { MembershipRole } from "@prisma/client";
export const Roles = Reflector.createDecorator<MembershipRole[]>();
export const Roles = Reflector.createDecorator<
(typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number] | typeof SYSTEM_ADMIN_ROLE
>();
@@ -0,0 +1,42 @@
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import { Membership } from "@calcom/prisma/client";
@Injectable()
export class IsMembershipInOrg implements CanActivate {
constructor(private organizationsMembershipRepository: OrganizationsMembershipRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { membership: Membership }>();
const membershipId: string = request.params.membershipId;
const orgId: string = request.params.orgId;
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
}
if (!membershipId) {
throw new ForbiddenException("No membership id found in request params.");
}
const membership = await this.organizationsMembershipRepository.findOrgMembership(
Number(orgId),
Number(membershipId)
);
if (!membership) {
throw new NotFoundException(`Membership (${membershipId}) not found.`);
}
request.membership = membership;
return true;
}
}
@@ -1,4 +1,4 @@
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -27,7 +27,7 @@ export class OrganizationRolesGuard implements CanActivate {
await this.isPlatform(organizationId);
const membership = await this.membershipRepository.findOrgUserMembership(organizationId, user.id);
const allowedRoles = this.reflector.get(Roles, context.getHandler());
const allowedRoles = this.reflector.get(MembershipRoles, context.getHandler());
this.isMembershipAccepted(membership.accepted);
this.isRoleAllowed(membership.role, allowedRoles);
@@ -1,14 +1,24 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
type CachedData = {
org?: Team;
canAccess?: boolean;
};
@Injectable()
export class IsOrgGuard implements CanActivate {
constructor(private organizationsRepository: OrganizationsRepository) {}
constructor(
private organizationsRepository: OrganizationsRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
let canAccess = false;
const request = context.switchToHttp().getRequest<Request & { organization: Team }>();
const organizationId: string = request.params.orgId;
@@ -16,13 +26,33 @@ export class IsOrgGuard implements CanActivate {
throw new ForbiddenException("No organization id found in request params.");
}
const org = await this.organizationsRepository.findById(Number(organizationId));
const REDIS_CACHE_KEY = `apiv2:org:${organizationId}:guard:isOrg`;
const cachedData = await this.redisService.redis.get(REDIS_CACHE_KEY);
if (org && org.isOrganization) {
request.organization = org;
return true;
if (cachedData) {
const { org: cachedOrg, canAccess: cachedCanAccess } = JSON.parse(cachedData) as CachedData;
if (cachedOrg?.id === Number(organizationId) && cachedCanAccess !== undefined) {
request.organization = cachedOrg;
return cachedCanAccess;
}
}
return false;
const org = await this.organizationsRepository.findById(Number(organizationId));
if (org?.isOrganization) {
request.organization = org;
canAccess = true;
}
if (org) {
await this.redisService.redis.set(
REDIS_CACHE_KEY,
JSON.stringify({ org: org, canAccess } satisfies CachedData),
"EX",
300
);
}
return canAccess;
}
}
@@ -14,7 +14,20 @@ describe("PermissionsGuard", () => {
beforeEach(async () => {
reflector = new Reflector();
guard = new PermissionsGuard(reflector, createMock<TokensRepository>(), createMock<ConfigService>());
guard = new PermissionsGuard(
reflector,
createMock<TokensRepository>(),
createMock<ConfigService>({
get: jest.fn().mockImplementation((key: string) => {
switch (key) {
case "api.apiKeyPrefix":
return "cal_";
default:
return null;
}
}),
})
);
});
it("should be defined", () => {
@@ -0,0 +1,163 @@
import { ORG_ROLES, TEAM_ROLES, SYSTEM_ADMIN_ROLE } from "@/lib/roles/constants";
import { GetUserReturnType } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException, Logger } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class RolesGuard implements CanActivate {
private readonly logger = new Logger("RolesGuard Logger");
constructor(
private reflector: Reflector,
private membershipRepository: MembershipsRepository,
private readonly redisService: RedisService
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId = request.params.teamId as string;
const orgId = request.params.orgId as string;
const user = request.user as GetUserReturnType;
const allowedRole = this.reflector.get(Roles, context.getHandler());
const REDIS_CACHE_KEY = `apiv2:user:${user.id ?? "none"}:org:${orgId ?? "none"}:team:${
teamId ?? "none"
}:guard:roles:${allowedRole}`;
const cachedAccess = JSON.parse((await this.redisService.redis.get(REDIS_CACHE_KEY)) ?? "false");
if (cachedAccess) {
return cachedAccess;
}
let canAccess = false;
// User is not authenticated
if (!user) {
this.logger.log("User is not authenticated, denying access.");
canAccess = false;
}
// System admin can access everything
else if (user.isSystemAdmin) {
this.logger.log(`User (${user.id}) is system admin, allowing access.`);
canAccess = true;
}
// if the required role is SYSTEM_ADMIN_ROLE but user is not system admin, return false
else if (allowedRole === SYSTEM_ADMIN_ROLE && !user.isSystemAdmin) {
this.logger.log(`User (${user.id}) is not system admin, denying access.`);
canAccess = false;
}
// Checking the role of the user within the organization
else if (Boolean(orgId) && !Boolean(teamId)) {
const membership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not a member of the organization.`);
}
if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
canAccess = hasMinimumRole({
checkRole: `ORG_${membership.role}`,
minimumRole: allowedRole,
roles: ORG_ROLES,
});
}
}
// Checking the role of the user within the team
else if (Boolean(teamId) && !Boolean(orgId)) {
const membership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
if (!membership) {
this.logger.log(`User (${user.id}) is not a member of the team (${teamId}), denying access.`);
throw new ForbiddenException(`User is not a member of the team.`);
}
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
canAccess = hasMinimumRole({
checkRole: `TEAM_${membership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
}
// Checking the role for team and org, org is above team in term of permissions
else if (Boolean(teamId) && Boolean(orgId)) {
const teamMembership = await this.membershipRepository.findMembershipByTeamId(Number(teamId), user.id);
const orgMembership = await this.membershipRepository.findMembershipByOrgId(Number(orgId), user.id);
if (!orgMembership) {
this.logger.log(`User (${user.id}) is not part of the organization (${orgId}), denying access.`);
throw new ForbiddenException(`User is not part of the organization.`);
}
// if the role checked is a TEAM role
if (TEAM_ROLES.includes(allowedRole as unknown as (typeof TEAM_ROLES)[number])) {
// if the user is admin or owner of org, allow request because org > team
if (`ORG_${orgMembership.role}` === "ORG_ADMIN" || `ORG_${orgMembership.role}` === "ORG_OWNER") {
canAccess = true;
} else {
if (!teamMembership) {
this.logger.log(
`User (${user.id}) is not part of the team (${teamId}) and/or, is not an admin nor an owner of the organization (${orgId}).`
);
throw new ForbiddenException(
"User is not part of the team and/or, is not an admin nor an owner of the organization."
);
}
// if user is not admin nor an owner of org, and is part of the team, then check user team membership role
canAccess = hasMinimumRole({
checkRole: `TEAM_${teamMembership.role}`,
minimumRole: allowedRole,
roles: TEAM_ROLES,
});
}
}
// if allowed role is a ORG ROLE, check org membersip role
else if (ORG_ROLES.includes(allowedRole as unknown as (typeof ORG_ROLES)[number])) {
canAccess = hasMinimumRole({
checkRole: `ORG_${orgMembership.role}`,
minimumRole: allowedRole,
roles: ORG_ROLES,
});
}
}
await this.redisService.redis.set(REDIS_CACHE_KEY, String(canAccess), "EX", 300);
return canAccess;
}
}
type Roles = (typeof ORG_ROLES)[number] | (typeof TEAM_ROLES)[number];
type HasMinimumTeamRoleProp = {
checkRole: (typeof TEAM_ROLES)[number];
minimumRole: string;
roles: typeof TEAM_ROLES;
};
type HasMinimumOrgRoleProp = {
checkRole: (typeof ORG_ROLES)[number];
minimumRole: string;
roles: typeof ORG_ROLES;
};
type HasMinimumRoleProp = HasMinimumTeamRoleProp | HasMinimumOrgRoleProp;
export function hasMinimumRole(props: HasMinimumRoleProp): boolean {
const checkedRoleIndex = props.roles.indexOf(props.checkRole as never);
const requiredRoleIndex = props.roles.indexOf(props.minimumRole as never);
// minimum role given does not exist
if (checkedRoleIndex === -1 || requiredRoleIndex === -1) {
throw new Error("Invalid role");
}
return checkedRoleIndex <= requiredRoleIndex;
}
@@ -0,0 +1,43 @@
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import {
Injectable,
CanActivate,
ExecutionContext,
ForbiddenException,
NotFoundException,
} from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsTeamInOrg implements CanActivate {
constructor(private organizationsTeamsRepository: OrganizationsTeamsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const teamId: string = request.params.teamId;
const orgId: string = request.params.orgId;
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
}
if (!teamId) {
throw new ForbiddenException("No team id found in request params.");
}
const team = await this.organizationsTeamsRepository.findOrgTeam(Number(orgId), Number(teamId));
if (team && !team.isOrganization && team.parentId === Number(orgId)) {
request.team = team;
return true;
}
if (!team) {
throw new NotFoundException(`Team (${teamId}) not found.`);
}
return false;
}
}
@@ -0,0 +1,33 @@
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common";
import { Request } from "express";
import { Team } from "@calcom/prisma/client";
@Injectable()
export class IsUserInOrg implements CanActivate {
constructor(private organizationsRepository: OrganizationsRepository) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request & { team: Team }>();
const orgId: string = request.params.orgId;
const userId: string = request.params.userId;
if (!userId) {
throw new ForbiddenException("No user id found in request params.");
}
if (!orgId) {
throw new ForbiddenException("No org id found in request params.");
}
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(userId));
if (user) {
request.user = user;
return true;
}
return false;
}
}
@@ -37,9 +37,9 @@ describe("ApiAuthStrategy", () => {
const validAccessTokenEmail = "access-token-user-email@example.com";
let validApiKeyUser: User;
let validAccessTokenUser: User;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
let module: TestingModule;
beforeAll(async () => {
module = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
ignoreEnvFile: true,
@@ -89,14 +89,11 @@ describe("ApiAuthStrategy", () => {
describe("authenticate with strategy", () => {
it("should return user associated with valid access token", async () => {
console.log("HERERERER");
const { accessToken } = await tokensRepositoryFixture.createTokens(
validAccessTokenUser.id,
oAuthClient.id
);
console.log("Access token", accessToken);
const context: ExecutionContext = {
switchToHttp: () => ({
getRequest: () => ({
@@ -110,9 +107,9 @@ describe("ApiAuthStrategy", () => {
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.authenticate(request);
await expect(user.id).toEqual(validAccessTokenUser.id);
const user = await strategy.accessTokenStrategy(accessToken);
await expect(user).toBeDefined();
if (user) await expect(user.id).toEqual(validAccessTokenUser.id);
});
it("should return user associated with valid api key", async () => {
@@ -133,9 +130,9 @@ describe("ApiAuthStrategy", () => {
} as ExecutionContext;
const request = context.switchToHttp().getRequest();
const user = await strategy.authenticate(request);
expect(user.id).toEqual(validApiKeyUser.id);
const user = await strategy.apiKeyStrategy(keyString);
await expect(user).toBeDefined();
if (user) expect(user.id).toEqual(validApiKeyUser.id);
});
it("should throw 401 if api key is invalid", async () => {
@@ -180,4 +177,10 @@ describe("ApiAuthStrategy", () => {
}
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(validApiKeyEmail);
await userRepositoryFixture.deleteByEmail(validAccessTokenEmail);
module.close();
});
});
@@ -1,6 +1,6 @@
import { AppConfig } from "@/config/type";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { SubscribeToPlanInput } from "@/modules/billing/controllers/inputs/subscribe-to-plan.input";
@@ -47,7 +47,7 @@ export class BillingController {
@Get("/:teamId/check")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN", "MEMBER"])
@MembershipRoles(["OWNER", "ADMIN", "MEMBER"])
async checkTeamBilling(
@Param("teamId") teamId: number
): Promise<ApiResponse<CheckPlatformBillingResponseDto>> {
@@ -64,7 +64,7 @@ export class BillingController {
@Post("/:teamId/subscribe")
@UseGuards(NextAuthGuard, OrganizationRolesGuard)
@Roles(["OWNER", "ADMIN"])
@MembershipRoles(["OWNER", "ADMIN"])
async subscribeTeamToStripe(
@Param("teamId") teamId: number,
@Body() input: SubscribeToPlanInput
@@ -133,6 +133,9 @@ export class BillingService {
}
async increaseUsageByClientId(clientId: string) {
if (this.configService.get("e2e")) {
return void 0;
}
const team = await this.teamsRepository.findTeamIdFromClientId(clientId);
if (!team.id) return Promise.resolve(); // noop resolution.
@@ -0,0 +1,11 @@
import { Global, Module } from "@nestjs/common";
import { EmailService } from "./email.service";
@Global()
@Module({
imports: [],
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}
@@ -0,0 +1,31 @@
import { Injectable } from "@nestjs/common";
import { sendSignupToOrganizationEmail, getTranslation } from "@calcom/platform-libraries-0.0.20";
@Injectable()
export class EmailService {
public async sendSignupToOrganizationEmail({
usernameOrEmail,
orgName,
orgId,
locale,
inviterName,
}: {
usernameOrEmail: string;
orgName: string;
orgId: number;
locale: string | null;
inviterName: string;
}) {
const translation = await getTranslation(locale || "en", "common");
await sendSignupToOrganizationEmail({
usernameOrEmail,
team: { name: orgName, parent: null },
inviterName: inviterName,
isOrg: true,
teamId: orgId,
translation,
});
}
}
@@ -1,6 +1,8 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import { MembershipRole } from "@calcom/prisma/client";
@Injectable()
export class MembershipsRepository {
constructor(private readonly dbRead: PrismaReadService) {}
@@ -18,6 +20,23 @@ export class MembershipsRepository {
return membership;
}
async findMembershipByTeamId(teamId: number, userId: number) {
const membership = await this.dbRead.prisma.membership.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
return membership;
}
async findMembershipByOrgId(orgId: number, userId: number) {
return this.findMembershipByTeamId(orgId, userId);
}
async isUserOrganizationAdmin(userId: number, organizationId: number) {
const adminMembership = await this.dbRead.prisma.membership.findFirst({
where: {
@@ -30,4 +49,17 @@ export class MembershipsRepository {
return !!adminMembership;
}
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
const membership = await this.dbRead.prisma.membership.create({
data: {
role,
teamId,
userId,
accepted,
},
});
return membership;
}
}
@@ -3,6 +3,7 @@ import { AppModule } from "@/app.module";
import { DEFAULT_EVENT_TYPES } from "@/ee/event-types/event-types_2024_04_15/constants/constants";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { Locales } from "@/lib/enums/locales";
import {
CreateUserResponse,
UserReturned,
@@ -141,6 +142,7 @@ describe("OAuth Client Users Endpoints", () => {
timeZone: userTimeZone,
weekStart: "Monday",
timeFormat: 24,
locale: Locales.FR,
};
const response = await request(app.getHttpServer())
@@ -163,6 +165,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
expect(responseBody.data.user.weekStart).toEqual(requestBody.weekStart);
expect(responseBody.data.user.timeFormat).toEqual(requestBody.timeFormat);
expect(responseBody.data.user.locale).toEqual(requestBody.locale);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
@@ -234,7 +237,7 @@ describe("OAuth Client Users Endpoints", () => {
it(`/PUT/:id`, async () => {
const userUpdatedEmail = "pineapple-pizza@gmail.com";
const body: UpdateManagedUserInput = { email: userUpdatedEmail };
const body: UpdateManagedUserInput = { email: userUpdatedEmail, locale: Locales.PT_BR };
const response = await request(app.getHttpServer())
.patch(`/api/v2/oauth-clients/${oAuthClient.id}/users/${postResponseData.user.id}`)
@@ -248,6 +251,7 @@ describe("OAuth Client Users Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.email).toEqual(getOAuthUserEmail(oAuthClient.id, userUpdatedEmail));
expect(responseBody.data.locale).toEqual(Locales.PT_BR);
});
it(`/DELETE/:id`, () => {
@@ -1,4 +1,5 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Locales } from "@/lib/enums/locales";
import { CreateManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import { GetManagedUserOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-user.output";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -191,6 +192,7 @@ export class OAuthClientUsersController {
createdDate: user.createdDate,
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
locale: user.locale as Locales,
};
}
}
@@ -1,5 +1,7 @@
import { Locales } from "@/lib/enums/locales";
import { ApiProperty } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsEnum, IsOptional } from "class-validator";
export class ManagedUserOutput {
@ApiProperty({ example: 1 })
@@ -26,4 +28,9 @@ export class ManagedUserOutput {
@ApiProperty({ example: null })
defaultScheduleId!: number | null;
@IsEnum(Locales)
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
locale?: Locales;
}
@@ -13,6 +13,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { Membership, PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
@@ -62,6 +63,8 @@ describe("OAuth Clients Endpoints", () => {
let usersFixtures: UserRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let user: User;
let org: Team;
let app: INestApplication;
@@ -80,11 +83,14 @@ describe("OAuth Clients Endpoints", () => {
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
user = await usersFixtures.create({
email: userEmail,
});
org = await teamFixtures.create({
name: "apiOrg",
isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
@@ -94,6 +100,7 @@ describe("OAuth Clients Endpoints", () => {
isPlatform: false,
});
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
await platformBillingRepositoryFixture.create(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
@@ -114,6 +121,8 @@ describe("OAuth Clients Endpoints", () => {
let usersFixtures: UserRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let teamFixtures: TeamRepositoryFixture;
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
let user: User;
let org: Team;
let app: INestApplication;
@@ -132,11 +141,14 @@ describe("OAuth Clients Endpoints", () => {
usersFixtures = new UserRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
teamFixtures = new TeamRepositoryFixture(moduleRef);
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
user = await usersFixtures.create({
email: userEmail,
});
org = await teamFixtures.create({
name: "apiOrg",
isOrganization: true,
metadata: {
isOrganization: true,
orgAutoAcceptEmail: "api.com",
@@ -145,6 +157,7 @@ describe("OAuth Clients Endpoints", () => {
},
isPlatform: true,
});
await platformBillingRepositoryFixture.create(org.id);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
@@ -1,7 +1,7 @@
import { getEnv } from "@/env";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { MembershipRoles } from "@/modules/auth/decorators/roles/membership-roles.decorator";
import { NextAuthGuard } from "@/modules/auth/guards/next-auth/next-auth.guard";
import { OrganizationRolesGuard } from "@/modules/auth/guards/organization-roles/organization-roles.guard";
import { GetManagedUsersOutput } from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/get-managed-users.output";
@@ -64,7 +64,7 @@ export class OAuthClientsController {
@Post("/")
@HttpCode(HttpStatus.CREATED)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
@DocsCreatedResponse({
description: "Create an OAuth client",
@@ -96,7 +96,7 @@ export class OAuthClientsController {
@Get("/")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClients(@GetUser() user: UserWithProfile): Promise<GetOAuthClientsResponseDto> {
const organizationId = (user.movedToProfile?.organizationId ?? user.organizationId) as number;
@@ -107,7 +107,7 @@ export class OAuthClientsController {
@Get("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientById(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
const client = await this.oauthClientRepository.getOAuthClient(clientId);
@@ -119,7 +119,7 @@ export class OAuthClientsController {
@Get("/:clientId/managed-users")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER, MembershipRole.MEMBER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async getOAuthClientManagedUsersById(
@Param("clientId") clientId: string,
@@ -137,7 +137,7 @@ export class OAuthClientsController {
@Patch("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async updateOAuthClient(
@Param("clientId") clientId: string,
@@ -150,7 +150,7 @@ export class OAuthClientsController {
@Delete("/:clientId")
@HttpCode(HttpStatus.OK)
@Roles([MembershipRole.ADMIN, MembershipRole.OWNER])
@MembershipRoles([MembershipRole.ADMIN, MembershipRole.OWNER])
@DocsOperation({ description: AUTH_DOCUMENTATION })
async deleteOAuthClient(@Param("clientId") clientId: string): Promise<GetOAuthClientResponseDto> {
this.logger.log(`Deleting OAuth Client with ID: ${clientId}`);
@@ -113,7 +113,7 @@ describe("OAuthFlow Endpoints", () => {
const REDIRECT_STATUS = 302;
const response = await request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/authorize`)
.post(`/v2/oauth/${oAuthClient.id}/authorize`)
.send(body)
.expect(REDIRECT_STATUS);
@@ -133,7 +133,7 @@ describe("OAuthFlow Endpoints", () => {
};
const response = await request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/exchange`)
.post(`/v2/oauth/${oAuthClient.id}/exchange`)
.set("Authorization", authorizationToken)
.send(body)
.expect(200);
@@ -153,7 +153,7 @@ describe("OAuthFlow Endpoints", () => {
};
return request(app.getHttpServer())
.post(`/oauth/${oAuthClient.id}/refresh`)
.post(`/v2/oauth/${oAuthClient.id}/refresh`)
.set("x-cal-secret-key", secretKey)
.send(body)
.expect(200)
@@ -7,7 +7,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, Injectable } from "@nestjs/common";
import { User } from "@prisma/client";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.2";
import { createNewUsersConnectToOrgIfExists, slugify } from "@calcom/platform-libraries-0.0.20";
@Injectable()
export class OAuthClientUsersService {
@@ -36,17 +36,17 @@ export class OAuthClientUsersService {
const email = this.getOAuthUserEmail(oAuthClientId, body.email);
user = (
await createNewUsersConnectToOrgIfExists({
usernamesOrEmails: [email],
input: {
teamId: organizationId,
role: "MEMBER",
usernameOrEmail: [email],
isOrg: true,
language: "en",
},
invitations: [
{
usernameOrEmail: email,
role: "MEMBER",
},
],
teamId: organizationId,
isOrg: true,
parentId: null,
autoAcceptEmailDomain: "never-auto-accept-email-domain-for-managed-users",
connectionInfoMap: {
orgConnectInfoByUsernameOrEmail: {
[email]: {
orgId: organizationId,
autoAccept: true,
@@ -59,7 +59,11 @@ export class OAuthClientUsersService {
})
)[0];
await this.userRepository.addToOAuthClient(user.id, oAuthClientId);
await this.userRepository.update(user.id, { name: body.name ?? user.username ?? undefined });
const updatedUser = await this.userRepository.update(user.id, {
name: body.name ?? user.username ?? undefined,
locale: body.locale,
});
user.locale = updatedUser.locale;
}
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
@@ -0,0 +1,156 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/create-team-event-type.output";
import { DeleteTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/delete-team-event-type.output";
import { GetTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-type.output";
import { GetTeamEventTypesOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/get-team-event-types.output";
import { UpdateTeamEventTypeOutput } from "@/modules/organizations/controllers/event-types/outputs/teams/update-team-event-type.output";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
UseGuards,
Get,
Post,
Param,
ParseIntPipe,
Body,
Patch,
Delete,
HttpCode,
HttpStatus,
NotFoundException,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateTeamEventTypeInput_2024_06_14,
SkipTakePagination,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Event Types")
export class OrganizationsEventTypesController {
constructor(private readonly organizationsEventTypesService: OrganizationsEventTypesService) {}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@Post("/teams/:teamId/event-types")
async createTeamEventType(
@GetUser() user: UserWithProfile,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("orgId", ParseIntPipe) orgId: number,
@Body() bodyEventType: CreateTeamEventTypeInput_2024_06_14
): Promise<CreateTeamEventTypeOutput> {
const eventType = await this.organizationsEventTypesService.createTeamEventType(
user,
teamId,
orgId,
bodyEventType
);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@Get("/teams/:teamId/event-types/:eventTypeId")
async getTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId") eventTypeId: number
): Promise<GetTeamEventTypeOutput> {
const eventType = await this.organizationsEventTypesService.getTeamEventType(teamId, eventTypeId);
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
}
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@Get("/teams/:teamId/event-types")
async getTeamEventTypes(@Param("teamId", ParseIntPipe) teamId: number): Promise<GetTeamEventTypesOutput> {
const eventTypes = await this.organizationsEventTypesService.getTeamEventTypes(teamId);
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Roles("TEAM_ADMIN")
@Get("/teams/event-types")
async getTeamsEventTypes(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetTeamEventTypesOutput> {
const { skip, take } = queryParams;
const eventTypes = await this.organizationsEventTypesService.getTeamsEventTypes(orgId, skip, take);
return {
status: SUCCESS_STATUS,
data: eventTypes,
};
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@Patch("/teams/:teamId/event-types/:eventTypeId")
async updateTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId") eventTypeId: number,
@GetUser() user: UserWithProfile,
@Body() bodyEventType: UpdateTeamEventTypeInput_2024_06_14
): Promise<UpdateTeamEventTypeOutput> {
const eventType = await this.organizationsEventTypesService.updateTeamEventType(
eventTypeId,
teamId,
bodyEventType,
user
);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Roles("TEAM_ADMIN")
@UseGuards(IsTeamInOrg)
@Delete("/teams/:teamId/event-types/:eventTypeId")
@HttpCode(HttpStatus.OK)
async deleteTeamEventType(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("eventTypeId") eventTypeId: number
): Promise<DeleteTeamEventTypeOutput> {
const eventType = await this.organizationsEventTypesService.deleteTeamEventType(teamId, eventTypeId);
return {
status: SUCCESS_STATUS,
data: {
id: eventTypeId,
title: eventType.title,
},
};
}
}
@@ -0,0 +1,596 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
ApiSuccessResponse,
CreateTeamEventTypeInput_2024_06_14,
Host,
TeamEventTypeOutput_2024_06_14,
UpdateTeamEventTypeInput_2024_06_14,
} from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Organizations Event Types Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let org: Team;
let team: Team;
let falseTestOrg: Team;
let falseTestTeam: Team;
const userEmail = "org-admin-event-types-controller-e222e@api.com";
let userAdmin: User;
const teammate1Email = "teammate111@team.com";
const teammate2Email = "teammate221@team.com";
const falseTestUserEmail = "false-user@false-team.com";
let teammate1: User;
let teammate2: User;
let falseTestUser: User;
let collectiveEventType: TeamEventTypeOutput_2024_06_14;
let managedEventType: TeamEventTypeOutput_2024_06_14;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
userAdmin = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
role: "ADMIN",
});
teammate1 = await userRepositoryFixture.create({
email: teammate1Email,
username: teammate1Email,
});
teammate2 = await userRepositoryFixture.create({
email: teammate2Email,
username: teammate2Email,
});
falseTestUser = await userRepositoryFixture.create({
email: falseTestUserEmail,
username: falseTestUserEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
falseTestOrg = await organizationsRepositoryFixture.create({
name: "False test org",
isOrganization: true,
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
falseTestTeam = await teamsRepositoryFixture.create({
name: "Outside org team",
isOrganization: false,
parent: { connect: { id: falseTestOrg.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${userAdmin.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: userAdmin.id,
},
},
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: userAdmin.id } },
team: { connect: { id: org.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teammate1.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teammate2.id } },
team: { connect: { id: team.id } },
accepted: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: falseTestUser.id } },
team: { connect: { id: falseTestTeam.id } },
accepted: true,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(userAdmin).toBeDefined();
expect(org).toBeDefined();
});
it("should not be able to create event-type for team outside org", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation",
slug: "coding-consultation",
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId: teammate1.id,
mandatory: true,
priority: "high",
},
],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`)
.send(body)
.expect(404);
});
it("should not be able to create event-type for user outside org", async () => {
const userId = falseTestUser.id;
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation",
slug: "coding-consultation",
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId,
mandatory: true,
priority: "high",
},
],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
.send(body)
.expect(404);
});
it("should create a collective team event-type", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation collective",
slug: "coding-consultation collective",
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
bookingFields: [
{
type: "select",
label: "select which language is your codebase in",
slug: "select-language",
required: true,
placeholder: "select language",
options: ["javascript", "python", "cobol"],
},
],
schedulingType: "COLLECTIVE",
hosts: [
{
userId: teammate1.id,
mandatory: true,
priority: "high",
},
{
userId: teammate2.id,
mandatory: false,
priority: "low",
},
],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
.send(body)
.expect(201)
.then((response) => {
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.length).toEqual(2);
evaluateHost(body.hosts[0], data.hosts[0]);
evaluateHost(body.hosts[1], data.hosts[1]);
collectiveEventType = responseBody.data;
});
});
it("should create a managed team event-type", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
title: "Coding consultation managed",
slug: "coding-consultation-managed",
description: "Our team will review your codebase.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
schedulingType: "MANAGED",
hosts: [
{
userId: teammate1.id,
mandatory: true,
priority: "high",
},
{
userId: teammate2.id,
mandatory: false,
priority: "low",
},
],
};
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
.send(body)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(3);
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate2EventTypes.length).toEqual(1);
expect(teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED").length).toEqual(
1
);
const responseTeamEvent = responseBody.data[0];
expect(responseTeamEvent?.teamId).toEqual(team.id);
const responseTeammate1Event = responseBody.data[1];
expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id);
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
const responseTeammate2Event = responseBody.data[2];
expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id);
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
managedEventType = responseTeamEvent;
});
});
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}`)
.expect(404);
});
it("should not get a non existing event-type", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/999999`)
.expect(404);
});
it("should get a team event-type", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.title).toEqual(collectiveEventType.title);
expect(data.hosts.length).toEqual(2);
evaluateHost(collectiveEventType.hosts[0], data.hosts[0]);
evaluateHost(collectiveEventType.hosts[1], data.hosts[1]);
collectiveEventType = responseBody.data;
});
});
it("should not get event-types of team outside org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types`)
.expect(404);
});
it("should get team event-types", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
console.log("asap responseBody.data", JSON.stringify(responseBody.data, null, 2));
expect(data.length).toEqual(2);
const eventTypeCollective = data.find((eventType) => eventType.schedulingType === "COLLECTIVE");
const eventTypeManaged = data.find((eventType) => eventType.schedulingType === "MANAGED");
expect(eventTypeCollective?.title).toEqual(collectiveEventType.title);
expect(eventTypeCollective?.hosts.length).toEqual(2);
expect(eventTypeManaged?.title).toEqual(managedEventType.title);
expect(eventTypeManaged?.hosts.length).toEqual(2);
evaluateHost(collectiveEventType.hosts[0], eventTypeCollective?.hosts[0]);
evaluateHost(collectiveEventType.hosts[1], eventTypeCollective?.hosts[1]);
});
});
it("should not be able to update event-type for incorrect team", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
title: "Clean code consultation",
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
.send(body)
.expect(404);
});
it("should not be able to update non existing event-type", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
title: "Clean code consultation",
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/999999`)
.send(body)
.expect(404);
});
it("should update collective event-type", async () => {
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
{
userId: teammate1.id,
mandatory: true,
priority: "medium",
},
];
const body: UpdateTeamEventTypeInput_2024_06_14 = {
hosts: newHosts,
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
.send(body)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const eventType = responseBody.data;
expect(eventType.title).toEqual(collectiveEventType.title);
expect(eventType.hosts.length).toEqual(1);
evaluateHost(eventType.hosts[0], newHosts[0]);
});
});
it("should update managed event-type", async () => {
const newTitle = "Coding consultation managed updated";
const newHosts: UpdateTeamEventTypeInput_2024_06_14["hosts"] = [
{
userId: teammate1.id,
mandatory: true,
priority: "medium",
},
];
const body: UpdateTeamEventTypeInput_2024_06_14 = {
title: newTitle,
hosts: newHosts,
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(2);
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
const managedTeamEventTypes = teamEventTypes.filter(
(eventType) => eventType.schedulingType === "MANAGED"
);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate2EventTypes.length).toEqual(0);
expect(managedTeamEventTypes.length).toEqual(1);
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(false);
const responseTeamEvent = responseBody.data[0];
expect(responseTeamEvent.title).toEqual(newTitle);
expect(responseTeamEvent.assignAllTeamMembers).toEqual(false);
const responseTeammate1Event = responseBody.data[1];
expect(responseTeammate1Event.title).toEqual(newTitle);
expect(teammate1EventTypes[0].title).toEqual(newTitle);
expect(
teamEventTypes.filter((eventType) => eventType.schedulingType === "MANAGED")?.[0]?.title
).toEqual(newTitle);
managedEventType = responseBody.data[0];
});
});
it("should assign all members to managed event-type", async () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
assignAllTeamMembers: true,
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`)
.send(body)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const data = responseBody.data;
expect(data.length).toEqual(3);
const teammate1EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate1.id);
const teammate2EventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(teammate2.id);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
const managedTeamEventTypes = teamEventTypes.filter(
(eventType) => eventType.schedulingType === "MANAGED"
);
expect(teammate1EventTypes.length).toEqual(1);
expect(teammate2EventTypes.length).toEqual(1);
expect(managedTeamEventTypes.length).toEqual(1);
expect(managedTeamEventTypes[0].assignAllTeamMembers).toEqual(true);
const responseTeamEvent = responseBody.data[0];
expect(responseTeamEvent?.teamId).toEqual(team.id);
expect(responseTeamEvent.assignAllTeamMembers).toEqual(true);
const responseTeammate1Event = responseBody.data[1];
expect(responseTeammate1Event?.ownerId).toEqual(teammate1.id);
expect(responseTeammate1Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
const responseTeammate2Event = responseBody.data[2];
expect(responseTeammate2Event?.ownerId).toEqual(teammate2.id);
expect(responseTeammate2Event?.parentEventTypeId).toEqual(responseTeamEvent?.id);
managedEventType = responseTeamEvent;
});
});
it("should not delete event-type of team outside org", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/teams/${falseTestTeam.id}/event-types/${collectiveEventType.id}`)
.expect(404);
});
it("should delete event-type not part of the team", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/99999`)
.expect(404);
});
it("should delete collective event-type", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${collectiveEventType.id}`)
.expect(200);
});
it("should delete managed event-type", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${managedEventType.id}`)
.expect(200);
});
function evaluateHost(expected: Host, received: Host | undefined) {
expect(expected.userId).toEqual(received?.userId);
expect(expected.mandatory).toEqual(received?.mandatory);
expect(expected.priority).toEqual(received?.priority);
}
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(userAdmin.email);
await userRepositoryFixture.deleteByEmail(teammate1.email);
await userRepositoryFixture.deleteByEmail(teammate2.email);
await userRepositoryFixture.deleteByEmail(falseTestUser.email);
await teamsRepositoryFixture.delete(team.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,21 @@
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNotEmptyObject, ValidateNested } from "class-validator";
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
export class CreateTeamEventTypeOutput extends ApiResponseWithoutData {
@IsNotEmptyObject()
@ValidateNested()
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
{
type: "array",
items: { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
},
],
})
@Type(() => TeamEventTypeOutput_2024_06_14)
data!: TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[];
}
@@ -0,0 +1,10 @@
import { Type } from "class-transformer";
import { ValidateNested } from "class-validator";
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
export class DeleteTeamEventTypeOutput extends ApiResponseWithoutData {
@ValidateNested()
@Type(() => TeamEventTypeOutput_2024_06_14)
data!: Pick<TeamEventTypeOutput_2024_06_14, "id" | "title">;
}
@@ -0,0 +1,10 @@
import { Type } from "class-transformer";
import { ValidateNested } from "class-validator";
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
export class GetTeamEventTypeOutput extends ApiResponseWithoutData {
@ValidateNested()
@Type(() => TeamEventTypeOutput_2024_06_14)
data!: TeamEventTypeOutput_2024_06_14;
}
@@ -0,0 +1,10 @@
import { Type } from "class-transformer";
import { ValidateNested } from "class-validator";
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
export class GetTeamEventTypesOutput extends ApiResponseWithoutData {
@ValidateNested({ each: true })
@Type(() => TeamEventTypeOutput_2024_06_14)
data!: TeamEventTypeOutput_2024_06_14[];
}
@@ -0,0 +1,21 @@
import { ApiProperty, getSchemaPath } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsNotEmptyObject, ValidateNested } from "class-validator";
import { ApiResponseWithoutData, TeamEventTypeOutput_2024_06_14 } from "@calcom/platform-types";
export class UpdateTeamEventTypeOutput extends ApiResponseWithoutData {
@IsNotEmptyObject()
@ValidateNested()
@ApiProperty({
oneOf: [
{ $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
{
type: "array",
items: { $ref: getSchemaPath(TeamEventTypeOutput_2024_06_14) },
},
],
})
@Type(() => TeamEventTypeOutput_2024_06_14)
data!: TeamEventTypeOutput_2024_06_14 | TeamEventTypeOutput_2024_06_14[];
}
@@ -0,0 +1,311 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input";
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 { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Membership, Team } from "@calcom/prisma/client";
describe("Organizations Memberships Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: Membership;
const userEmail = "org-admin-membership-controller-e2e@api.com";
const userEmail2 = "org-member-membership-controller-e2e@api.com";
const invitedUserEmail = "org-member-invited-membership-controller-e2e@api.com";
let user: User;
let user2: User;
let userToInviteViaApi: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
});
userToInviteViaApi = await userRepositoryFixture.create({
email: invitedUserEmail,
username: invitedUserEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
membership = await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
membership2 = await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should get all the memberships of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership.id);
expect(responseBody.data[1].id).toEqual(membership2.id);
});
});
it("should get all the memberships of the org paginated", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership2.id);
expect(responseBody.data[0].userId).toEqual(user2.id);
});
});
it("should fail if org does not exist", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/120494059/memberships`).expect(403);
});
it("should get the membership of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships/${membership.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membership.id);
expect(responseBody.data.userId).toEqual(user.id);
});
});
it("should create the membership of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/memberships`)
.send({
userId: userToInviteViaApi.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgMembershipDto)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = 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);
});
});
it("should update the membership of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
.send({
role: "OWNER",
} satisfies UpdateOrgMembershipDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.role).toEqual("OWNER");
});
});
it("should delete the membership of the org we created via api", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
});
});
it("should fail to get the membership of the org we just deleted", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(404);
});
it("should fail if the membership does not exist", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships/123132145`)
.expect(404);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
describe("Organizations Memberships Endpoints", () => {
describe("User Authentication - User is Org Member", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let membership: Membership;
const userEmail = "org-member-memberships-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
membership = await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should deny get all the memberships of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/memberships`).expect(403);
});
it("should deny get all the memberships of the org paginated", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships?skip=1&take=1`)
.expect(403);
});
it("should deny get the membership of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/memberships/${membership.id}`)
.expect(403);
});
it("should deny create the membership for the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/memberships`)
.send({
role: "OWNER",
userId: user.id,
accepted: true,
} satisfies CreateOrgMembershipDto)
.expect(403);
});
it("should deny update the membership of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/memberships/${membership.id}`)
.send({
role: "MEMBER",
} satisfies Partial<CreateOrgMembershipDto>)
.expect(403);
});
it("should deny delete the membership of the org we created via api", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/memberships/${membership.id}`)
.expect(403);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,127 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetMembership } from "@/modules/auth/decorators/get-membership/get-membership.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsMembershipInOrg } from "@/modules/auth/guards/memberships/is-membership-in-org.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { CreateOrgMembershipDto } from "@/modules/organizations/inputs/create-organization-membership.input";
import { UpdateOrgMembershipDto } from "@/modules/organizations/inputs/update-organization-membership.input";
import { CreateOrgMembershipOutput } from "@/modules/organizations/outputs/organization-membership/create-membership.output";
import { DeleteOrgMembership } from "@/modules/organizations/outputs/organization-membership/delete-membership.output";
import { GetAllOrgMemberships } from "@/modules/organizations/outputs/organization-membership/get-all-memberships.output";
import { GetOrgMembership } from "@/modules/organizations/outputs/organization-membership/get-membership.output";
import { OrgMembershipOutputDto } from "@/modules/organizations/outputs/organization-membership/membership.output";
import { UpdateOrgMembership } from "@/modules/organizations/outputs/organization-membership/update-membership.output";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
import { Membership } from "@calcom/prisma/client";
@Controller({
path: "/v2/organizations/:orgId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Memberships")
export class OrganizationsMembershipsController {
constructor(private organizationsMembershipService: OrganizationsMembershipService) {}
@Roles("ORG_ADMIN")
@Get("/")
@HttpCode(HttpStatus.OK)
async getAllMemberships(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetAllOrgMemberships> {
const { skip, take } = queryParams;
const memberships = await this.organizationsMembershipService.getPaginatedOrgMemberships(
orgId,
skip ?? 0,
take ?? 250
);
return {
status: SUCCESS_STATUS,
data: memberships.map((membership) =>
plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" })
),
};
}
@Roles("ORG_ADMIN")
@Post("/")
@HttpCode(HttpStatus.CREATED)
async createMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreateOrgMembershipDto
): Promise<CreateOrgMembershipOutput> {
const membership = await this.organizationsMembershipService.createOrgMembership(orgId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsMembershipInOrg)
@Get("/:membershipId")
@HttpCode(HttpStatus.OK)
async getUserSchedule(@GetMembership() membership: Membership): Promise<GetOrgMembership> {
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsMembershipInOrg)
@Delete("/:membershipId")
@HttpCode(HttpStatus.OK)
async deleteMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("membershipId", ParseIntPipe) membershipId: number
): Promise<DeleteOrgMembership> {
const membership = await this.organizationsMembershipService.deleteOrgMembership(orgId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
};
}
@UseGuards(IsMembershipInOrg)
@Roles("ORG_ADMIN")
@Patch("/:membershipId")
@HttpCode(HttpStatus.OK)
async updateMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("membershipId", ParseIntPipe) membershipId: number,
@Body() body: UpdateOrgMembershipDto
): Promise<UpdateOrgMembership> {
const membership = await this.organizationsMembershipService.updateOrgMembership(
orgId,
membershipId,
body
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgMembershipOutputDto, membership, { strategy: "excludeAll" }),
};
}
}
@@ -1,81 +0,0 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import * as request from "supertest";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Organizations Team Endpoints", () => {
describe("User Authentication", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let org: Team;
const userEmail = "org-teams-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should get all the teams of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toEqual([]);
});
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -1,31 +0,0 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
@Controller({
path: "/v2/organizations/:orgId/teams",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsController {
@Get()
async getAllTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@GetOrg() organization: Team,
@GetOrg("name") orgName: string
): Promise<ApiResponse<Team[]>> {
console.log(orgId, organization, orgName);
return {
status: SUCCESS_STATUS,
data: [],
};
}
}
@@ -0,0 +1,139 @@
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsUserInOrg } from "@/modules/auth/guards/users/is-user-in-org.guard";
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
import {
Controller,
UseGuards,
Get,
Post,
Param,
ParseIntPipe,
Body,
Patch,
Delete,
HttpCode,
HttpStatus,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateScheduleInput_2024_06_11,
CreateScheduleOutput_2024_06_11,
DeleteScheduleOutput_2024_06_11,
GetScheduleOutput_2024_06_11,
GetSchedulesOutput_2024_06_11,
UpdateScheduleInput_2024_06_11,
UpdateScheduleOutput_2024_06_11,
} from "@calcom/platform-types";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Schedules")
export class OrganizationsSchedulesController {
constructor(
private schedulesService: SchedulesService_2024_06_11,
private organizationScheduleService: OrganizationsSchedulesService
) {}
@Roles("ORG_ADMIN")
@Get("/schedules")
async getOrganizationSchedules(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
const { skip, take } = queryParams;
const schedules = await this.organizationScheduleService.getOrganizationSchedules(orgId, skip, take);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Post("/users/:userId/schedules")
async createUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Body() bodySchedule: CreateScheduleInput_2024_06_11
): Promise<CreateScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.createUserSchedule(userId, bodySchedule);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules/:scheduleId")
async getUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Param("scheduleId") scheduleId: number
): Promise<GetScheduleOutput_2024_06_11> {
const schedule = await this.schedulesService.getUserSchedule(userId, scheduleId);
return {
status: SUCCESS_STATUS,
data: schedule,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Get("/users/:userId/schedules")
async getUserSchedules(
@Param("userId", ParseIntPipe) userId: number
): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(userId);
return {
status: SUCCESS_STATUS,
data: schedules,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Patch("/users/:userId/schedules/:scheduleId")
async updateUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Param("scheduleId", ParseIntPipe) scheduleId: number,
@Body() bodySchedule: UpdateScheduleInput_2024_06_11
): Promise<UpdateScheduleOutput_2024_06_11> {
const updatedSchedule = await this.schedulesService.updateUserSchedule(userId, scheduleId, bodySchedule);
return {
status: SUCCESS_STATUS,
data: updatedSchedule,
};
}
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
@Delete("/users/:userId/schedules/:scheduleId")
@HttpCode(HttpStatus.OK)
async deleteUserSchedule(
@Param("userId", ParseIntPipe) userId: number,
@Param("scheduleId", ParseIntPipe) scheduleId: number
): Promise<DeleteScheduleOutput_2024_06_11> {
await this.schedulesService.deleteUserSchedule(userId, scheduleId);
return {
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,338 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import {
CreateScheduleInput_2024_06_11,
CreateScheduleOutput_2024_06_11,
GetScheduleOutput_2024_06_11,
GetSchedulesOutput_2024_06_11,
ScheduleAvailabilityInput_2024_06_11,
ScheduleOutput_2024_06_11,
UpdateScheduleInput_2024_06_11,
UpdateScheduleOutput_2024_06_11,
} from "@calcom/platform-types";
import { User, Team, Membership, Profile } from "@calcom/prisma/client";
describe("Organizations Schedules Endpoints", () => {
describe("User lacks required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "mr-robot@schedules-api.com";
let user: User;
let org: Team;
let membership: Membership;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "Ecorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should not be able to create schedule for org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
.send({
name: "work",
timeZone: "Europe/Rome",
isDefault: true,
})
.expect(403);
});
it("should not be able to get org schedules", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/schedules`).expect(403);
});
it("should mot be able to get user schedules", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
.expect(403);
});
afterAll(async () => {
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
describe("User has required role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const userEmail = "mr-robot@schedules-api.com";
const username = "mr-robot";
let user: User;
let org: Team;
let profile: Profile;
let membership: Membership;
let createdSchedule: ScheduleOutput_2024_06_11;
const createScheduleInput: CreateScheduleInput_2024_06_11 = {
name: "work",
timeZone: "Europe/Rome",
isDefault: true,
};
const defaultAvailability: ScheduleAvailabilityInput_2024_06_11[] = [
{
days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"],
startTime: "09:00",
endTime: "17:00",
},
];
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "Ecorp",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
profile = await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: username,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
membership = await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should create schedule for org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users/${user.id}/schedules`)
.send(createScheduleInput)
.expect(201)
.then(async (response) => {
const responseBody: CreateScheduleOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
createdSchedule = response.body.data;
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(createdSchedule, expectedSchedule, 1);
const scheduleOwner = createdSchedule.ownerId
? await userRepositoryFixture.get(createdSchedule.ownerId)
: null;
expect(scheduleOwner?.defaultScheduleId).toEqual(createdSchedule.id);
});
});
function outputScheduleMatchesExpected(
outputSchedule: ScheduleOutput_2024_06_11 | null,
expected: CreateScheduleInput_2024_06_11 & {
availability: CreateScheduleInput_2024_06_11["availability"];
} & {
overrides: CreateScheduleInput_2024_06_11["overrides"];
},
expectedAvailabilityLength: number
) {
expect(outputSchedule).toBeTruthy();
expect(outputSchedule?.name).toEqual(expected.name);
expect(outputSchedule?.timeZone).toEqual(expected.timeZone);
expect(outputSchedule?.isDefault).toEqual(expected.isDefault);
expect(outputSchedule?.availability.length).toEqual(expectedAvailabilityLength);
const outputScheduleAvailability = outputSchedule?.availability[0];
expect(outputScheduleAvailability).toBeDefined();
expect(outputScheduleAvailability?.days).toEqual(expected.availability?.[0].days);
expect(outputScheduleAvailability?.startTime).toEqual(expected.availability?.[0].startTime);
expect(outputScheduleAvailability?.endTime).toEqual(expected.availability?.[0].endTime);
expect(JSON.stringify(outputSchedule?.overrides)).toEqual(JSON.stringify(expected.overrides));
}
it("should get org schedules", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/schedules`)
.expect(200)
.then(async (response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const schedules = response.body.data;
expect(schedules.length).toEqual(1);
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
});
});
it("should get org user schedule", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
.expect(200)
.then(async (response) => {
const responseBody: GetScheduleOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const schedule = response.body.data;
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(schedule, expectedSchedule, 1);
});
});
it("should get user schedules", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users/${user.id}/schedules/`)
.expect(200)
.then(async (response) => {
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const schedules = response.body.data;
expect(schedules.length).toEqual(1);
const expectedSchedule = {
...createScheduleInput,
availability: defaultAvailability,
overrides: [],
};
outputScheduleMatchesExpected(schedules[0], expectedSchedule, 1);
});
});
it("should update user schedule name", async () => {
const newScheduleName = "updated-schedule-name";
const body: UpdateScheduleInput_2024_06_11 = {
name: newScheduleName,
};
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
.send(body)
.expect(200)
.then((response: any) => {
const responseData: UpdateScheduleOutput_2024_06_11 = response.body;
expect(responseData.status).toEqual(SUCCESS_STATUS);
const responseSchedule = responseData.data;
const expectedSchedule = { ...createdSchedule, name: newScheduleName };
outputScheduleMatchesExpected(responseSchedule, expectedSchedule, 1);
createdSchedule = responseSchedule;
});
});
it("should delete user schedule", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/users/${user.id}/schedules/${createdSchedule.id}`)
.expect(200);
});
afterAll(async () => {
await profileRepositoryFixture.delete(profile.id);
await membershipFixtures.delete(membership.id);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,342 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
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 { User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Membership, Team } from "@calcom/prisma/client";
describe("Organizations Teams Memberships Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let orgTeam: Team;
let nonOrgTeam: Team;
let membership: Membership;
let membership2: Membership;
let membershipCreatedViaApi: Membership;
const userEmail = "org-admin-membership-teams-controller-e2e@api.com";
const userEmail2 = "org-member-membership-teams-controller-e2e@api.com";
const nonOrgUserEmail = "non-org-member-membership-teams-controller-e2e@api.com";
const invitedUserEmail = "org-member-invited-membership-teams-controller-e2e@api.com";
let user: User;
let user2: User;
let nonOrgUser: User;
let userToInviteViaApi: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
user2 = await userRepositoryFixture.create({
email: userEmail2,
username: userEmail2,
});
nonOrgUser = await userRepositoryFixture.create({
email: nonOrgUserEmail,
username: nonOrgUserEmail,
});
userToInviteViaApi = await userRepositoryFixture.create({
email: invitedUserEmail,
username: invitedUserEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
orgTeam = await teamsRepositoryFixture.create({
name: "Org Team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
nonOrgTeam = await teamsRepositoryFixture.create({
name: "Non Org Team",
isOrganization: false,
});
membership = await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: orgTeam.id } },
});
membership2 = await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: orgTeam.id } },
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user2.id } },
team: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: userToInviteViaApi.id } },
team: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: nonOrgUser.id } },
team: { connect: { id: nonOrgTeam.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${user2.id}`,
username: userEmail2,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user2.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${userToInviteViaApi.id}`,
username: invitedUserEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: userToInviteViaApi.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should get all the memberships of the org's team", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(membership.id);
expect(responseBody.data[1].id).toEqual(membership2.id);
expect(responseBody.data.length).toEqual(2);
});
});
it("should fail to get all the memberships of team which is not in the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`)
.expect(404);
});
it("should get all the memberships of the org's team paginated", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership[]> = 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.length).toEqual(1);
});
});
it("should fail if org does not exist", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/120494059/teams/${orgTeam.id}/memberships`)
.expect(403);
});
it("should get the membership of the org's team", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membership.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membership.id);
expect(responseBody.data.userId).toEqual(user.id);
});
});
it("should fail to get the membership of a team not in the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships/${membership.id}`)
.expect(404);
});
it("should fail to create the membership of a team not in the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${nonOrgTeam.id}/memberships`)
.send({
userId: userToInviteViaApi.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(404);
});
it("should create the membership of the org's team", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.send({
userId: userToInviteViaApi.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = 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);
});
});
it("should fail to create the membership of the org's team for a non org user", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.send({
userId: nonOrgUser.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(422);
});
it("should update the membership of the org's team", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
.send({
role: "OWNER",
} satisfies UpdateOrgTeamMembershipDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.role).toEqual("OWNER");
});
});
it("should delete the membership of the org's team we created via api", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Membership> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(membershipCreatedViaApi.id);
});
});
it("should fail to get the membership of the org's team we just deleted", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(404);
});
it("should fail if the membership does not exist", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships/123132145`)
.expect(404);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
await userRepositoryFixture.deleteByEmail(nonOrgUser.email);
await userRepositoryFixture.deleteByEmail(user2.email);
await organizationsRepositoryFixture.delete(org.id);
await organizationsRepositoryFixture.delete(nonOrgTeam.id);
await app.close();
});
});
});
@@ -0,0 +1,158 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/inputs/update-organization-team-membership.input";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import {
OrgTeamMembershipOutputDto,
OrgTeamMembershipsOutputResponseDto,
OrgTeamMembershipOutputResponseDto,
} from "@/modules/organizations/outputs/organization-teams-memberships.output";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
HttpStatus,
HttpCode,
UnprocessableEntityException,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/organizations/:orgId/teams/:teamId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard, IsTeamInOrg)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsMembershipsController {
constructor(
private organizationsTeamsMembershipsService: OrganizationsTeamsMembershipsService,
private readonly organizationsRepository: OrganizationsRepository
) {}
@Get("/")
@ApiOperation({ summary: "Get all the memberships of a team of an organization." })
@UseGuards()
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getAllOrgTeamMemberships(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<OrgTeamMembershipsOutputResponseDto> {
const { skip, take } = queryParams;
const orgTeamMemberships = await this.organizationsTeamsMembershipsService.getPaginatedOrgTeamMemberships(
orgId,
teamId,
skip ?? 0,
take ?? 250
);
return {
status: SUCCESS_STATUS,
data: orgTeamMemberships.map((membership) =>
plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" })
),
};
}
@Get("/:membershipId")
@ApiOperation({ summary: "Get the membership of an organization's team by ID" })
@UseGuards()
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getOrgTeamMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number
): Promise<OrgTeamMembershipOutputResponseDto> {
const orgTeamMembership = await this.organizationsTeamsMembershipsService.getOrgTeamMembership(
orgId,
teamId,
membershipId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, orgTeamMembership, { strategy: "excludeAll" }),
};
}
@Roles("TEAM_ADMIN")
@Delete("/:membershipId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete the membership of an organization's team by ID" })
async deleteOrgTeamMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number
): Promise<OrgTeamMembershipOutputResponseDto> {
const membership = await this.organizationsTeamsMembershipsService.deleteOrgTeamMembership(
orgId,
teamId,
membershipId
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
};
}
@Roles("TEAM_ADMIN")
@Patch("/:membershipId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Update the membership of an organization's team by ID" })
async updateOrgTeamMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number,
@Body() data: UpdateOrgTeamMembershipDto
): Promise<OrgTeamMembershipOutputResponseDto> {
const membership = await this.organizationsTeamsMembershipsService.updateOrgTeamMembership(
orgId,
teamId,
membershipId,
data
);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
};
}
@Roles("TEAM_ADMIN")
@Post("/")
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Create a membership of an organization's team" })
async createOrgTeamMembership(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() data: CreateOrgTeamMembershipDto
): Promise<OrgTeamMembershipOutputResponseDto> {
const user = await this.organizationsRepository.findOrgUser(Number(orgId), Number(data.userId));
if (!user) {
throw new UnprocessableEntityException("User is not part of the Organization");
}
const membership = await this.organizationsTeamsMembershipsService.createOrgTeamMembership(teamId, data);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamMembershipOutputDto, membership, { strategy: "excludeAll" }),
};
}
}
@@ -0,0 +1,438 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
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 { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Organizations Team Endpoints", () => {
describe("User Authentication - User is Org Admin", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let team: Team;
let team2: Team;
let teamCreatedViaApi: Team;
const userEmail = "org-admin-teams-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
team2 = await teamsRepositoryFixture.create({
name: "Test org team 2",
isOrganization: false,
parent: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should get all the teams of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(team.id);
expect(responseBody.data[1].id).toEqual(team2.id);
});
});
it("should get all the teams of the org paginated", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(team2.id);
});
});
it("should fail if org does not exist", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/120494059/teams`).expect(403);
});
it("should get the team of the org", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(team.id);
expect(responseBody.data.parentId).toEqual(team.parentId);
});
});
it("should create the team of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API");
expect(teamCreatedViaApi.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
user.id,
teamCreatedViaApi.id
);
expect(membership?.role ?? "").toEqual("OWNER");
});
});
it("should update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.send({
name: "Team created via API Updated",
} satisfies CreateOrgTeamDto)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API Updated");
expect(teamCreatedViaApi.parentId).toEqual(org.id);
});
});
it("should delete the team of the org we created via api", async () => {
return request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(teamCreatedViaApi.id);
expect(responseBody.data.parentId).toEqual(teamCreatedViaApi.parentId);
});
});
it("should fail to get the team of the org we just deleted", async () => {
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.expect(404);
});
it("should fail if the team does not exist", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/123132145`).expect(404);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
describe("Organizations Team Endpoints", () => {
describe("User Authentication - User is Org Member", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let team: Team;
let team2: Team;
const userEmail = "org-member-teams-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
team2 = await teamsRepositoryFixture.create({
name: "Test org team 2",
isOrganization: false,
parent: { connect: { id: org.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should deny get all the teams of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
});
it("should deny get all the teams of the org paginated", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
});
it("should deny get the team of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(403);
});
it("should deny create the team of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}`)
.send({
name: "Team created via API Updated",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny delete the team of the org we created via api", async () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
describe("Organizations Team Endpoints", () => {
describe("User Authentication - User is Team Owner", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let org: Team;
let team: Team;
let team2: Team;
const userEmail = "org-member-teams-owner-controller-e2e@api.com";
let user: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
});
org = await organizationsRepositoryFixture.create({
name: "Test Organization",
isOrganization: true,
});
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: user.id } },
team: { connect: { id: org.id } },
});
team = await teamsRepositoryFixture.create({
name: "Test org team",
isOrganization: false,
parent: { connect: { id: org.id } },
});
team2 = await teamsRepositoryFixture.create({
name: "Test org team 2",
isOrganization: false,
parent: { connect: { id: org.id } },
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: team.id } },
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: user.id } },
team: { connect: { id: team2.id } },
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should deny get all the teams of the org", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams`).expect(403);
});
it("should deny get all the teams of the org paginated", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams?skip=1&take=1`).expect(403);
});
it("should get the team of the org for which the user is team owner", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/teams/${team.id}`).expect(200);
});
it("should deny create the team of the org", async () => {
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny update the team of the org", async () => {
return request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/teams/${team.id}`)
.send({
name: "Team created via API Updated",
} satisfies CreateOrgTeamDto)
.expect(403);
});
it("should deny delete the team of the org we created via api", async () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/teams/${team2.id}`).expect(403);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(user.email);
await teamsRepositoryFixture.delete(team.id);
await teamsRepositoryFixture.delete(team2.id);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,117 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetTeam } from "@/modules/auth/decorators/get-team/get-team.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 { 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 { CreateOrgTeamDto } from "@/modules/organizations/inputs/create-organization-team.input";
import {
OrgTeamOutputDto,
OrgTeamOutputResponseDto,
OrgTeamsOutputResponseDto,
} from "@/modules/organizations/outputs/organization-team.output";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SkipTakePagination } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
@Controller({
path: "/v2/organizations/:orgId/teams",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@DocsTags("Organizations Teams")
export class OrganizationsTeamsController {
constructor(private organizationsTeamsService: OrganizationsTeamsService) {}
@Get()
@ApiOperation({ summary: "Get all the teams of an organization." })
@UseGuards()
@Roles("ORG_ADMIN")
async getAllTeams(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() queryParams: SkipTakePagination
): Promise<OrgTeamsOutputResponseDto> {
const { skip, take } = queryParams;
const teams = await this.organizationsTeamsService.getPaginatedOrgTeams(orgId, skip ?? 0, take ?? 250);
return {
status: SUCCESS_STATUS,
data: teams.map((team) => plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" })),
};
}
@UseGuards(IsTeamInOrg)
@Roles("TEAM_ADMIN")
@Get("/:teamId")
@ApiOperation({ summary: "Get a team of the organization by ID." })
async getTeam(@GetTeam() team: Team): Promise<OrgTeamOutputResponseDto> {
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@Delete("/:teamId")
@ApiOperation({ summary: "Delete a team of the organization by ID." })
async deleteTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.deleteOrgTeam(orgId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@UseGuards(IsTeamInOrg)
@Roles("ORG_ADMIN")
@Patch("/:teamId")
@ApiOperation({ summary: "Update a team of the organization by ID." })
async updateTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: CreateOrgTeamDto
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.updateOrgTeam(orgId, teamId, body);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
@Post()
@Roles("ORG_ADMIN")
@ApiOperation({ summary: "Create a team for an organization." })
async createTeam(
@Param("orgId", ParseIntPipe) orgId: number,
@Body() body: CreateOrgTeamDto,
@GetUser() user: UserWithProfile
): Promise<OrgTeamOutputResponseDto> {
const team = await this.organizationsTeamsService.createOrgTeam(orgId, body, user);
return {
status: SUCCESS_STATUS,
data: plainToClass(OrgTeamOutputDto, team, { strategy: "excludeAll" }),
};
}
}
@@ -0,0 +1,115 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetOrg } from "@/modules/auth/decorators/get-org/get-org.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 { 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 { CreateOrganizationUserInput } from "@/modules/organizations/inputs/create-organization-user.input";
import { GetOrganizationsUsersInput } from "@/modules/organizations/inputs/get-organization-users.input";
import { UpdateOrganizationUserInput } from "@/modules/organizations/inputs/update-organization-user.input";
import { GetOrganizationUsersOutput } from "@/modules/organizations/outputs/get-organization-users.output";
import { GetOrganizationUserOutput } from "@/modules/organizations/outputs/get-organization-users.output";
import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
import { GetUserOutput } from "@/modules/users/outputs/get-users.output";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
UseGuards,
Get,
Post,
Patch,
Delete,
Param,
ParseIntPipe,
Body,
UseInterceptors,
Query,
} from "@nestjs/common";
import { ClassSerializerInterceptor } from "@nestjs/common";
import { ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { Team } from "@calcom/prisma/client";
@Controller({
path: "/v2/organizations/:orgId/users",
version: API_VERSIONS_VALUES,
})
@UseInterceptors(ClassSerializerInterceptor)
@UseGuards(ApiAuthGuard, IsOrgGuard, RolesGuard)
@UseGuards(IsOrgGuard)
@DocsTags("Organizations Users")
export class OrganizationsUsersController {
constructor(private readonly organizationsUsersService: OrganizationsUsersService) {}
@Get()
@Roles("ORG_ADMIN")
async getOrganizationsUsers(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrganizationsUsersInput
): Promise<GetOrganizationUsersOutput> {
const users = await this.organizationsUsersService.getUsers(
orgId,
query.emails,
query.skip ?? 0,
query.take ?? 250
);
return {
status: SUCCESS_STATUS,
data: users.map((user) => plainToInstance(GetUserOutput, user, { strategy: "excludeAll" })),
};
}
@Post()
@Roles("ORG_ADMIN")
async createOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@GetOrg() org: Team,
@Body() input: CreateOrganizationUserInput,
@GetUser() inviter: UserWithProfile
): Promise<GetOrganizationUserOutput> {
const user = await this.organizationsUsersService.createUser(
org,
input,
inviter.name ?? inviter.username ?? inviter.email
);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
};
}
@Patch("/:userId")
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
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);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
};
}
@Delete("/:userId")
@Roles("ORG_ADMIN")
@UseGuards(IsUserInOrg)
async deleteOrganizationUser(
@Param("orgId", ParseIntPipe) orgId: number,
@Param("userId", ParseIntPipe) userId: number
): Promise<GetOrganizationUserOutput> {
const user = await this.organizationsUsersService.deleteUser(orgId, userId);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
};
}
}
@@ -0,0 +1,347 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { EmailService } from "@/modules/email/email.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 * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { withApiAuth } from "test/utils/withApiAuth";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { User, Team, Membership } from "@calcom/prisma/client";
describe("Organizations Users Endpoints", () => {
describe("Member role", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
const userEmail = "member1@org.com";
let user: User;
let org: Team;
let membership: Membership;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "Test org 3",
isOrganization: true,
});
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
membership = await membershipFixtures.addUserToOrg(user, org, "MEMBER", true);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should not be able to find org users", async () => {
return request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`).expect(403);
});
it("should not be able to create a new org user", async () => {
return request(app.getHttpServer()).post(`/v2/organizations/${org.id}/users`).expect(403);
});
it("should not be able to update an org user", async () => {
return request(app.getHttpServer()).patch(`/v2/organizations/${org.id}/users/${user.id}`).expect(403);
});
it("should not be able to delete an org user", async () => {
return request(app.getHttpServer()).delete(`/v2/organizations/${org.id}/users/${user.id}`).expect(403);
});
afterAll(async () => {
// await membershipFixtures.delete(membership.id);
await Promise.all([userRepositoryFixture.deleteByEmail(user.email)]);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
await app.close();
});
});
describe("Admin role", () => {
let app: INestApplication;
let profileRepositoryFixture: ProfileRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: TeamRepositoryFixture;
let membershipFixtures: MembershipRepositoryFixture;
const userEmail = "admin1@org.com";
const nonMemberEmail = "non-member@test.com";
let user: User;
let org: Team;
let createdUser: User;
const orgMembersData = [
{
email: "member1@org.com",
username: "member1@org.com",
},
{
email: "member2@org.com",
username: "member2@org.com",
},
{
email: "member3@org.com",
username: "member3@org.com",
},
];
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
membershipFixtures = new MembershipRepositoryFixture(moduleRef);
org = await organizationsRepositoryFixture.create({
name: "Test org 2",
isOrganization: true,
});
await userRepositoryFixture.create({
email: nonMemberEmail,
username: "non-member",
});
const orgMembers = await Promise.all(
orgMembersData.map((member) =>
userRepositoryFixture.create({
email: member.email,
username: member.username,
organization: { connect: { id: org.id } },
})
)
);
// create profiles of orgMember like they would be when being invied to the org
await Promise.all(
orgMembers.map((member) =>
profileRepositoryFixture.create({
uid: `usr-${member.id}`,
username: member.username ?? `usr-${member.id}`,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: member.id,
},
},
})
)
);
user = await userRepositoryFixture.create({
email: userEmail,
username: userEmail,
organization: { connect: { id: org.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${user.id}`,
username: userEmail,
organization: {
connect: {
id: org.id,
},
},
user: {
connect: {
id: user.id,
},
},
});
await membershipFixtures.addUserToOrg(user, org, "ADMIN", true);
await Promise.all(
orgMembers.map((member) => membershipFixtures.addUserToOrg(member, org, "MEMBER", true))
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should be defined", () => {
expect(userRepositoryFixture).toBeDefined();
expect(organizationsRepositoryFixture).toBeDefined();
expect(user).toBeDefined();
expect(org).toBeDefined();
});
it("should get all org users", async () => {
const { body } = await request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`);
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(4);
expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0);
});
it("should only get users with the specified email", async () => {
const { body } = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users`)
.query({
emails: userEmail,
})
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(1);
expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
});
it("should get users within the specified emails array", async () => {
const orgMemberEmail = orgMembersData[0].email;
const { body } = await request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/users`)
.query({
emails: [userEmail, orgMemberEmail],
})
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
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);
});
it("should update an org user", async () => {
const { body } = await request(app.getHttpServer())
.patch(`/v2/organizations/${org.id}/users/${user.id}`)
.send({
theme: "light",
})
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data as User;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.theme).toBe("light");
});
it("should create a new org user", async () => {
const newOrgUser = {
email: "new-org-member-b@org.com",
organizationRole: "MEMBER",
autoAccept: true,
};
const emailSpy = jest
.spyOn(EmailService.prototype, "sendSignupToOrganizationEmail")
.mockImplementation(() => Promise.resolve());
const { body } = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users`)
.send({
email: newOrgUser.email,
})
.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(emailSpy).toHaveBeenCalledWith({
usernameOrEmail: newOrgUser.email,
orgName: org.name,
orgId: org.id,
inviterName: "admin1@org.com",
locale: null,
});
createdUser = userData;
});
it("should delete an org user", async () => {
const { body } = await request(app.getHttpServer())
.delete(`/v2/organizations/${org.id}/users/${createdUser.id}`)
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data as User;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.id).toBe(createdUser.id);
});
afterAll(async () => {
// await membershipFixtures.delete(membership.id);
await Promise.all([
userRepositoryFixture.deleteByEmail(user.email),
userRepositoryFixture.deleteByEmail(nonMemberEmail),
...orgMembersData.map((member) => userRepositoryFixture.deleteByEmail(member.email)),
]);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
});
});
});
@@ -0,0 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
export class CreateOrgMembershipDto {
@IsInt()
readonly userId!: number;
@IsOptional()
@IsBoolean()
readonly accepted?: boolean = false;
@IsEnum(MembershipRole)
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role: MembershipRole = MembershipRole.MEMBER;
@IsOptional()
@IsBoolean()
readonly disableImpersonation?: boolean = false;
}
@@ -0,0 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
export class CreateOrgTeamMembershipDto {
@IsInt()
readonly userId!: number;
@IsOptional()
@IsBoolean()
readonly accepted?: boolean = false;
@IsEnum(MembershipRole)
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role: MembershipRole = MembershipRole.MEMBER;
@IsOptional()
@IsBoolean()
readonly disableImpersonation?: boolean = false;
}
@@ -0,0 +1,75 @@
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class CreateOrgTeamDto {
@IsString()
@Length(1)
readonly name!: string;
@IsOptional()
@IsString()
readonly slug?: string;
@IsOptional()
@IsUrl()
readonly logoUrl?: string;
@IsOptional()
@IsUrl()
readonly calVideoLogo?: string;
@IsOptional()
@IsUrl()
readonly appLogo?: string;
@IsOptional()
@IsUrl()
readonly appIconLogo?: string;
@IsOptional()
@IsString()
readonly bio?: string;
@IsOptional()
@IsBoolean()
readonly hideBranding?: boolean = false;
@IsOptional()
@IsBoolean()
readonly isPrivate?: boolean;
@IsOptional()
@IsBoolean()
readonly hideBookATeamMember?: boolean;
@IsOptional()
@IsString()
readonly metadata?: string; // Assuming metadata is a JSON string. Adjust accordingly if it's a nested object.
@IsOptional()
@IsString()
readonly theme?: string;
@IsOptional()
@IsString()
readonly brandColor?: string;
@IsOptional()
@IsString()
readonly darkBrandColor?: string;
@IsOptional()
@IsUrl()
readonly bannerUrl?: string;
@IsOptional()
@IsString()
readonly timeFormat?: number;
@IsOptional()
@IsString()
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
readonly weekStart?: string = "Sunday";
}
@@ -0,0 +1,17 @@
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { MembershipRole } from "@prisma/client";
import { IsString, IsOptional, IsBoolean, IsEnum } from "class-validator";
export class CreateOrganizationUserInput extends CreateUserInput {
@IsOptional()
@IsString()
locale = "en";
@IsOptional()
@IsEnum(MembershipRole)
organizationRole: MembershipRole = MembershipRole.MEMBER;
@IsOptional()
@IsBoolean()
autoAccept = true;
}
@@ -0,0 +1,3 @@
import { GetUsersInput } from "@/modules/users/inputs/get-users.input";
export class GetOrganizationsUsersInput extends GetUsersInput {}
@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
export class UpdateOrgMembershipDto {
@IsOptional()
@IsBoolean()
readonly accepted?: boolean = false;
@IsEnum(MembershipRole)
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role?: MembershipRole = MembershipRole.MEMBER;
@IsOptional()
@IsBoolean()
readonly disableImpersonation?: boolean = false;
}
@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
export class UpdateOrgTeamMembershipDto {
@IsOptional()
@IsBoolean()
readonly accepted?: boolean = false;
@IsEnum(MembershipRole)
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
readonly role?: MembershipRole = MembershipRole.MEMBER;
@IsOptional()
@IsBoolean()
readonly disableImpersonation?: boolean = false;
}
@@ -0,0 +1,3 @@
import { UpdateUserInput } from "@/modules/users/inputs/update-user.input";
export class UpdateOrganizationUserInput extends UpdateUserInput {}
@@ -1,14 +1,84 @@
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/organizations-teams.controller";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { EmailModule } from "@/modules/email/email.module";
import { EmailService } from "@/modules/email/email.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsEventTypesController } from "@/modules/organizations/controllers/event-types/organizations-event-types.controller";
import { OrganizationsMembershipsController } from "@/modules/organizations/controllers/memberships/organizations-membership.controller";
import { OrganizationsSchedulesController } from "@/modules/organizations/controllers/schedules/organizations-schedules.controller";
import { OrganizationsTeamsMembershipsController } from "@/modules/organizations/controllers/teams/memberships/organizations-teams-memberships.controller";
import { OrganizationsTeamsController } from "@/modules/organizations/controllers/teams/organizations-teams.controller";
import { OrganizationsUsersController } from "@/modules/organizations/controllers/users/organizations-users.controller";
import { OrganizationsRepository } from "@/modules/organizations/organizations.repository";
import { OrganizationsEventTypesRepository } from "@/modules/organizations/repositories/organizations-event-types.repository";
import { OrganizationsMembershipRepository } from "@/modules/organizations/repositories/organizations-membership.repository";
import { OrganizationSchedulesRepository } from "@/modules/organizations/repositories/organizations-schedules.repository";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/repositories/organizations-teams-memberships.repository";
import { OrganizationsTeamsRepository } from "@/modules/organizations/repositories/organizations-teams.repository";
import { OrganizationsUsersRepository } from "@/modules/organizations/repositories/organizations-users.repository";
import { InputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/input.service";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { OutputOrganizationsEventTypesService } from "@/modules/organizations/services/event-types/output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/services/organizations-membership.service";
import { OrganizationsSchedulesService } from "@/modules/organizations/services/organizations-schedules.service";
import { OrganizationsTeamsMembershipsService } from "@/modules/organizations/services/organizations-teams-memberships.service";
import { OrganizationsTeamsService } from "@/modules/organizations/services/organizations-teams.service";
import { OrganizationsUsersService } from "@/modules/organizations/services/organizations-users-service";
import { OrganizationsService } from "@/modules/organizations/services/organizations.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, StripeModule],
providers: [OrganizationsRepository, OrganizationsService],
exports: [OrganizationsService, OrganizationsRepository],
controllers: [OrganizationsTeamsController],
imports: [
PrismaModule,
StripeModule,
SchedulesModule_2024_06_11,
UsersModule,
RedisModule,
EmailModule,
EventTypesModule_2024_06_14,
],
providers: [
OrganizationsRepository,
OrganizationsTeamsRepository,
OrganizationsService,
OrganizationsTeamsService,
MembershipsRepository,
OrganizationsSchedulesService,
OrganizationSchedulesRepository,
OrganizationsUsersRepository,
OrganizationsUsersService,
EmailService,
OrganizationsMembershipRepository,
OrganizationsMembershipService,
OrganizationsEventTypesService,
InputOrganizationsEventTypesService,
OutputOrganizationsEventTypesService,
OrganizationsEventTypesRepository,
OrganizationsTeamsMembershipsRepository,
OrganizationsTeamsMembershipsService,
],
exports: [
OrganizationsService,
OrganizationsRepository,
OrganizationsTeamsRepository,
OrganizationsUsersRepository,
OrganizationsUsersService,
OrganizationsMembershipRepository,
OrganizationsMembershipService,
OrganizationsTeamsMembershipsRepository,
OrganizationsTeamsMembershipsService,
],
controllers: [
OrganizationsTeamsController,
OrganizationsSchedulesController,
OrganizationsUsersController,
OrganizationsMembershipsController,
OrganizationsEventTypesController,
OrganizationsTeamsMembershipsController,
],
})
export class OrganizationsModule {}

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