Compare commits

..
94 Commits
Author SHA1 Message Date
GitHub Actions 6b1c747c0e chore: release v4.7.24
Post release / release (push) Canceled after 0s
2024-12-12 17:22:03 +00:00
Benny Joo 04036c32a7 fix: filling users logic (#18128)
* fix filling users logic

* fix type check

* refactor
2024-12-12 17:12:56 +00:00
Alex van Andel 49070b96ae Revert "feat: add text & numeric filters to insights routing responses (#18016)"
This reverts commit a7e7561efa.
2024-12-12 17:10:49 +00:00
GitHub Actions 237fea0a41 chore: release v4.7.23
Post release / release (push) Canceled after 0s
2024-12-12 16:40:04 +00:00
a7e7561efa feat: add text & numeric filters to insights routing responses (#18016)
* feat: improve text filters (WIP)

* move function to bottom

* apply some styles

* fix selection of TextFilterOptions

* rename value to operand

* remove unused file

* merge filters/filters into filters/utils

* fix regression of not putting url params correctly

* move makeWhereClause to filters/utils

* fix negative, empty, and not empty operators

* fix initial filtering from search state (url)

* fix type errors

* do not send an empty array to query

* update yarn.lock

* i18n for text filter operators

* extract logic as useColumnFilters()

* add missing import

* fix type error

* revert yarn.lock

* use i18n

* insensitive text match

* move data-table to @calcom/features

* fix type errors

* fix type errors

* fix type errors

* feat: support DataTable filters for Insights Routing WIP

* remove unused filters

* remove additionalFilters and fix types

* clean up filter components

* support icons for ActiveFilters

* support filters on json

* fix filter ui

* fix type error and clean up

* revert changes

* revert change

* clean up

* revert change

* fix compatibility with insights booking page

* remove unused params

* fix type errors

* update yarn.lock

* fix field filter and adjust ui

* chore: update yarn.lock

* fix text filter

* add Clear Filters button

* add more test data

* feat: support numeric filter WIP

* feat: support numeric filter

* feat: move People to an external filter

* fixing query states

* rename state to activeFilters

* add useDataTable

* fix ResponseCellValue bug and enable resizing for RoutingFormResponsesTable

* fix response values

* truncate attribute text

* seed booking attendees

* fix accessor (no actual change though)

* add a gap

* rename

* use pathname as default identifier for DataTable

* fix type error

* support text filter for assignment reason

* Apply suggestions from code review

Co-authored-by: Alex van Andel <me@alexvanandel.com>

* rename fields to headers

* use safeParse

* fix type error

* push yarn.lock with faker

* fix MemberList

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: sean-brydon <sean@cal.com>
2024-12-12 16:06:30 +00:00
Alex van Andel e366c10521 Convert update statement into unique select in getServerSession 2024-12-12 15:50:44 +00:00
Morgan 2ace1d1017 chore: add successRedirectUrl to event-types api v2 (#18136)
* chore: add successRedirectUrl to event-types api v2

* chore: add successRedirectUrl to event-types api v2 patch

* chore: add successRedirectUrl to event-types api v2 patch
2024-12-12 16:32:35 +02:00
sean-brydon fbe31ad82d early return if no users are found to prevent prisma error (#18133) 2024-12-12 13:37:39 +01:00
sean-brydonandUdit Takkar ee3c08918a feat: weights per attribute UI (#18109)
* wip attribut weights

* edit sheet and display in table

* add shrink so badges dont split

* push changes to support weights on csv export

* fix typing issue

* type error

* fall back to 100 weight

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-12 11:54:10 +00:00
Lauris Skraucis b6d6b7cdb4 fix: store default booking fields when creating event (#18108)
* refactor: store default booking fields when creating event

* Revert "refactor: store default booking fields when creating event"

This reverts commit 78b7c4eda2be61420b4410bc80a3d77b5557a967.

* refactor: v2 return default booking fields if they are null

* test: v2 returning default booking fields if they are null

* chore: platform-libraries export getBookingFieldsWithSystemFields
2024-12-12 10:58:45 +01:00
Hariom BalharaandUdit Takkar 1b82552e76 Fix crm error log (#18105)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-12 13:15:14 +05:30
Joe Au-Yeung 13a90aba30 Fix logic (#18131) 2024-12-11 21:03:51 -07:00
Alex van Andel de0de64a16 build: Node alpine libssl symlink fix (#18127) 2024-12-12 00:39:36 +00:00
GitHub Actions 8e772d9046 chore: release v4.7.22
Post release / release (push) Canceled after 0s
2024-12-11 23:04:54 +00:00
Omar López 7ac7a14beb refactor: improved observability for Salesforce (#18125)
* refactor: better observability for Salesforce

* Update crmManager.ts

* Update CrmService.d.ts
2024-12-11 22:57:43 +00:00
Alex van Andel 9c127cbfc2 Revert "feat: migrate /apps/routing-forms/forms to /routing (#18001)"
This reverts commit 1de007ad21.
2024-12-11 22:16:50 +00:00
GitHub Actions 187a2c576e chore: release v4.7.22 2024-12-11 21:54:50 +00:00
5e8d994ac1 feat: Change 'add override' to 'save override' (#17434)
* feat: Change 'add override' to 'save override'

* fix: type check

* fix: type check

* autosave in availability edit sheet

* remove unused translation key

* added requested changes

* added requested changes

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-12-11 21:20:42 +00:00
VishnuandPrashant Varma 1de007ad21 feat: migrate /apps/routing-forms/forms to /routing (#18001)
* feat: add getServerSideProps and getStaticProps

* feat: add routing pages view

* feat: add routing and related pages

* feat: setup redirect

* feat: remove routing configs in getStaticProps and getServerSideProps

* feat: add redirects in next.config and middleware

* feat: add split icon

* feat: rename href to /routing/forms

* feat: fix redirects

* feat: finish routing page

* feat: remove /apps/routing-forms/forms redirect in next-config

* feat: revert back redirects in serverSideProps

* fix: minor issues

* fix: routing-form url cloning

* chore: remove comments

* fix: routing page link

* fix: routing hover link

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-12-11 21:18:46 +00:00
michaelvaletteandYounes Henni 06494a6999 fix: correct line-breaks in calendar event description (#18077)
* fix: correct line-breaks in calendar event description

* Update packages/lib/CalEventParser.ts

Co-authored-by: Younes Henni <youneshenniwrites@gmail.com>

* fix: review comments

* fix: better add spacing on who

---------

Co-authored-by: Younes Henni <youneshenniwrites@gmail.com>
2024-12-11 21:18:29 +00:00
Benny Joo 016c47d535 fix: do not export client components directly in app routes (#18122) 2024-12-11 21:17:53 +00:00
Alex van Andel 2aeb290bda fix: Error on webhooks page exporting client only comp directly (#18118)
* fix: Error on webhooks page exporting client only comp directly

* Wrap correctly, WebhooksView contains dynamic logic in the SettingsHeader
2024-12-11 19:42:04 +00:00
Keith Williams 95b4f67842 chore: Add CODEOWNERS (#18116)
* chore: Add CODEOWNERS

* Added the calcom org

* Removed non-critical calendar apps
2024-12-11 18:29:41 +00:00
Benny JooandUdit Takkar 1a4afe06c8 chore: Remove Pages Router / Render App Router - /video (#16518)
* /video: Remove Pages Router / Render App Router

* fix

* add meeting_unavailable i18n

* remove <Head> from videos view

* add correct metadata to video/[uid]

* add safety to videos-single-view logic

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-12-11 18:51:30 +01:00
Keith Williams 0eb72d66a7 revert: "fix: Request permissions to allow events to be created on shared Office365/Outlook calendars (#17760)" (#18111)
This reverts commit 1680cba5a5.
2024-12-11 14:57:57 +00:00
Eunjae Lee 7d2523e823 fix: prevent infinite rerendering of DataTable (#18087) 2024-12-11 14:03:17 +00:00
Benny JooandKeith Williams 6739ed3a87 feat: Title - AI translation for booking pages (#17794)
* feat: AI description - DB model + frontend + backend (fetch only)

* fix types and add validation to backend

* improve log

* improve

* import type

* add EventTypeTranslationRepository

* fix replexica error

* fix replexica error

* fix

* fix test

* wip

* wip

* add createManyDescriptionTranslations method

* improve logic

* refactor

* update replexica type

* improve typing

* Renamed descriptionTranslations to fieldTranslations

* log errors in replexica service

* handle translations in background

* don't create, upsert instead

* make sure that description is updated

* simplify

* fix

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

* improve filter logic in frontend

* implement background job using Tasker

* update schema

* update schema again

* rename

* rename id to uid

* add migration

* fix type

* update

* update schema and migration sql

* fix migration

* fix type check

* fix type check 2

* improve find logic in EventMeta

* add more validation

* add logger

* rename

* upgrade replexica/sdk to 0.7.0

* upgrade replexica to 0.7.0

* use batchLocalizeText

* add comments

* override browser locale if user is a signed in calcom user

* fix type in replexica

* fix migration sql

* fix type

* use dynamic import

* do not use batchLocalizeText

* chore: drop sourceLang, targetLang and id columns

* fix

* wip

* add more validations

* fix type error

* address comment

* refactor

* update booking page to use translated title

* fix type error

* fix

* revert yarn.lock changes

* remove unused import

* remove duplication migrate.sql file

* fix test

* remove test tags

* fix function name

* refactor

* fix type check

* use Promise.all

* remove unneeded code

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-12-11 14:18:32 +01:00
Anik Dhabal Babu dd3505ad8b fix: Insights team filter behavior (#18084)
* fix: Insights team filter behavior

* fix type error
2024-12-11 14:43:25 +05:30
Benny Joo ce9df65b5d chore: Remove Pages Router / Use App Router - /workflows (#16519)
* /workflows: Remove Pages Router / Render App Router

* fix

* unneeded changes

* disable server side fetching for now

* revert unneeded changes
2024-12-11 09:51:22 +01:00
sean-brydon fc6e39c78a feat: assigned bookings per period (#17940)
* wip

* bring in users median status for selected range

* merge

* i18n

* routed to talbe period scroll x-y infinite

* insights i18n + hover card on user

* add search by name or email

* wip download button

* fix type error

* type fix

* type errors + status

* type error
2024-12-11 08:12:06 +00:00
Calcom BotandReplexica 931f3d3396 feat: update translations via @replexica (#18100)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-12-11 00:12:17 +00:00
GitHub Actions 8a6fac6748 chore: release v4.7.21
Post release / release (push) Canceled after 0s
2024-12-11 00:07:34 +00:00
Benny Joo af43ede38e remove pages router and use app router for insights pages (#18088) 2024-12-10 16:56:56 -07:00
Benny Joo a66eb9dfac chore: remove extra fetching and get user locale from headers in settings pages (#18048)
* add settings pages to matcher

* fetch user locale from headers in settings pages
2024-12-10 16:56:12 -07:00
Benny Joo 5ba676b014 chore: app router migration - /enterprise and d/[link]/[slug] (#18101)
* use app router for /enterprise page

* use app router for /d page
2024-12-10 23:48:24 +00:00
Benny Joo c2c082c105 chore: Remove Pages Router / Use App Router - more, maintenance, upgrade, connect-and-join (#16531)
* Remove Pages Router / Render App Router

* remove head component from maintenance view

* use i18n metadata
2024-12-10 14:37:25 -07:00
Joe Au-Yeung 44d6c1f3fb feat: Show routing reason on booking page [CAL-4814] (#18053) 2024-12-10 14:31:48 -07:00
Benny Joo a466e8eef7 feat: remove setup-availability step in onboarding flow (#17799)
* feat: make the onboarding flow 3-step for google signups

* fix

* refactor

* rename

* improve tests

* remove test tag

* fix test and type error

* fix

* feat: remove setup-availability step in onboarding flow

* update test

* revert unneeded change

* add safety

* update create schedule options
2024-12-10 14:30:23 -07:00
VijayandPrashant Varma 370345da22 fix: Not able to create consecutive ooo (#17388)
* fix: able to create consecutive ooo

* added e2e test

* chore

* update to allow overlapping ooo and test for same

* refactor to reduce repeating code in tests

* nit

* added testcases for consecutive and reverse redirects

* fix:duplicate OOO creation

* prevent duplicate entries, also added test

* chore

* update after merges

* correct datetype in prisma query

* update to changes in rdp after merge

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-12-10 19:46:28 +05:30
Kartik SainiandKeith Williams a1fc6668d5 feat: ability to require an email (#18057)
* feat: ability to require an email

* fix: requireEmails empty bug

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

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

* add tests

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-12-10 17:27:32 +05:30
Calcom BotandReplexica 351ab0790f feat: update translations via @replexica (#18078)
Co-authored-by: Replexica <support@replexica.com>
2024-12-10 08:49:46 +00:00
Alex van Andel 504600b151 Change alpine image to one compatible with Prisma (#18080) 2024-12-10 02:50:00 +00:00
GitHub Actions 8b222490c2 chore: release v4.7.20
Post release / release (push) Canceled after 0s
2024-12-10 01:14:21 +00:00
Anwar SadathandAnik Dhabal Babu 0657a96795 feat: Add OAuth support for closecom app linking (#17936)
* feat: Add OAuth support for closecom app linking

* Fix types

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2024-12-10 00:18:30 +00:00
Kartik Saini 9c9d7fe0c3 fix: can't edit ooo (#18020)
* fix: can't edit ooo

* fix: unauthorized users can edit ooo
2024-12-09 23:39:56 +00:00
sean-brydon 950879b9c7 fix: sorting insights table for booking status (#17982)
* Revert "merge columns (#17938)"

This reverts commit a3ceefc00e.

* sort order by status
2024-12-09 23:39:10 +00:00
Peer Richelsen 55dc4bddb6 fixed days of trial (#18074) 2024-12-09 19:04:45 +00:00
Anik Dhabal Babu cbb1b285f5 fix: not able to mark no show (#17980)
* fix: not able to mark no show

* add test

* Update bookings-list.e2e.ts
2024-12-09 14:02:45 -05:00
Anik Dhabal Babu 45f8ecbab7 chore: Add location to workflow reminder template (#17449)
* chore: Add location to workflow reminder template

* update

* remove log
2024-12-09 18:58:17 +00:00
Toon ToetenelandToon Toetenel 1680cba5a5 fix: Request permissions to allow events to be created on shared Office365/Outlook calendars (#17760)
Co-authored-by: Toon Toetenel <toon@breakingbits.nl>
2024-12-09 16:17:48 +00:00
Benny Joo 046042a877 feat: remove 2 steps from the onboarding flow for google signups (#17787) 2024-12-09 15:44:42 +00:00
Morgan a926e0b9d6 fix: public booking page serialization error (#18070)
* fix: public booking page serialization error

* fixup! fix: public booking page serialization error
2024-12-09 14:48:06 +00:00
9334b67f2c feat: banner and avatars for Booker atom (#17821)
* init

* added bannerUrl to output

* cleanup

* fixup

* fix response DTOs

* remove logs

* send back avatar url for user

* enable passing avatar urls for managed user

* dont include urls for platform

* remove logs

* add team banner

* update docs

* nit

* PR feedback

* fix type error

* add prop to pass in bannerUrl

* update DTOs

* update tests

* fix tests

---------

Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-12-09 14:35:57 +00:00
Hariom Balhara ae1b844bea chore: Remove pages/settings (#18064)
* Remove unused org mkigration pages and delete pages/settings

* Remove more unused code causing ts errors

* Remove SettingsLayout
2024-12-09 15:19:12 +01:00
Morgan 15e93af2ac chore: enable markdown for platform atoms (#18069)
* chore: enable markdown for platform atoms

* fixup! chore: enable markdown for platform atoms
2024-12-09 16:11:02 +02:00
Rajiv Sahal 49c97c6380 rearrange order of platform tabs (#18068) 2024-12-09 18:50:59 +05:30
Keith Williams 8679d21d78 docs: Fix OAuth casing (#18066) 2024-12-09 13:18:44 +01:00
Vincent Lam 382f00e178 - Set stripe payment element to accordion layout (#18056)
- Removed hardcoded payment_method_types for checkouts and immediate payments
2024-12-09 11:37:46 +01:00
Anik Dhabal Babu e8eab10d17 fix: team event leading to a 404 page due slug conflict (#18055) 2024-12-09 11:36:15 +01:00
Eunjae Lee 7a7282a7ab fix: add members page back to settings (#18065) 2024-12-09 10:23:27 +00:00
Calcom BotandReplexica 19b3d6a633 feat: update translations via @replexica (#18063)
* chore: sync with main

* feat: update translations via @replexica

---------

Co-authored-by: Replexica <support@replexica.com>
2024-12-09 10:07:43 +00:00
Lauris Skraucis 046b7a0d08 feat: v2 teams memberships endpoints (#18041) 2024-12-09 10:52:01 +01:00
Benny Joo 30acf6b196 chore: use app router - /apps main page (#16980)
* chore: apps main page remove pages router

* revert unneeded changes
2024-12-09 09:48:22 +00:00
Eunjae Lee 5e95018870 feat: resizing in DataTable (#17973)
* feat: support resizing in data table

* wip

* clean up colors

* feat: resizable DataTable

* update implementation

* fix

* remove unused code

* improve dependency array

* enable resizing at DataTable level

* hide header label

* fix type error
2024-12-09 08:26:02 +00:00
Morgan e559e7a46f fix: add onTouchEnd for mobile apps (#18040) 2024-12-09 10:21:52 +02:00
Morgan 3b1123a29a chore: add user profile to org user endpoints apiv2 (#18039) 2024-12-09 10:21:28 +02:00
Udit TakkarandHariom be3e580107 feat: domain wide delegation UI (#18013)
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-12-09 08:27:16 +05:30
Ignite_99 b325cf8d00 Fixes #17863: When crating a new event from a team event list, the cancel button redirects to /event-types (#18058) 2024-12-08 17:19:04 +00:00
Hariom Balhara 6ce9defecf fix: Do not consider salesforce ownership when making rerouting decision (#18034) 2024-12-07 02:36:41 +00:00
Anik Dhabal Babu 8d05656b7b fix: Schedule a timezone change only allows you to select one day not a range (#18045) 2024-12-06 16:53:55 +00:00
Anik Dhabal Babu 091400b7d5 chore: Add host default language to workflow reminder weekdays (#18029)
* chore: Add host default language to workflow reminder weekdays

* update
2024-12-06 15:56:03 +00:00
Hariom BalharaandJoe Au-Yeung 6de26b3c43 feat: Enable SCIM logging that can be configured through constant (#18043)
* Enable SCIM logging that can be configured through constants

* Remove example

---------

Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
2024-12-06 15:48:16 +00:00
Somay ChauhanandMorgan 063400fcbc feat: conferencing atoms (#17988)
* added api/v2 for zoom connect

* Update conferencing.controller.ts

* fix: added api for ConferencingAppsViewPlatformWrapper

* type fixes

* added getBulkEventTypes api for bulk-update default location

* added bulk-update-to-default-location

* fix:bulk-update-to-default-location

* invalidated

* fixed returnTo and onErrorReturnTo

* make logos work for conferencing atoms

* smalll improvements

* fixup

* share common types

* type fix, and fixed a typo

* fix: type-error

* fix: json.parse(json.parse())

* fix: invalidate query

* fix: removing app throws error in the main app

* undo platform-libraries-1.2.3

* update platform-libraries version

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2024-12-06 19:03:04 +05:30
Syed Ali Shahbaz 44115ac7fa feat: v1 booking "created at" field in response (#18038)
* Update booking.ts

* Update booking.ts
2024-12-06 10:22:47 +00:00
Hariom Balhara 7e76770ba6 feat: Okta(SCIM2.0) -> Attributes Sync and Group Option support (#17805) 2024-12-06 15:17:50 +05:30
GitHub Actions d5ef3e1d72 chore: release v4.7.19
Post release / release (push) Canceled after 0s
2024-12-06 09:31:35 +00:00
Benny Joo 0c19606e19 chore: technical debt - remove instances of testing /future routes (#18032) 2024-12-06 02:53:00 +00:00
Benny Joo b8219c273d feat: Reduce bundle size via better imports + install gcal + gvideo by default for google signups (#17810)
* wip

* use light imports

* fix

* perf: Reduce bundle size via better imports with `googleapis` (#17811)

* fix test

* refactor

* update vite.config.js

* fix

* fix unit test failing
2024-12-06 02:51:48 +00:00
Calcom BotandReplexica f75415e414 feat: update translations via @replexica (#18033)
Co-authored-by: Replexica <support@replexica.com>
2024-12-06 01:14:40 +00:00
Syed Ali ShahbazandCarinaWolli 4bf5e3cdf5 feat: Webhook support for Managed Events (#17986)
* managed event webhooks

* more fixes

* fix types --WIP

* fix types

* fix test

* add test

* remove remnant comments

* address feedback

* fix test

* fix test

* fix tests

* change of plans \(00)/

* remove console log

* remove unnecessary comments

* revert tests

* list active webhooks in child event types

* add translations

* allow editing own event types

* add locked info for children

* secure create handler

* fix for isLocked check

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-12-05 23:20:34 +00:00
Benny Joo 4181b5988a chore: remove unused files (#18025) 2024-12-05 22:01:24 +00:00
Keith Williams c41ba9d984 revert: "feat: refactor filters on insights response table (#17796)" (#18024)
* revert: "feat: refactor filters on insights response table (#17796)"

This reverts commit 5d88858995.

* Put back the search columns and options english terms
2024-12-05 17:29:09 +00:00
Benny Joo 2a578ea9d5 chore: drop sourceLang, targetLang and id columns (#17792)
* chore: drop sourceLang, targetLang and id columns

* remove copying for sourceLocale and targetLocale
2024-12-05 17:23:07 +01:00
GitHub Actions c96827e1c0 chore: release v4.7.18
Post release / release (push) Canceled after 0s
2024-12-05 16:18:37 +00:00
Calcom BotandReplexica 3f5363038c feat: update translations via @replexica (#18022)
Co-authored-by: Replexica <support@replexica.com>
2024-12-05 16:12:27 +00:00
Kartik SainiandPrashant Varma e2de28b933 feat: prevent rescheduling past bookings (#18003)
* feat: prevent rescheduling past bookings

* hide reschedule option on frontend

* fix

* addd null check for booking.endTime

---------

Co-authored-by: Prashant Varma <prashantvarma5083@gmail.com>
2024-12-05 16:36:00 +01:00
Eunjae LeeandUdit Takkar 5d88858995 feat: refactor filters on insights response table (#17796)
* feat: improve text filters (WIP)

* move function to bottom

* apply some styles

* fix selection of TextFilterOptions

* rename value to operand

* remove unused file

* merge filters/filters into filters/utils

* fix regression of not putting url params correctly

* move makeWhereClause to filters/utils

* fix negative, empty, and not empty operators

* fix initial filtering from search state (url)

* fix type errors

* do not send an empty array to query

* update yarn.lock

* i18n for text filter operators

* extract logic as useColumnFilters()

* add missing import

* fix type error

* revert yarn.lock

* use i18n

* insensitive text match

* move data-table to @calcom/features

* fix type errors

* fix type errors

* fix type errors

* feat: support DataTable filters for Insights Routing WIP

* remove unused filters

* remove additionalFilters and fix types

* clean up filter components

* support icons for ActiveFilters

* support filters on json

* fix filter ui

* fix type error and clean up

* revert changes

* revert change

* clean up

* revert change

* fix compatibility with insights booking page

* remove unused params

* fix type errors

* update yarn.lock

* fix field filter and adjust ui

* chore: update yarn.lock

* fix text filter

* add Clear Filters button

* add more test data

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2024-12-05 16:32:52 +01:00
Anik Dhabal Babu 82a500aeaa fix: days of the week in different language in the workflow (#17850)
* fix: days of the week in different language in the workflow

* Update
2024-12-05 15:19:45 +00:00
Hariom Balhara 2408337c78 fix: Don't force reschedule with same RR host if reschedule is actually rerouting (#17511)
* Dont force reschedule with same RR host if routed there

* Add unit tests
2024-12-05 09:30:22 -05:00
Rajiv Sahal 119edb5389 fix: localStorage not found for Booker atom (#18014) 2024-12-05 19:50:09 +05:30
Anwar SadathandPraash ae4474cd42 feat: Add slotFormat query parameter to slots API V2 endpoint to return start and end time of a slot (#17873)
* feat: Add formatAsStartAndEndTime query parameter to slots API V2 endpoint to return start and end time of a slot

* Update apps/api/v2/src/modules/slots/controllers/slots.controller.ts

Co-authored-by: Praash <99237795+Praashh@users.noreply.github.com>

* Fix duration was allowed to be any number

* Refactor code

* Show slotFormat property as optional in swagger doc

---------

Co-authored-by: Praash <99237795+Praashh@users.noreply.github.com>
2024-12-05 19:12:17 +05:30
Morgan 3290ae526a fix: custom implem markdownToSafeHTML for platform (#18018) 2024-12-05 12:18:45 +00:00
Morgan 16a7cf6d04 fix: fetching event-types of org users apiv2 (#18004) 2024-12-05 06:02:19 +00:00
Benny Joo ab2ab31eef chore: named export / default export same time (#18012) 2024-12-04 23:21:29 +01:00
520 changed files with 16879 additions and 7051 deletions
+6 -8
View File
@@ -119,7 +119,7 @@ NEXT_PUBLIC_FRESHCHAT_HOST=
# Google OAuth credentials
# To enable Login with Google you need to:
# 1. Set `GOOGLE_API_CREDENTIALS` above
# 1. Set `GOOGLE_API_CREDENTIALS` below
# 2. Set `GOOGLE_LOGIN_ENABLED` to `true`
# When self-hosting please ensure you configure the Google integration as an Internal app so no one else can login to your instance
# @see https://support.google.com/cloud/answer/6158849#public-and-internal&zippy=%2Cpublic-and-internal-applications
@@ -239,7 +239,8 @@ CLOUDFLARE_TURNSTILE_SECRET=
NEXT_PUBLIC_TEAM_IMPERSONATION=false
# Close.com internal CRM
CLOSECOM_API_KEY=
CLOSECOM_CLIENT_ID=
CLOSECOM_CLIENT_SECRET=
# Sendgrid internal sync service
SENDGRID_SYNC_API_KEY=
@@ -356,10 +357,8 @@ APP_ROUTER_APPS_CATEGORIES_ENABLED=0
APP_ROUTER_APPS_CATEGORIES_CATEGORY_ENABLED=0
APP_ROUTER_BOOKING_ENABLED=0
APP_ROUTER_BOOKINGS_STATUS_ENABLED=0
APP_ROUTER_WORKFLOWS_ENABLED=0
APP_ROUTER_GETTING_STARTED_STEP_ENABLED=0
APP_ROUTER_APPS_ENABLED=0
APP_ROUTER_VIDEO_ENABLED=0
APP_ROUTER_TEAM_ENABLED=0
APP_ROUTER_TEAMS_ENABLED=0
APP_ROUTER_AVAILABILITY_ENABLED=0
@@ -371,10 +370,6 @@ APP_ROUTER_AUTH_SAML_ENABLED=0
APP_ROUTER_AUTH_ERROR_ENABLED=0
APP_ROUTER_AUTH_PLATFORM_ENABLED=0
APP_ROUTER_AUTH_OAUTH2_ENABLED=0
APP_ROUTER_MORE_ENABLED=0
APP_ROUTER_MAINTENANCE_ENABLED=0
APP_ROUTER_UPGRADE_ENABLED=0
APP_ROUTER_CONNECT_AND_JOIN_ENABLED=0
# disable setry server source maps
SENTRY_DISABLE_SERVER_WEBPACK_PLUGIN=1
@@ -414,3 +409,6 @@ NEXT_PUBLIC_LOGGER_LEVEL=
# Used to use Replexica SDK, a tool for real-time AI-powered localization
REPLEXICA_API_KEY=
# Comma-separated list of DSyncData.directoryId to log SCIM API requests for. It can be enabled temporarily for debugging the requests being sent to SCIM server.
DIRECTORY_IDS_TO_LOG=
+11
View File
@@ -0,0 +1,11 @@
/packages/app-store/applecalendar/**/* @calcom/Foundation
/packages/app-store/caldavcalendar/**/* @calcom/Foundation
/packages/app-store/exchange2013calendar/**/* @calcom/Foundation
/packages/app-store/exchange2016calendar/**/* @calcom/Foundation
/packages/app-store/exchangecalendar/**/* @calcom/Foundation
/packages/app-store/feishucalendar/**/* @calcom/Foundation
/packages/app-store/googlecalendar/**/* @calcom/Foundation
/packages/app-store/ics-feedcalendar/**/* @calcom/Foundation
/packages/app-store/larkcalendar/**/* @calcom/Foundation
/packages/app-store/office365calendar/**/* @calcom/Foundation
/packages/app-store/zohocalendar/**/* @calcom/Foundation
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
View File
+7
View File
@@ -0,0 +1,7 @@
undecided:
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
View File
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
+2
View File
@@ -23,6 +23,7 @@ const schemaBookingBaseBodyParams = Booking.pick({
status: true,
rescheduledBy: true,
cancelledBy: true,
createdAt: true,
}).partial();
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
@@ -119,4 +120,5 @@ export const schemaBookingReadPublic = Booking.extend({
fromReschedule: true,
cancelledBy: true,
rescheduledBy: true,
createdAt: true,
});
+1
View File
@@ -6,6 +6,7 @@ ARG DATABASE_URL
WORKDIR /calcom
RUN set -eux;
RUN ln -s /usr/lib/libssl.so.3 /lib/libssl.so.3
ENV NODE_ENV="production"
ENV NODE_OPTIONS="--max-old-space-size=8192"
+1 -1
View File
@@ -29,7 +29,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.69",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.71",
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
@@ -113,7 +113,7 @@ describe("Platform Calendars Endpoints", () => {
});
// TODO: Uncomment this once CI is ready to run proper Office365 tests
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar oauth with valid access token `, async () => {
// it(`/GET/v2/calendars/${OFFICE_365_CALENDAR}/connect: it should redirect to auth-url for office 365 calendar OAuth with valid access token `, async () => {
// const response = await request(app.getHttpServer())
// .get(`/v2/calendars/${OFFICE_365_CALENDAR}/connect`)
// .set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -123,7 +123,7 @@ describe("Platform Calendars Endpoints", () => {
// expect(data.authUrl).toBeDefined();
// });
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar oauth with valid access token `, async () => {
it(`/GET/v2/calendars/${GOOGLE_CALENDAR}/connect: it should redirect to auth-url for google calendar OAuth with valid access token `, async () => {
const response = await request(app.getHttpServer())
.get(`/v2/calendars/${GOOGLE_CALENDAR}/connect`)
.set("Authorization", `Bearer ${accessTokenSecret}`)
@@ -4,13 +4,14 @@ import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { calendar_v3 } from "@googleapis/calendar";
import { Logger, NotFoundException } from "@nestjs/common";
import { BadRequestException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import { z } from "zod";
import { SUCCESS_STATUS, GOOGLE_CALENDAR_TYPE } from "@calcom/platform-constants";
@@ -78,7 +79,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri);
return oAuth2Client;
}
@@ -90,7 +91,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
}
if (gcalCredentials.invalid) {
throw new BadRequestException("Invalid google oauth credentials.");
throw new BadRequestException("Invalid google OAuth credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
@@ -134,8 +135,7 @@ export class GoogleCalendarService implements OAuthCalendarApp {
oAuth2Client.setCredentials(key);
const calendar = google.calendar({
version: "v3",
const calendar = new calendar_v3.Calendar({
auth: oAuth2Client,
});
@@ -78,4 +78,11 @@ export class EventTypesRepository_2024_04_15 {
async deleteEventType(eventTypeId: number) {
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
}
async getEventTypeWithDuration(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
select: { length: true },
});
}
}
@@ -10,10 +10,12 @@ 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, Schedule } from "@prisma/client";
import { PlatformOAuthClient, Team, User, Schedule, EventType } 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 { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.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";
@@ -75,18 +77,22 @@ describe("Event types Endpoints", () => {
let teamRepositoryFixture: TeamRepositoryFixture;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
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 orgUser: User;
let falseTestUser: User;
let firstSchedule: Schedule;
let secondSchedule: Schedule;
let falseTestSchedule: Schedule;
let orgUserEventType1: EventType;
let orgUserEventType2: EventType;
let orgUserEventType3: EventType;
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
@@ -109,8 +115,12 @@ describe("Event types Endpoints", () => {
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
schedulesRepostoryFixture = new SchedulesRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({ name: "organization" });
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
organization = await teamRepositoryFixture.create({
name: "organization",
slug: "event-type-2024-06-14-org-slug",
});
oAuthClient = await createOAuthClient(organization.id);
user = await userRepositoryFixture.create({
email: userEmail,
@@ -118,6 +128,49 @@ describe("Event types Endpoints", () => {
username,
});
orgUser = await userRepositoryFixture.create({
email: "event-types-2024-06-14-org-user@example.com",
name: "event-types-2024-06-14-org-user",
username: "event-types-2024-06-14-org-user",
});
profileRepositoryFixture.create({
uid: `usr-${orgUser.id}`,
username: orgUser.username as string,
organization: {
connect: {
id: organization.id,
},
},
user: {
connect: {
id: orgUser.id,
},
},
});
orgUserEventType1 = await eventTypesRepositoryFixture.create(
{ title: "orgUserEventType1", slug: "org-event-type-1", length: 60, locations: [] },
orgUser.id
);
orgUserEventType2 = await eventTypesRepositoryFixture.create(
{ title: "orgUserEventType2", slug: "org-event-type-2", length: 60, locations: [] },
orgUser.id
);
orgUserEventType3 = await eventTypesRepositoryFixture.create(
{ title: "orgUserEventType3", slug: "org-event-type-3", length: 60, locations: [] },
orgUser.id
);
await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: orgUser.id } },
team: { connect: { id: organization.id } },
accepted: true,
});
falseTestUser = await userRepositoryFixture.create({
email: falseTestEmail,
name: "false-test",
@@ -342,7 +395,7 @@ describe("Event types Endpoints", () => {
});
});
it(`/GET/even-types by username`, async () => {
it(`/GET/event-types by username`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
@@ -386,6 +439,61 @@ describe("Event types Endpoints", () => {
expect(fetchedEventType.color).toEqual(eventType.color);
});
it(`/GET/event-types by username and orgSlug`, async () => {
console.log(organization);
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(3);
expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined();
expect(responseBody.data?.find((e) => e.id === orgUserEventType2.id)?.id).toBeDefined();
expect(responseBody.data?.find((e) => e.id === orgUserEventType3.id)?.id).toBeDefined();
});
it(`/GET/event-types by username and orgSlug and eventSlug`, async () => {
const response = await request(app.getHttpServer())
.get(
`/api/v2/event-types?username=${orgUser.username}&orgSlug=${organization.slug}&eventSlug=${orgUserEventType1.slug}`
)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(1);
expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined();
});
it(`/GET/event-types by username and orgId`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${orgUser.username}&orgId=${organization.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
// note: bearer token value mocked using "withAccessTokenAuth" for user which id is used when creating event type above
.set("Authorization", `Bearer whatever`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data?.length).toEqual(3);
expect(responseBody.data?.find((e) => e.id === orgUserEventType1.id)?.id).toBeDefined();
expect(responseBody.data?.find((e) => e.id === orgUserEventType2.id)?.id).toBeDefined();
expect(responseBody.data?.find((e) => e.id === orgUserEventType3.id)?.id).toBeDefined();
});
it("should return an error when creating an event type with seats enabled and multiple locations", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class 2",
@@ -919,6 +1027,12 @@ describe("Event types Endpoints", () => {
} catch (e) {
// User might have been deleted by the test
}
try {
await userRepositoryFixture.delete(orgUser.id);
} catch (e) {
// User might have been deleted by the test
}
await app.close();
});
});
@@ -1321,6 +1435,73 @@ describe("Event types Endpoints", () => {
});
});
it("should return event type with default bookingFields if they are not defined", async () => {
const eventTypeInput = {
title: "undefined booking fields",
description: "undefined booking fields",
length: 40,
hidden: false,
slug: "undefined-booking-fields",
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
};
const eventType = await eventTypesRepositoryFixture.create(eventTypeInput, user.id);
return request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([
{
isDefault: true,
type: "name",
slug: "name",
required: true,
},
{
isDefault: true,
type: "email",
slug: "email",
required: true,
},
{
isDefault: true,
type: "radioInput",
slug: "location",
required: false,
},
{
isDefault: true,
type: "text",
slug: "title",
required: true,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
},
{
isDefault: true,
type: "multiemail",
slug: "guests",
required: false,
},
{
isDefault: true,
type: "textarea",
slug: "rescheduleReason",
required: false,
},
]);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -17,7 +17,7 @@ export class EventTypeResponseTransformPipe implements PipeTransform {
private transformEventType(eventType: EventTypeResponse): EventTypeOutput_2024_06_14 {
return plainToClass(
EventTypeOutput_2024_06_14,
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType),
this.outputEventTypesService.getResponseEventType(eventType.ownerId, eventType, false),
{ strategy: "exposeAll" }
);
}
@@ -74,8 +74,13 @@ export class EventTypesService_2024_06_14 {
await this.checkUserOwnsSchedule(userId, body.scheduleId);
}
async getEventTypeByUsernameAndSlug(username: string, eventTypeSlug: string) {
const user = await this.usersRepository.findByUsername(username);
async getEventTypeByUsernameAndSlug(
username: string,
eventTypeSlug: string,
orgSlug?: string,
orgId?: number
) {
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
if (!user) {
return null;
}
@@ -92,8 +97,8 @@ export class EventTypesService_2024_06_14 {
};
}
async getEventTypesByUsername(username: string) {
const user = await this.usersRepository.findByUsername(username);
async getEventTypesByUsername(username: string, orgSlug?: string, orgId?: number) {
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
if (!user) {
return [];
}
@@ -152,27 +157,26 @@ export class EventTypesService_2024_06_14 {
}
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14) {
const { username, eventSlug, usernames } = queryParams;
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
if (username && eventSlug) {
const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug);
const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug, orgSlug, orgId);
return eventType ? [eventType] : [];
}
if (username) {
return await this.getEventTypesByUsername(username);
return await this.getEventTypesByUsername(username, orgSlug, orgId);
}
if (usernames) {
const dynamicEventType = await this.getDynamicEventType(usernames);
const dynamicEventType = await this.getDynamicEventType(usernames, orgSlug, orgId);
return [dynamicEventType];
}
return [];
}
async getDynamicEventType(usernames: string[]) {
const users = await this.usersService.getByUsernames(usernames);
async getDynamicEventType(usernames: string[], orgSlug?: string, orgId?: number) {
const users = await this.usersService.getByUsernames(usernames, orgSlug, orgId);
const usersFiltered: UserWithProfile[] = [];
for (const user of users) {
if (user) {
@@ -21,6 +21,7 @@ import {
transformSeatsInternalToApi,
InternalLocation,
BookingFieldSchema,
getBookingFieldsWithSystemFields,
} from "@calcom/platform-libraries";
import {
TransformFutureBookingsLimitSchema_2024_06_14,
@@ -88,7 +89,11 @@ type Input = Pick<
@Injectable()
export class OutputEventTypesService_2024_06_14 {
getResponseEventType(ownerId: number, databaseEventType: Input): EventTypeOutput_2024_06_14 {
getResponseEventType(
ownerId: number,
databaseEventType: Input,
isOrgTeamEvent: boolean
): EventTypeOutput_2024_06_14 {
const {
id,
length,
@@ -122,7 +127,7 @@ export class OutputEventTypesService_2024_06_14 {
const customName = databaseEventType?.eventName ?? undefined;
const bookingFields = databaseEventType.bookingFields
? this.transformBookingFields(databaseEventType.bookingFields)
: [];
: this.getDefaultBookingFields(isOrgTeamEvent);
const recurrence = this.transformRecurringEvent(databaseEventType.recurringEvent);
const metadata = this.transformMetadata(databaseEventType.metadata) || {};
const users = this.transformUsers(databaseEventType.users || []);
@@ -240,6 +245,18 @@ export class OutputEventTypesService_2024_06_14 {
return [...transformBookingFieldsInternalToApi(knownBookingFields), ...unknownBookingFields];
}
getDefaultBookingFields(isOrgTeamEvent: boolean) {
const defaultBookingFields = getBookingFieldsWithSystemFields({
disableGuests: false,
bookingFields: null,
customInputs: [],
metadata: null,
workflows: [],
isOrgTeamEvent,
});
return this.transformBookingFields(defaultBookingFields);
}
transformRecurringEvent(recurringEvent: any) {
if (!recurringEvent) return null;
const recurringEventParsed = parseRecurringEvent(recurringEvent);
@@ -95,7 +95,7 @@ describe("Platform Gcal Endpoints", () => {
.expect(401);
});
it(`/GET/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}`)
@@ -105,7 +105,7 @@ describe("Platform Gcal Endpoints", () => {
expect(data.authUrl).toBeDefined();
});
it(`/GET/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`
+1 -4
View File
@@ -27,10 +27,7 @@ import {
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import { google } from "googleapis";
import { z } from "zod";
import { APPS_READ, GOOGLE_CALENDAR_TYPE, SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -103,7 +100,7 @@ export class GcalController {
}
if (gcalCredentials.invalid) {
throw new BadRequestException("Invalid google oauth credentials.");
throw new BadRequestException("Invalid google OAuth credentials.");
}
const { connectedCalendars } = await this.calendarsService.getCalendars(userId);
@@ -9,6 +9,7 @@ import { ProviderModule } from "@/ee/provider/provider.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
import { SlotsModule } from "@/modules/slots/slots.module";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
import { Module } from "@nestjs/common";
@@ -24,6 +25,7 @@ import { Module } from "@nestjs/common";
CalendarsModule,
BookingsModule_2024_04_15,
BookingsModule_2024_08_13,
TeamsMembershipsModule,
SlotsModule,
],
})
@@ -22,6 +22,15 @@ export class AppsRepository {
});
}
async deleteAppCredentials(credentialIdsToDelete: number[], userId: number) {
return this.dbWrite.prisma.credential.deleteMany({
where: {
id: { in: credentialIdsToDelete },
userId,
},
});
}
async createTeamAppCredential(type: string, key: Prisma.InputJsonValue, teamId: number, appId: string) {
return this.dbWrite.prisma.credential.create({
data: {
@@ -32,4 +41,28 @@ export class AppsRepository {
},
});
}
async findAppCredential({
type,
appId,
userId,
teamId,
}: {
type: string;
appId: string;
userId?: number;
teamId?: number;
}) {
return this.dbWrite.prisma.credential.findMany({
select: {
id: true,
},
where: {
type,
userId,
teamId,
appId,
},
});
}
}
@@ -1,6 +1,6 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { google } from "googleapis";
import { OAuth2Client } from "googleapis-common";
import { z } from "zod";
@Injectable()
@@ -21,7 +21,7 @@ export class GCalService {
const { client_id, client_secret } = this.gcalResponseSchema.parse(app.keys);
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirectUri);
const oAuth2Client = new OAuth2Client(client_id, client_secret, redirectUri);
return oAuth2Client;
}
}
@@ -2,6 +2,7 @@ import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_0
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
@@ -15,6 +16,7 @@ import { Module } from "@nestjs/common";
imports: [PrismaModule, EventTypesModule_2024_06_14, OrganizationsModule],
providers: [
EventTypesAtomService,
ConferencingAtomsService,
MembershipsRepository,
CredentialsRepository,
UsersRepository,
@@ -1,5 +1,9 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { EventTypesAppInput } from "@/modules/atoms/inputs/event-types-app.input";
import {
BulkUpdateEventTypeToDefaultLocationDto,
EventTypesAppInput,
} from "@/modules/atoms/inputs/event-types-app.input";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
@@ -18,8 +22,9 @@ import {
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
import type { UpdateEventTypeReturn } from "@calcom/platform-libraries";
import { ConnectedApps } from "@calcom/platform-libraries";
import { ApiResponse } from "@calcom/platform-types";
/*
@@ -36,7 +41,10 @@ these endpoints should not be recommended for use by third party and are exclude
@DocsTags("Atoms - endpoints for atoms")
@DocsExcludeController(true)
export class AtomsController {
constructor(private readonly eventTypesService: EventTypesAtomService) {}
constructor(
private readonly eventTypesService: EventTypesAtomService,
private readonly conferencingService: ConferencingAtomsService
) {}
@Get("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@@ -52,6 +60,17 @@ export class AtomsController {
};
}
@Get("/event-types")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async getAtomEventTypes(@GetUser("id") userId: number): Promise<ApiResponse<unknown>> {
const eventType = await this.eventTypesService.getUserEventTypes(userId);
return {
status: SUCCESS_STATUS,
data: eventType,
};
}
@Get("event-types-app/:appSlug")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -84,6 +103,19 @@ export class AtomsController {
};
}
@Patch("/event-types/bulk-update-to-default-location")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async bulkUpdateAtomEventTypes(
@GetUser() user: UserWithProfile,
@Body() body: BulkUpdateEventTypeToDefaultLocationDto
): Promise<{ status: typeof SUCCESS_STATUS | typeof ERROR_STATUS }> {
await this.eventTypesService.bulkUpdateEventTypesDefaultLocation(user, body.eventTypeIds);
return {
status: SUCCESS_STATUS,
};
}
@Patch("event-types/:eventTypeId")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@@ -114,4 +146,13 @@ export class AtomsController {
data: eventType,
};
}
@Get("/conferencing")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
async listInstalledConferencingApps(@GetUser() user: UserWithProfile): Promise<ApiResponse<ConnectedApps>> {
const conferencingApps = await this.conferencingService.getConferencingApps(user);
return { status: SUCCESS_STATUS, data: conferencingApps };
}
}
@@ -1,5 +1,5 @@
import { Transform } from "class-transformer";
import { IsNumber, IsOptional } from "class-validator";
import { Transform, Type } from "class-transformer";
import { ArrayNotEmpty, IsArray, IsInt, IsNumber, IsOptional } from "class-validator";
export class EventTypesAppInput {
@Transform(({ value }: { value: string }) => value && parseInt(value))
@@ -7,3 +7,10 @@ export class EventTypesAppInput {
@IsOptional()
teamId?: number;
}
export class BulkUpdateEventTypeToDefaultLocationDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
eventTypeIds!: number[];
}
@@ -0,0 +1,25 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { getConnectedApps, ConnectedApps } from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class ConferencingAtomsService {
private logger = new Logger("ConferencingAtomService");
constructor(private readonly dbWrite: PrismaWriteService) {}
async getConferencingApps(user: UserWithProfile): Promise<ConnectedApps> {
return getConnectedApps({
user,
input: {
variant: "conferencing",
onlyInstalled: true,
},
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
}
@@ -19,6 +19,9 @@ import {
getAppFromSlug,
MembershipRole,
EventTypeMetaDataSchema,
getClientSecretFromPayment,
getBulkEventTypes,
bulkUpdateEventsToDefaultLocation,
} from "@calcom/platform-libraries";
import type {
App,
@@ -29,8 +32,7 @@ import type {
TDependencyData,
CredentialPayload,
} from "@calcom/platform-libraries";
import { getClientSecretFromPayment } from "@calcom/platform-libraries";
import { PrismaClient } from "@calcom/prisma/client";
import { PrismaClient } from "@calcom/prisma";
type EnabledAppType = App & {
credential: CredentialDataWithTeamName;
@@ -81,6 +83,10 @@ export class EventTypesAtomService {
return eventType;
}
async getUserEventTypes(userId: number) {
return getBulkEventTypes(userId);
}
async updateTeamEventType(
eventTypeId: number,
body: TUpdateEventTypeInputSchema,
@@ -317,4 +323,12 @@ export class EventTypesAtomService {
profile,
};
}
async bulkUpdateEventTypesDefaultLocation(user: UserWithProfile, eventTypeIds: number[]) {
return bulkUpdateEventsToDefaultLocation({
eventTypeIds,
user,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
}
}
@@ -102,7 +102,7 @@ describe("PermissionsGuard", () => {
});
});
describe("when oauth id is provided", () => {
describe("when OAuth id is provided", () => {
it("should return true for valid permissions", async () => {
const mockContext = createMockExecutionContext({ "x-cal-client-id": "100" });
jest.spyOn(reflector, "get").mockReturnValue([SCHEDULE_WRITE]);
@@ -1,20 +1,27 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { ConferencingController } from "@/modules/conferencing/controllers/conferencing.controller";
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
@Module({
imports: [PrismaModule],
imports: [PrismaModule, ConfigModule],
providers: [
ConferencingService,
ConferencingRepository,
GoogleMeetService,
CredentialsRepository,
UsersRepository,
TokensRepository,
ZoomVideoService,
AppsRepository,
],
exports: [],
controllers: [ConferencingController],
@@ -1,17 +1,27 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import {
ConferencingAppsOauthUrlOutputDto,
GetConferencingAppsOauthUrlResponseDto,
} from "@/modules/conferencing/outputs/get-conferencing-apps-oauth-url";
import {
ConferencingAppsOutputResponseDto,
ConferencingAppOutputResponseDto,
ConferencingAppsOutputDto,
DisconnectConferencingAppOutputResponseDto,
} from "@/modules/conferencing/outputs/get-conferencing-apps.output";
import { GetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/get-default-conferencing-app.output";
import { SetDefaultConferencingAppOutputResponseDto } from "@/modules/conferencing/outputs/set-default-conferencing-app.output";
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
import { GoogleMeetService } from "@/modules/conferencing/services/google-meet.service";
import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.service";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Get,
Query,
HttpCode,
HttpStatus,
Logger,
@@ -20,11 +30,24 @@ import {
Param,
BadRequestException,
Delete,
Headers,
Redirect,
UnauthorizedException,
Req,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToInstance } from "class-transformer";
import { Request } from "express";
import { CONFERENCING_APPS, GOOGLE_MEET, SUCCESS_STATUS } from "@calcom/platform-constants";
import { GOOGLE_MEET, ZOOM, SUCCESS_STATUS } from "@calcom/platform-constants";
export type OAuthCallbackState = {
accessToken: string;
teamId?: number;
fromApp?: boolean;
returnTo?: string;
onErrorReturnTo?: string;
};
@Controller({
path: "/v2/conferencing",
@@ -35,8 +58,10 @@ export class ConferencingController {
private readonly logger = new Logger("Platform Gcal Provider");
constructor(
private readonly tokensRepository: TokensRepository,
private readonly conferencingService: ConferencingService,
private readonly googleMeetService: GoogleMeetService
private readonly googleMeetService: GoogleMeetService,
private readonly zoomVideoService: ZoomVideoService
) {}
@Post("/:app/connect")
@@ -56,16 +81,89 @@ export class ConferencingController {
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
CONFERENCING_APPS.join(", ")
[GOOGLE_MEET].join(", ")
);
}
}
@Get("/:app/oauth/auth-url")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Get OAuth conferencing app auth url" })
async redirect(
@Req() req: Request,
@Headers("Authorization") authorization: string,
@Param("app") app: string,
@Query("returnTo") returnTo?: string,
@Query("onErrorReturnTo") onErrorReturnTo?: string
): Promise<GetConferencingAppsOauthUrlResponseDto> {
let credential;
const origin = req.headers.origin;
const accessToken = authorization.replace("Bearer ", "");
const state: OAuthCallbackState = {
returnTo: returnTo ?? origin,
onErrorReturnTo: onErrorReturnTo ?? origin,
fromApp: false,
accessToken,
};
switch (app) {
case ZOOM:
credential = await this.zoomVideoService.generateZoomAuthUrl(JSON.stringify(state));
return {
status: SUCCESS_STATUS,
data: plainToInstance(ConferencingAppsOauthUrlOutputDto, credential),
};
default:
throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", "));
}
}
@Get("/:app/oauth/callback")
@UseGuards()
@Redirect(undefined, 301)
@ApiOperation({ summary: "conferencing apps oauths callback" })
async save(
@Query("state") state: string,
@Param("app") app: string,
@Query("code") code: string,
@Query("error") error: string | undefined,
@Query("error_description") error_description: string | undefined
): Promise<{ url: string }> {
const decodedCallbackState: OAuthCallbackState = JSON.parse(state);
try {
const userId = await this.tokensRepository.getAccessTokenOwnerId(decodedCallbackState.accessToken);
if (error) {
throw new BadRequestException(error_description);
}
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
switch (app) {
case ZOOM:
return await this.zoomVideoService.connectZoomApp(decodedCallbackState, code, userId);
default:
throw new BadRequestException("Invalid conferencing app, available apps are: ", [ZOOM].join(", "));
}
} catch (error) {
return {
url: decodedCallbackState.onErrorReturnTo ?? "",
};
}
}
@Get("/")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "List your conferencing applications" })
async listConferencingApps(@GetUser("id") userId: number): Promise<ConferencingAppsOutputResponseDto> {
async listInstalledConferencingApps(
@GetUser("id") userId: number
): Promise<ConferencingAppsOutputResponseDto> {
const conferencingApps = await this.conferencingService.getConferencingApps(userId);
const data = conferencingApps.map((conferencingApps) =>
@@ -83,18 +181,17 @@ export class ConferencingController {
@GetUser("id") userId: number,
@Param("app") app: string
): Promise<SetDefaultConferencingAppOutputResponseDto> {
switch (app) {
case GOOGLE_MEET:
await this.googleMeetService.setDefault(userId);
await this.conferencingService.setDefaultConferencingApp(userId, app);
return { status: SUCCESS_STATUS };
}
return { status: SUCCESS_STATUS };
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
CONFERENCING_APPS.join(", ")
);
}
@Get("/default")
@HttpCode(HttpStatus.OK)
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Get your default conferencing application" })
async getDefault(@GetUser("id") userId: number): Promise<GetDefaultConferencingAppOutputResponseDto> {
const defaultconferencingApp = await this.conferencingService.getUserDefaultConferencingApp(userId);
return { status: SUCCESS_STATUS, data: defaultconferencingApp };
}
@Delete("/:app/disconnect")
@@ -102,20 +199,10 @@ export class ConferencingController {
@UseGuards(ApiAuthGuard)
@ApiOperation({ summary: "Disconnect your conferencing application" })
async disconnect(
@GetUser("id") userId: number,
@GetUser() user: UserWithProfile,
@Param("app") app: string
): Promise<ConferencingAppOutputResponseDto> {
switch (app) {
case GOOGLE_MEET:
const credential = await this.googleMeetService.disconnectGoogleMeetApp(userId);
return { status: SUCCESS_STATUS, data: plainToInstance(ConferencingAppsOutputDto, credential) };
default:
throw new BadRequestException(
"Invalid conferencing app, available apps are: ",
CONFERENCING_APPS.join(", ")
);
}
): Promise<DisconnectConferencingAppOutputResponseDto> {
await this.conferencingService.disconnectConferencingApp(user, app);
return { status: SUCCESS_STATUS };
}
}
@@ -0,0 +1,22 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsEnum } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class ConferencingAppsOauthUrlOutputDto {
@IsString()
@Expose()
readonly url!: string;
}
export class GetConferencingAppsOauthUrlResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@Type(() => ConferencingAppsOauthUrlOutputDto)
data!: ConferencingAppsOauthUrlOutputDto;
}
@@ -48,3 +48,9 @@ export class ConferencingAppOutputResponseDto {
@Type(() => ConferencingAppsOutputDto)
data!: ConferencingAppsOutputDto;
}
export class DisconnectConferencingAppOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
}
@@ -0,0 +1,29 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsOptional, IsString, ValidateNested } from "class-validator";
import { ERROR_STATUS, SUCCESS_STATUS } from "@calcom/platform-constants";
export class DefaultConferencingAppsOutputDto {
@IsString()
@IsOptional()
@Expose()
readonly appSlug?: string;
@IsString()
@IsOptional()
@Expose()
readonly appLink?: string;
}
export class GetDefaultConferencingAppOutputResponseDto {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@Expose()
@ValidateNested()
@IsOptional()
@Type(() => DefaultConferencingAppsOutputDto)
data?: DefaultConferencingAppsOutputDto;
}
@@ -22,4 +22,10 @@ export class ConferencingRepository {
where: { userId, type: GOOGLE_MEET_TYPE },
});
}
async findConferencingApp(userId: number, app: string) {
return this.dbRead.prisma.credential.findFirst({
where: { userId, appId: app },
});
}
}
@@ -1,14 +1,61 @@
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.respository";
import { Logger } from "@nestjs/common";
import { UserWithProfile } from "@/modules/users/users.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { BadRequestException, InternalServerErrorException, Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { CONFERENCING_APPS, CAL_VIDEO } from "@calcom/platform-constants";
import { userMetadata, handleDeleteCredential } from "@calcom/platform-libraries";
@Injectable()
export class ConferencingService {
private logger = new Logger("ConferencingService");
constructor(private readonly conferencingRepository: ConferencingRepository) {}
constructor(
private readonly conferencingRepository: ConferencingRepository,
private readonly usersRepository: UsersRepository
) {}
async getConferencingApps(userId: number) {
return this.conferencingRepository.findConferencingApps(userId);
}
async getUserDefaultConferencingApp(userId: number) {
const user = await this.usersRepository.findById(userId);
return userMetadata.parse(user?.metadata)?.defaultConferencingApp;
}
async checkAppIsValidAndConnected(userId: number, app: string) {
if (!CONFERENCING_APPS.includes(app)) {
throw new BadRequestException("Invalid app, available apps are: ", CONFERENCING_APPS.join(", "));
}
const credential = await this.conferencingRepository.findConferencingApp(userId, app);
if (!credential) {
throw new BadRequestException(`${app} not connected.`);
}
return credential;
}
async disconnectConferencingApp(user: UserWithProfile, app: string) {
const credential = await this.checkAppIsValidAndConnected(user.id, app);
return handleDeleteCredential({
userId: user.id,
userMetadata: user?.metadata,
credentialId: credential.id,
});
}
async setDefaultConferencingApp(userId: number, app: string) {
// cal-video is global, so we can skip this check
if (app !== CAL_VIDEO) {
await this.checkAppIsValidAndConnected(userId, app);
}
const user = await this.usersRepository.setDefaultConferencingApp(userId, app);
const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } };
if (metadata?.defaultConferencingApp?.appSlug !== app) {
throw new InternalServerErrorException(`Could not set ${app} as default conferencing app`);
}
return true;
}
}
@@ -43,29 +43,4 @@ export class GoogleMeetService {
return googleMeetCredential;
}
async disconnectGoogleMeetApp(userId: number) {
const googleMeet = await this.conferencingRepository.findGoogleMeet(userId);
if (!googleMeet) {
throw new BadRequestException("Google Meet is not connected.");
}
const googleMeetCredential = await this.credentialsRepository.deleteUserCredentialById(
userId,
googleMeet.id
);
return googleMeetCredential;
}
async setDefault(userId: number) {
const user = await this.usersRepository.setDefaultConferencingApp(userId, GOOGLE_MEET);
const metadata = user.metadata as { defaultConferencingApp?: { appSlug?: string } };
if (metadata?.defaultConferencingApp?.appSlug !== GOOGLE_MEET) {
throw new InternalServerErrorException("Could not set Google Meet as default conferencing app");
}
return true;
}
}
@@ -0,0 +1,113 @@
import { AppsRepository } from "@/modules/apps/apps.repository";
import { OAuthCallbackState } from "@/modules/conferencing/controllers/conferencing.controller";
import { BadRequestException, Logger, NotFoundException, UnauthorizedException } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { Prisma } from "@prisma/client";
import { z } from "zod";
import { ZOOM, ZOOM_TYPE } from "@calcom/platform-constants";
import stringify = require("qs-stringify");
const zoomAppKeysSchema = z.object({
client_id: z.string(),
client_secret: z.string(),
});
@Injectable()
export class ZoomVideoService {
private logger = new Logger("ZoomVideoService");
private redirectUri = `${this.config.get("api.url")}/conferencing/zoom/oauth/callback`;
constructor(private readonly config: ConfigService, private readonly appsRepository: AppsRepository) {}
async getZoomAppKeys() {
const app = await this.appsRepository.getAppBySlug(ZOOM);
const { client_id, client_secret } = zoomAppKeysSchema.parse(app?.keys);
if (!client_id) {
throw new NotFoundException("Zoom app not found");
}
if (!client_secret) {
throw new NotFoundException("Zoom app not found");
}
return { client_id, client_secret };
}
async generateZoomAuthUrl(state: string) {
const { client_id } = await this.getZoomAppKeys();
const params = {
response_type: "code",
client_id,
redirect_uri: this.redirectUri,
state: state,
};
const query = stringify(params);
const url = `https://zoom.us/oauth/authorize?${query}`;
return { url };
}
async connectZoomApp(state: OAuthCallbackState, code: string, userId: number) {
const { client_id, client_secret } = await this.getZoomAppKeys();
const redirectUri = encodeURI(this.redirectUri);
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
const result = await fetch(
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
{
method: "POST",
headers: {
Authorization: authHeader,
},
}
);
if (result.status !== 200) {
let errorMessage = "Something is wrong with Zoom API";
try {
const responseBody = await result.json();
errorMessage = responseBody.error;
} catch (e) {
errorMessage = await result.clone().text();
}
throw new BadRequestException(errorMessage);
}
const responseBody = await result.json();
if (responseBody.error) {
throw new BadRequestException(responseBody.error);
}
responseBody.expiry_date = Math.round(Date.now() + responseBody.expires_in * 1000);
delete responseBody.expires_in;
if (!userId) {
throw new UnauthorizedException("Invalid Access token.");
}
const existingCredentialZoomVideo = await this.appsRepository.findAppCredential({
type: ZOOM_TYPE,
userId,
appId: ZOOM,
});
const credentialIdsToDelete = existingCredentialZoomVideo.map((item) => item.id);
if (credentialIdsToDelete.length > 0) {
await this.appsRepository.deleteAppCredentials(credentialIdsToDelete, userId);
}
await this.appsRepository.createAppCredential(
ZOOM_TYPE,
responseBody as unknown as Prisma.InputJsonObject,
userId,
ZOOM
);
return { url: state.returnTo ?? "" };
}
}
@@ -159,6 +159,7 @@ describe("OAuth Client Users Endpoints", () => {
email: userEmail,
timeZone: "incorrect-time-zone",
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
await request(app.getHttpServer())
@@ -191,6 +192,7 @@ describe("OAuth Client Users Endpoints", () => {
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
@@ -211,6 +213,7 @@ describe("OAuth Client Users Endpoints", () => {
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.user.avatarUrl).toEqual(requestBody.avatarUrl);
expect(responseBody.data.accessToken).toBeDefined();
expect(responseBody.data.refreshToken).toBeDefined();
@@ -265,6 +268,7 @@ describe("OAuth Client Users Endpoints", () => {
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
@@ -535,6 +539,7 @@ describe("OAuth Client Users Endpoints", () => {
timeFormat: 24,
locale: Locales.FR,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
};
const response = await request(app.getHttpServer())
@@ -213,6 +213,7 @@ export class OAuthClientUsersController {
timeFormat: user.timeFormat,
defaultScheduleId: user.defaultScheduleId,
locale: user.locale as Locales,
avatarUrl: user.avatarUrl,
};
}
}
@@ -1,7 +1,7 @@
import { Locales } from "@/lib/enums/locales";
import { ApiProperty } from "@nestjs/swagger";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import { IsEnum, IsOptional } from "class-validator";
import { IsEnum, IsOptional, IsUrl } from "class-validator";
export class ManagedUserOutput {
@ApiProperty({ example: 1 })
@@ -36,4 +36,14 @@ export class ManagedUserOutput {
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
locale?: Locales;
@IsUrl()
@IsOptional()
@ApiPropertyOptional({
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
})
avatarUrl?: string | null;
}
@@ -244,7 +244,7 @@ describe("OAuth client WebhooksController (e2e)", () => {
});
});
});
it("/webhooks (GET) should fail to get webhooks of oauth client that doesn't belong to you", () => {
it("/webhooks (GET) should fail to get webhooks of OAuth client that doesn't belong to you", () => {
return request(app.getHttpServer()).get(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks`).expect(403);
});
@@ -267,7 +267,7 @@ describe("OAuth client WebhooksController (e2e)", () => {
});
});
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) should fail to delete webhooks of an oauth client that doesn't belong to you", () => {
it("/oauth-clients/:oAuthClientId/webhooks/:webhookId (DELETE) should fail to delete webhooks of an OAuth client that doesn't belong to you", () => {
return request(app.getHttpServer())
.delete(`/v2/oauth-clients/${otherOAuthClient.id}/webhooks/${otherOAuthClientWebhook.id}`)
.expect(403);
@@ -66,9 +66,11 @@ export class OAuthClientUsersService {
const updatedUser = await this.userRepository.update(user.id, {
name: body.name,
locale: body.locale,
avatarUrl: body.avatarUrl,
});
user.locale = updatedUser.locale;
user.name = updatedUser.name;
user.avatarUrl = updatedUser.avatarUrl;
}
const { accessToken, refreshToken, accessTokenExpiresAt } = await this.tokensRepository.createOAuthTokens(
@@ -6,7 +6,7 @@ import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { User } from "@prisma/client";
import { SchedulingType, 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";
@@ -235,6 +235,7 @@ describe("Organizations Event Types Endpoints", () => {
it("should create a collective team event-type", async () => {
const body: CreateTeamEventTypeInput_2024_06_14 = {
successRedirectUrl: "https://masterchief.com/argentina/flan/video/1234",
title: "Coding consultation collective",
slug: "coding-consultation collective",
description: "Our team will review your codebase.",
@@ -334,7 +335,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(data.hideCalendarEventDetails).toEqual(body.hideCalendarEventDetails);
expect(data.lockTimeZoneToggleOnBookingPage).toEqual(body.lockTimeZoneToggleOnBookingPage);
expect(data.color).toEqual(body.color);
expect(data.successRedirectUrl).toEqual("https://masterchief.com/argentina/flan/video/1234");
collectiveEventType = responseBody.data;
});
});
@@ -496,6 +497,7 @@ describe("Organizations Event Types Endpoints", () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
hosts: newHosts,
successRedirectUrl: "https://new-url-success.com",
};
return request(app.getHttpServer())
@@ -507,6 +509,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
const eventType = responseBody.data;
expect(eventType.successRedirectUrl).toEqual("https://new-url-success.com");
expect(eventType.title).toEqual(collectiveEventType.title);
expect(eventType.hosts.length).toEqual(1);
evaluateHost(eventType.hosts[0], newHosts[0]);
@@ -526,6 +529,7 @@ describe("Organizations Event Types Endpoints", () => {
const body: UpdateTeamEventTypeInput_2024_06_14 = {
title: newTitle,
hosts: newHosts,
successRedirectUrl: "https://new-url-success-managed.com",
};
return request(app.getHttpServer())
@@ -569,6 +573,7 @@ describe("Organizations Event Types Endpoints", () => {
expect(responseTeammate1Event?.title).toEqual(newTitle);
managedEventType = responseBody.data[0];
expect(managedEventType.successRedirectUrl).toEqual("https://new-url-success-managed.com");
});
});
@@ -649,6 +654,81 @@ describe("Organizations Event Types Endpoints", () => {
.expect(200);
});
it("should return event type with default bookingFields if they are not defined", async () => {
const eventTypeInput = {
title: "unknown field event type two",
description: "unknown field event type description two",
length: 40,
hidden: false,
slug: "unknown-field-type-two",
locations: [],
schedulingType: SchedulingType.ROUND_ROBIN,
};
const eventType = await eventTypesRepositoryFixture.createTeamEventType({
...eventTypeInput,
team: { connect: { id: team.id } },
});
return request(app.getHttpServer())
.get(`/v2/organizations/${org.id}/teams/${team.id}/event-types/${eventType.id}`)
.expect(200)
.then(async (response) => {
const responseBody: ApiSuccessResponse<TeamEventTypeOutput_2024_06_14> = response.body;
const fetchedEventType = responseBody.data;
expect(fetchedEventType.bookingFields).toEqual([
{
isDefault: true,
type: "name",
slug: "name",
required: true,
},
{
isDefault: true,
type: "email",
slug: "email",
required: true,
},
{
isDefault: true,
required: false,
slug: "attendeePhoneNumber",
type: "phone",
},
{
isDefault: true,
type: "radioInput",
slug: "location",
required: false,
},
{
isDefault: true,
type: "text",
slug: "title",
required: true,
},
{
isDefault: true,
type: "textarea",
slug: "notes",
required: false,
},
{
isDefault: true,
type: "multiemail",
slug: "guests",
required: false,
},
{
isDefault: true,
type: "textarea",
slug: "rescheduleReason",
required: false,
},
]);
});
});
function evaluateHost(expected: Host, received: Host | undefined) {
expect(expected.userId).toEqual(received?.userId);
expect(expected.mandatory).toEqual(received?.mandatory);
@@ -12,7 +12,7 @@ export class OutputTeamEventTypesResponsePipe implements PipeTransform {
private async transformEventType(item: DatabaseTeamEventType): Promise<TeamEventTypeOutput_2024_06_14> {
return plainToClass(
TeamEventTypeOutput_2024_06_14,
await this.outputOrganizationsEventTypesService.getResponseTeamEventType(item),
await this.outputOrganizationsEventTypesService.getResponseTeamEventType(item, true),
{ strategy: "exposeAll" }
);
}
@@ -137,6 +137,8 @@ describe("Organizations Team Endpoints", () => {
.post(`/v2/organizations/${org.id}/teams`)
.send({
name: "Team created via API",
slug: "team-created-via-api",
bio: "This is our test team created via API",
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
@@ -144,6 +146,8 @@ describe("Organizations Team Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API");
expect(teamCreatedViaApi.slug).toEqual("team-created-via-api");
expect(teamCreatedViaApi.bio).toEqual("This is our test team created via API");
expect(teamCreatedViaApi.parentId).toEqual(org.id);
const membership = await membershipsRepositoryFixture.getUserMembershipByTeamId(
user.id,
@@ -171,6 +175,9 @@ describe("Organizations Team Endpoints", () => {
.patch(`/v2/organizations/${org.id}/teams/${teamCreatedViaApi.id}`)
.send({
name: "Team created via API Updated",
weekStart: "Monday",
logoUrl: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
bannerUrl: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
} satisfies CreateOrgTeamDto)
.expect(200)
.then((response) => {
@@ -178,6 +185,13 @@ describe("Organizations Team Endpoints", () => {
expect(responseBody.status).toEqual(SUCCESS_STATUS);
teamCreatedViaApi = responseBody.data;
expect(teamCreatedViaApi.name).toEqual("Team created via API Updated");
expect(teamCreatedViaApi.weekStart).toEqual("Monday");
expect(teamCreatedViaApi.logoUrl).toEqual(
"https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png"
);
expect(teamCreatedViaApi.bannerUrl).toEqual(
"https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png"
);
expect(teamCreatedViaApi.parentId).toEqual(org.id);
});
});
@@ -12,10 +12,12 @@ 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 {
GetOrganizationUsersResponseDTO,
GetOrgUsersWithProfileOutput,
} 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,
@@ -55,7 +57,7 @@ export class OrganizationsUsersController {
async getOrganizationsUsers(
@Param("orgId", ParseIntPipe) orgId: number,
@Query() query: GetOrganizationsUsersInput
): Promise<GetOrganizationUsersOutput> {
): Promise<GetOrganizationUsersResponseDTO> {
const users = await this.organizationsUsersService.getUsers(
orgId,
query.emails,
@@ -65,7 +67,13 @@ export class OrganizationsUsersController {
return {
status: SUCCESS_STATUS,
data: users.map((user) => plainToInstance(GetUserOutput, user, { strategy: "excludeAll" })),
data: users.map((user) =>
plainToInstance(
GetOrgUsersWithProfileOutput,
{ ...user, profile: user?.profiles?.[0] ?? {} },
{ strategy: "excludeAll" }
)
),
};
}
@@ -86,7 +94,11 @@ export class OrganizationsUsersController {
);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
data: plainToInstance(
GetOrgUsersWithProfileOutput,
{ ...user, profile: user?.profiles?.[0] ?? {} },
{ strategy: "excludeAll" }
),
};
}
@@ -104,7 +116,11 @@ export class OrganizationsUsersController {
const user = await this.organizationsUsersService.updateUser(orgId, userId, input);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
data: plainToInstance(
GetOrgUsersWithProfileOutput,
{ ...user, profile: user?.profiles?.[0] ?? {} },
{ strategy: "excludeAll" }
),
};
}
@@ -120,7 +136,11 @@ export class OrganizationsUsersController {
const user = await this.organizationsUsersService.deleteUser(orgId, userId);
return {
status: SUCCESS_STATUS,
data: plainToInstance(GetUserOutput, user, { strategy: "excludeAll" }),
data: plainToInstance(
GetOrgUsersWithProfileOutput,
{ ...user, profile: user?.profiles?.[0] ?? {} },
{ strategy: "excludeAll" }
),
};
}
}
@@ -1,6 +1,7 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { EmailService } from "@/modules/email/email.service";
import { GetOrgUsersWithProfileOutput } from "@/modules/organizations/outputs/get-organization-users.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -233,10 +234,18 @@ describe("Organizations Users Endpoints", () => {
it("should get all org users", async () => {
const { body } = await request(app.getHttpServer()).get(`/v2/organizations/${org.id}/users`);
const userData = body.data;
const userData = body.data as GetOrgUsersWithProfileOutput[];
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(4);
console.log(
"profiles",
{ userData },
userData.map((u) => u.profile)
);
expect(userData.find((u) => u.profile.username === "member1@org.com")).toBeDefined();
expect(userData.find((u) => u.profile.username === "member2@org.com")).toBeDefined();
expect(userData.find((u) => u.profile.username === "member3@org.com")).toBeDefined();
expect(userData.filter((user: { email: string }) => user.email === nonMemberEmail).length).toBe(0);
});
@@ -250,12 +259,13 @@ describe("Organizations Users Endpoints", () => {
.set("Content-Type", "application/json")
.set("Accept", "application/json");
const userData = body.data;
const userData = body.data as GetOrgUsersWithProfileOutput[];
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.length).toBe(1);
expect(userData.filter((user: { email: string }) => user.email === userEmail).length).toBe(1);
expect(userData.filter((user) => user.email === userEmail).length).toBe(1);
expect(userData.find((u) => u.profile.username === user.username)).toBeDefined();
});
it("should get users within the specified emails array", async () => {
@@ -1,16 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class CreateOrgTeamDto {
@IsString()
@Length(1)
@ApiProperty({ description: "Name of the team", example: "CalTeam", required: true })
readonly name!: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
required: false,
})
readonly logoUrl?: string;
@IsOptional()
@@ -59,6 +68,12 @@ export class CreateOrgTeamDto {
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
required: false,
})
readonly bannerUrl?: string;
@IsOptional()
@@ -67,10 +82,23 @@ export class CreateOrgTeamDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
required: false,
default: "Europe/London",
})
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
required: false,
default: "Sunday",
})
readonly weekStart?: string = "Sunday";
@IsOptional()
@@ -1,16 +1,25 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsBoolean, IsOptional, IsString, IsUrl, Length } from "class-validator";
export class UpdateOrgTeamDto {
@IsString()
@Length(1)
@ApiPropertyOptional({ description: "Name of the team", example: "CalTeam", required: false })
readonly name?: string;
@IsOptional()
@IsString()
@ApiPropertyOptional({ type: String, description: "Team slug", example: "caltel", required: false })
readonly slug?: string;
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png",
description: `URL of the teams logo image`,
required: false,
})
readonly logoUrl?: string;
@IsOptional()
@@ -59,6 +68,12 @@ export class UpdateOrgTeamDto {
@IsOptional()
@IsUrl()
@ApiPropertyOptional({
type: String,
example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png",
description: `URL of the teams banner image which is shown on booker`,
required: false,
})
readonly bannerUrl?: string;
@IsOptional()
@@ -67,10 +82,23 @@ export class UpdateOrgTeamDto {
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "America/New_York",
description: `Timezone is used to create teams's default schedule from Monday to Friday from 9AM to 5PM. It will default to Europe/London if not passed.`,
required: false,
default: "Europe/London",
})
readonly timeZone?: string = "Europe/London";
@IsOptional()
@IsString()
@ApiPropertyOptional({
type: String,
example: "Monday",
required: false,
default: "Sunday",
})
readonly weekStart?: string = "Sunday";
@IsOptional()
@@ -1,20 +1,64 @@
import { GetUserOutput } from "@/modules/users/outputs/get-users.output";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum } from "class-validator";
import { Expose, Type } from "class-transformer";
import { IsEnum, IsInt, IsString, ValidateNested, IsArray } from "class-validator";
import { ERROR_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
export class GetOrganizationUsersOutput {
export class ProfileOutput {
@IsInt()
@Expose()
@ApiProperty({ type: Number, required: true, description: "The ID of the profile of user", example: 1 })
id!: number;
@IsInt()
@Expose()
@ApiProperty({
type: Number,
required: true,
description: "The ID of the organization of user",
example: 1,
})
organizationId!: number;
@IsInt()
@Expose()
@ApiProperty({ type: Number, required: true, description: "The IDof the user", example: 1 })
userId!: number;
@IsString()
@Expose()
@ApiProperty({
type: String,
nullable: true,
required: false,
description: "The username of the user within the organization context",
example: "john_doe",
})
username!: string;
}
export class GetOrgUsersWithProfileOutput extends GetUserOutput {
@ApiProperty({
description: "organization user profile, contains user data within the organizaton context",
})
@Expose()
@ValidateNested()
@IsArray()
@Type(() => ProfileOutput)
profile!: ProfileOutput;
}
export class GetOrganizationUsersResponseDTO {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data!: GetUserOutput[];
data!: GetOrgUsersWithProfileOutput[];
}
export class GetOrganizationUserOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
data!: GetUserOutput;
data!: GetOrgUsersWithProfileOutput;
}
@@ -24,7 +24,13 @@ export class OrganizationsEventTypesRepository {
slug: eventTypeSlug,
},
},
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
include: {
users: true,
schedule: true,
hosts: true,
destinationCalendar: true,
team: { select: { bannerUrl: true } },
},
});
}
@@ -33,7 +39,13 @@ export class OrganizationsEventTypesRepository {
where: {
teamId,
},
include: { users: true, schedule: true, hosts: true, destinationCalendar: true },
include: {
users: true,
schedule: true,
hosts: true,
destinationCalendar: true,
team: { select: { bannerUrl: true } },
},
});
}
@@ -24,6 +24,13 @@ export class OrganizationsUsersRepository {
...this.filterOnOrgMembership(orgId),
...(emailArray && emailArray.length ? { email: { in: emailArray } } : {}),
},
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
skip,
take,
});
@@ -35,6 +42,13 @@ export class OrganizationsUsersRepository {
username,
...this.filterOnOrgMembership(orgId),
},
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
});
}
@@ -44,6 +58,13 @@ export class OrganizationsUsersRepository {
email,
...this.filterOnOrgMembership(orgId),
},
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
});
}
@@ -62,6 +83,13 @@ export class OrganizationsUsersRepository {
organizationId: orgId,
},
data: updateUserBody,
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
});
}
@@ -71,6 +99,13 @@ export class OrganizationsUsersRepository {
id: userId,
organizationId: orgId,
},
include: {
profiles: {
where: {
organizationId: orgId,
},
},
},
});
}
}
@@ -3,7 +3,7 @@ import { OrganizationsEventTypesRepository } from "@/modules/organizations/repos
import { UsersRepository } from "@/modules/users/users.repository";
import { Injectable } from "@nestjs/common";
import type { EventType, User, Schedule, Host, DestinationCalendar } from "@prisma/client";
import { SchedulingType } from "@prisma/client";
import { SchedulingType, Team } from "@prisma/client";
import { HostPriority, TeamEventTypeResponseHost } from "@calcom/platform-types";
@@ -12,6 +12,7 @@ type EventTypeRelations = {
schedule: Schedule | null;
hosts: Host[];
destinationCalendar?: DestinationCalendar | null;
team?: Pick<Team, "bannerUrl"> | null;
};
export type DatabaseTeamEventType = EventType & EventTypeRelations;
@@ -66,6 +67,7 @@ type Input = Pick<
| "eventName"
| "useEventTypeDestinationCalendarEmail"
| "hideCalendarEventDetails"
| "team"
>;
@Injectable()
@@ -76,12 +78,13 @@ export class OutputOrganizationsEventTypesService {
private readonly usersRepository: UsersRepository
) {}
async getResponseTeamEventType(databaseEventType: Input) {
async getResponseTeamEventType(databaseEventType: Input, isOrgTeamEvent: boolean) {
const { teamId, userId, parentId, assignAllTeamMembers } = databaseEventType;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { ownerId, users, ...rest } = this.outputEventTypesService.getResponseEventType(
0,
databaseEventType
databaseEventType,
isOrgTeamEvent
);
const hosts =
databaseEventType.schedulingType === "MANAGED"
@@ -95,6 +98,7 @@ export class OutputOrganizationsEventTypesService {
ownerId: userId,
parentEventTypeId: parentId,
schedulingType: databaseEventType.schedulingType,
bannerUrl: databaseEventType?.team?.bannerUrl,
assignAllTeamMembers: teamId ? assignAllTeamMembers : undefined,
};
}
@@ -129,9 +133,14 @@ export class OutputOrganizationsEventTypesService {
name: databaseUser?.name || "",
mandatory: databaseHost.isFixed,
priority: getPriorityLabel(databaseHost.priority || 2),
avatarUrl: databaseUser?.avatarUrl,
});
} else {
transformedHosts.push({ userId: databaseHost.userId, name: databaseUser?.name || "" });
transformedHosts.push({
userId: databaseHost.userId,
name: databaseUser?.name || "",
avatarUrl: databaseUser?.avatarUrl,
});
}
}
@@ -5,6 +5,7 @@ import { ApiTags as DocsTags, ApiCreatedResponse, ApiOkResponse, ApiOperation }
import { Response as ExpressResponse, Request as ExpressRequest } from "express";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SlotFormat } from "@calcom/platform-enums";
import { getAvailableSlots } from "@calcom/platform-libraries";
import type { AvailableSlotsType } from "@calcom/platform-libraries";
import { RemoveSelectedSlotInput, ReserveSlotInput } from "@calcom/platform-types";
@@ -89,9 +90,31 @@ export class SlotsController {
type: "array",
items: {
type: "object",
properties: {
time: { type: "string", format: "date-time", example: "2024-09-25T08:00:00.000Z" },
},
oneOf: [
{
properties: {
time: {
type: "string",
format: "date-time",
example: "2024-09-25T08:00:00.000Z",
},
},
},
{
properties: {
startTime: {
type: "string",
format: "date-time",
example: "2024-09-25T08:00:00.000Z",
},
endTime: {
type: "string",
format: "date-time",
example: "2024-09-25T08:30:00.000Z",
},
},
},
],
},
},
},
@@ -102,11 +125,18 @@ export class SlotsController {
status: "success",
data: {
slots: {
// Default format (when slotFormat is 'time' or not provided)
"2024-09-25": [{ time: "2024-09-25T08:00:00.000Z" }, { time: "2024-09-25T08:15:00.000Z" }],
// Alternative format (when slotFormat is 'range')
"2024-09-26": [
{ time: "2024-09-26T08:00:00.000Z" },
{ time: "2024-09-26T08:15:00.000Z" },
{ time: "2024-09-26T08:30:00.000Z" },
{
startTime: "2024-09-26T08:00:00.000Z",
endTime: "2024-09-26T08:30:00.000Z",
},
{
startTime: "2024-09-26T08:15:00.000Z",
endTime: "2024-09-26T08:45:00.000Z",
},
],
},
},
@@ -129,8 +159,20 @@ export class SlotsController {
},
});
const transformedSlots =
query.slotFormat === SlotFormat.Range
? await this.slotsService.formatSlots(
availableSlots,
query.duration,
query.eventTypeId,
query.slotFormat
)
: availableSlots.slots;
return {
data: availableSlots,
data: {
slots: transformedSlots,
},
status: SUCCESS_STATUS,
};
}
@@ -1,8 +1,9 @@
import { EventTypesRepository_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.repository";
import { SlotsRepository } from "@/modules/slots/slots.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
import { v4 as uuid } from "uuid";
import { SlotFormat } from "@calcom/platform-enums";
import { ReserveSlotInput } from "@calcom/platform-types";
@Injectable()
@@ -54,4 +55,51 @@ export class SlotsService {
const event = await this.eventTypeRepo.getEventTypeById(eventTypeId);
return !!event?.teamId;
}
async getEventTypeWithDuration(eventTypeId: number) {
return await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
}
async getDuration(duration?: number, eventTypeId?: number): Promise<number> {
if (duration) {
return duration;
}
if (eventTypeId) {
const eventType = await this.eventTypeRepo.getEventTypeWithDuration(eventTypeId);
if (!eventType) {
throw new Error("Event type not found");
}
return eventType.length;
}
throw new Error("duration or eventTypeId is required");
}
async formatSlots(
availableSlots: { slots: Record<string, { time: string }[]> },
duration?: number,
eventTypeId?: number,
slotFormat?: SlotFormat
): Promise<Record<string, { startTime: string; endTime: string }[]>> {
if (slotFormat && !Object.values(SlotFormat).includes(slotFormat)) {
throw new BadRequestException("Invalid slot format. Must be either 'range' or 'time'");
}
const slotDuration = await this.getDuration(duration, eventTypeId);
return Object.entries(availableSlots.slots).reduce<
Record<string, { startTime: string; endTime: string }[]>
>((acc, [date, slots]) => {
acc[date] = (slots as { time: string }[]).map((slot) => {
const startTime = new Date(slot.time);
const endTime = new Date(startTime.getTime() + slotDuration * 60000);
return {
startTime: startTime.toISOString(),
endTime: endTime.toISOString(),
};
});
return acc;
}, {});
}
}
@@ -0,0 +1,301 @@
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 { OrgTeamMembershipOutputDto } from "@/modules/organizations/outputs/organization-teams-memberships.output";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
import { GetTeamMembershipOutput } from "@/modules/teams/memberships/outputs/get-team-membership.output";
import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/get-team-memberships.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output";
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 { EventType, User } from "@prisma/client";
import * as request from "supertest";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomNumber } from "test/utils/randomNumber";
import { withApiAuth } from "test/utils/withApiAuth";
import { api } from "@calcom/app-store/alby";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Membership, Team } from "@calcom/prisma/client";
describe("Teams Memberships Endpoints", () => {
describe("User Authentication - User is Team Admin", () => {
let app: INestApplication;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let team: Team;
let teamEventType: EventType;
let managedEventType: EventType;
let teamAdminMembership: Membership;
let teamMemberMembership: Membership;
let membershipCreatedViaApi: TeamMembershipOutput;
const teamAdminEmail = `alice-admin-${randomNumber()}@api.com`;
const teamMemberEmail = `bob-member-${randomNumber()}@api.com`;
const nonTeamUserEmail = `charlie-outsider-${randomNumber()}@api`;
const invitedUserEmail = `david-invited-${randomNumber()}@api.com`;
let teamAdmin: User;
let teamMember: User;
let nonTeamUser: User;
let teammateInvitedViaApi: User;
beforeAll(async () => {
const moduleRef = await withApiAuth(
teamAdminEmail,
Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
})
).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
profileRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
teamAdmin = await userRepositoryFixture.create({
email: teamAdminEmail,
username: teamAdminEmail,
});
teamMember = await userRepositoryFixture.create({
email: teamMemberEmail,
username: teamMemberEmail,
});
nonTeamUser = await userRepositoryFixture.create({
email: nonTeamUserEmail,
username: nonTeamUserEmail,
});
teammateInvitedViaApi = await userRepositoryFixture.create({
email: invitedUserEmail,
username: invitedUserEmail,
});
team = await teamsRepositoryFixture.create({
name: `Team-${randomNumber()}`,
isOrganization: false,
});
teamEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "COLLECTIVE",
team: {
connect: { id: team.id },
},
title: "Collective Event Type",
slug: "collective-event-type",
length: 30,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
managedEventType = await eventTypesRepositoryFixture.createTeamEventType({
schedulingType: "MANAGED",
team: {
connect: { id: team.id },
},
title: "Managed Event Type",
slug: "managed-event-type",
length: 60,
assignAllTeamMembers: true,
bookingFields: [],
locations: [],
});
teamAdminMembership = await membershipsRepositoryFixture.create({
role: "ADMIN",
user: { connect: { id: teamAdmin.id } },
team: { connect: { id: team.id } },
});
teamMemberMembership = await membershipsRepositoryFixture.create({
role: "MEMBER",
user: { connect: { id: teamMember.id } },
team: { connect: { id: team.id } },
});
await profileRepositoryFixture.create({
uid: `usr-${teamAdmin.id}`,
username: teamAdminEmail,
organization: {
connect: {
id: team.id,
},
},
user: {
connect: {
id: teamAdmin.id,
},
},
});
await profileRepositoryFixture.create({
uid: `usr-${teamMember.id}`,
username: teamMemberEmail,
organization: {
connect: {
id: team.id,
},
},
user: {
connect: {
id: teamMember.id,
},
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
it("should get all the memberships of the team", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.length).toEqual(2);
expect(responseBody.data[0].id).toEqual(teamAdminMembership.id);
expect(responseBody.data[1].id).toEqual(teamMemberMembership.id);
});
});
it("should not be able to access memberships if not part of the team", async () => {
return request(app.getHttpServer()).get(`/v2/teams/9999/memberships`).expect(403);
});
it("should get all the memberships of the org's team paginated", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships?skip=1&take=1`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipsOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data[0].id).toEqual(teamMemberMembership.id);
expect(responseBody.data[0].userId).toEqual(teamMember.id);
expect(responseBody.data.length).toEqual(1);
});
});
it("should get membership of the team", async () => {
return request(app.getHttpServer())
.get(`/v2/teams/${team.id}/memberships/${teamAdminMembership.id}`)
.expect(200)
.then((response) => {
const responseBody: GetTeamMembershipOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data.id).toEqual(teamAdminMembership.id);
expect(responseBody.data.userId).toEqual(teamAdmin.id);
});
});
it("should have created the membership of the org's team and assigned team wide events", async () => {
const createTeamMembershipBody: CreateTeamMembershipInput = {
userId: teammateInvitedViaApi.id,
accepted: true,
role: "MEMBER",
};
return request(app.getHttpServer())
.post(`/v2/teams/${team.id}/memberships`)
.send(createTeamMembershipBody)
.expect(201)
.then((response) => {
const responseBody: CreateTeamMembershipOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
membershipCreatedViaApi = responseBody.data;
expect(membershipCreatedViaApi.teamId).toEqual(team.id);
expect(membershipCreatedViaApi.role).toEqual("MEMBER");
expect(membershipCreatedViaApi.userId).toEqual(teammateInvitedViaApi.id);
userHasCorrectEventTypes(membershipCreatedViaApi.userId);
});
});
async function userHasCorrectEventTypes(userId: number) {
const managedEventTypes = await eventTypesRepositoryFixture.getAllUserEventTypes(userId);
const teamEventTypes = await eventTypesRepositoryFixture.getAllTeamEventTypes(team.id);
expect(managedEventTypes?.length).toEqual(1);
expect(teamEventTypes?.length).toEqual(2);
const collectiveEvenType = teamEventTypes?.find((eventType) => eventType.slug === teamEventType.slug);
expect(collectiveEvenType).toBeTruthy();
const userHost = collectiveEvenType?.hosts.find((host) => host.userId === userId);
expect(userHost).toBeTruthy();
expect(managedEventTypes?.find((eventType) => eventType.slug === managedEventType.slug)).toBeTruthy();
}
it("should update the membership of the org's team", async () => {
const updateTeamMembershipBody: UpdateTeamMembershipInput = {
role: "OWNER",
};
return request(app.getHttpServer())
.patch(`/v2/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`)
.send(updateTeamMembershipBody)
.expect(200)
.then((response) => {
const responseBody: UpdateTeamMembershipOutput = 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/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(200)
.then((response) => {
const responseBody: ApiSuccessResponse<OrgTeamMembershipOutputDto> = 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/teams/${team.id}/memberships/${membershipCreatedViaApi.id}`)
.expect(404);
});
it("should fail if the membership does not exist", async () => {
return request(app.getHttpServer()).get(`/v2/teams/${team.id}/memberships/123132145`).expect(404);
});
afterAll(async () => {
await userRepositoryFixture.deleteByEmail(teamAdmin.email);
await userRepositoryFixture.deleteByEmail(teammateInvitedViaApi.email);
await userRepositoryFixture.deleteByEmail(nonTeamUser.email);
await userRepositoryFixture.deleteByEmail(teamMember.email);
await teamsRepositoryFixture.delete(team.id);
await app.close();
});
});
});
@@ -0,0 +1,159 @@
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 { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
import { OrganizationsEventTypesService } from "@/modules/organizations/services/event-types/organizations-event-types.service";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { CreateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/create-team-membership.output";
import { DeleteTeamMembershipOutput } from "@/modules/teams/memberships/outputs/delete-team-membership.output";
import { GetTeamMembershipOutput } from "@/modules/teams/memberships/outputs/get-team-membership.output";
import { GetTeamMembershipsOutput } from "@/modules/teams/memberships/outputs/get-team-memberships.output";
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { UpdateTeamMembershipOutput } from "@/modules/teams/memberships/outputs/update-team-membership.output";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import {
Controller,
UseGuards,
Get,
Param,
ParseIntPipe,
Query,
Delete,
Patch,
Post,
Body,
HttpCode,
HttpStatus,
Logger,
} from "@nestjs/common";
import { ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { updateNewTeamMemberEventTypes } from "@calcom/platform-libraries";
import { SkipTakePagination } from "@calcom/platform-types";
@Controller({
path: "/v2/teams/:teamId/memberships",
version: API_VERSIONS_VALUES,
})
@UseGuards(ApiAuthGuard, RolesGuard)
@DocsTags("Teams / Memberships")
export class TeamsMembershipsController {
private logger = new Logger("TeamsMembershipsController");
constructor(
private teamsMembershipsService: TeamsMembershipsService,
private oganizationsEventTypesService: OrganizationsEventTypesService
) {}
@Roles("TEAM_ADMIN")
@Post("/")
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: "Create a membership" })
async createTeamMembership(
@Param("teamId", ParseIntPipe) teamId: number,
@Body() body: CreateTeamMembershipInput
): Promise<CreateTeamMembershipOutput> {
const membership = await this.teamsMembershipsService.createTeamMembership(teamId, body);
if (membership.accepted) {
try {
await updateNewTeamMemberEventTypes(body.userId, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@Get("/:membershipId")
@ApiOperation({ summary: "Get a membership" })
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getTeamMembership(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number
): Promise<GetTeamMembershipOutput> {
const orgTeamMembership = await this.teamsMembershipsService.getTeamMembership(teamId, membershipId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, orgTeamMembership, { strategy: "excludeAll" }),
};
}
@Get("/")
@ApiOperation({ summary: "Get all memberships" })
@Roles("TEAM_ADMIN")
@HttpCode(HttpStatus.OK)
async getTeamMemberships(
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetTeamMembershipsOutput> {
const { skip, take } = queryParams;
const orgTeamMemberships = await this.teamsMembershipsService.getPaginatedTeamMemberships(
teamId,
skip ?? 0,
take ?? 250
);
return {
status: SUCCESS_STATUS,
data: orgTeamMemberships.map((membership) =>
plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" })
),
};
}
@Roles("TEAM_ADMIN")
@Patch("/:membershipId")
@ApiOperation({ summary: "Create a membership" })
async updateTeamMembership(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number,
@Body() body: UpdateTeamMembershipInput
): Promise<UpdateTeamMembershipOutput> {
const membership = await this.teamsMembershipsService.updateTeamMembership(teamId, membershipId, body);
const currentMembership = await this.teamsMembershipsService.getTeamMembership(teamId, membershipId);
const updatedMembership = await this.teamsMembershipsService.updateTeamMembership(
teamId,
membershipId,
body
);
if (!currentMembership.accepted && updatedMembership.accepted) {
try {
await updateNewTeamMemberEventTypes(updatedMembership.userId, teamId);
} catch (err) {
this.logger.error("Could not update new team member eventTypes", err);
}
}
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
@Roles("TEAM_ADMIN")
@Delete("/:membershipId")
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: "Delete a membership" })
async deleteTeamMembership(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number
): Promise<DeleteTeamMembershipOutput> {
const membership = await this.teamsMembershipsService.deleteTeamMembership(teamId, membershipId);
await this.oganizationsEventTypesService.deleteUserTeamEventTypesAndHosts(membership.userId, teamId);
return {
status: SUCCESS_STATUS,
data: plainToClass(TeamMembershipOutput, membership, { strategy: "excludeAll" }),
};
}
}
@@ -0,0 +1,20 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum, IsInt } from "class-validator";
export class CreateTeamMembershipInput {
@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,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { IsBoolean, IsOptional, IsEnum } from "class-validator";
export class UpdateTeamMembershipInput {
@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 { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class CreateTeamMembershipOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -0,0 +1,20 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class DeleteTeamMembershipOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -0,0 +1,20 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetTeamMembershipOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -0,0 +1,20 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetTeamMembershipsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput[];
}
@@ -0,0 +1,32 @@
import { ApiProperty } from "@nestjs/swagger";
import { MembershipRole } from "@prisma/client";
import { Expose } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, IsString } from "class-validator";
export class TeamMembershipOutput {
@IsInt()
@Expose()
readonly id!: number;
@IsInt()
@Expose()
readonly userId!: number;
@IsInt()
@Expose()
readonly teamId!: number;
@IsBoolean()
@Expose()
readonly accepted!: boolean;
@IsString()
@ApiProperty({ enum: ["MEMBER", "OWNER", "ADMIN"] })
@Expose()
readonly role!: MembershipRole;
@IsOptional()
@IsBoolean()
@Expose()
readonly disableImpersonation?: boolean;
}
@@ -0,0 +1,20 @@
import { TeamMembershipOutput } from "@/modules/teams/memberships/outputs/team-membership.output";
import { ApiProperty } from "@nestjs/swagger";
import { Type } from "class-transformer";
import { IsEnum, IsNotEmptyObject, ValidateNested } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class UpdateTeamMembershipOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
type: TeamMembershipOutput,
})
@IsNotEmptyObject()
@ValidateNested()
@Type(() => TeamMembershipOutput)
data!: TeamMembershipOutput;
}
@@ -0,0 +1,50 @@
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
@Injectable()
export class TeamsMembershipsService {
constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {}
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
const teamMembership = await this.teamsMembershipsRepository.createTeamMembership(teamId, data);
return teamMembership;
}
async getPaginatedTeamMemberships(teamId: number, skip = 0, take = 250) {
const teamMemberships = await this.teamsMembershipsRepository.findTeamMembershipsPaginated(
teamId,
skip,
take
);
return teamMemberships;
}
async getTeamMembership(teamId: number, membershipId: number) {
const teamMemberships = await this.teamsMembershipsRepository.findTeamMembership(teamId, membershipId);
if (!teamMemberships) {
throw new NotFoundException("Organization's Team membership not found");
}
return teamMemberships;
}
async updateTeamMembership(teamId: number, membershipId: number, data: UpdateTeamMembershipInput) {
const teamMembership = await this.teamsMembershipsRepository.updateTeamMembershipById(
teamId,
membershipId,
data
);
return teamMembership;
}
async deleteTeamMembership(teamId: number, membershipId: number) {
const teamMembership = await this.teamsMembershipsRepository.deleteTeamMembershipById(
teamId,
membershipId
);
return teamMembership;
}
}
@@ -0,0 +1,15 @@
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TeamsMembershipsController } from "@/modules/teams/memberships/controllers/teams-memberships.controller";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule],
providers: [TeamsMembershipsRepository, TeamsMembershipsService],
controllers: [TeamsMembershipsController],
})
export class TeamsMembershipsModule {}
@@ -0,0 +1,57 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { CreateTeamMembershipInput } from "@/modules/teams/memberships/inputs/create-team-membership.input";
import { UpdateTeamMembershipInput } from "@/modules/teams/memberships/inputs/update-team-membership.input";
import { Injectable } from "@nestjs/common";
@Injectable()
export class TeamsMembershipsRepository {
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
return this.dbWrite.prisma.membership.create({
data: { ...data, teamId: teamId },
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
});
}
async findTeamMembershipsPaginated(teamId: number, skip: number, take: number) {
return await this.dbRead.prisma.membership.findMany({
where: {
teamId: teamId,
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
skip,
take,
});
}
async findTeamMembership(teamId: number, membershipId: number) {
return this.dbRead.prisma.membership.findUnique({
where: {
id: membershipId,
teamId: teamId,
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
});
}
async deleteTeamMembershipById(teamId: number, membershipId: number) {
return this.dbWrite.prisma.membership.delete({
where: {
id: membershipId,
teamId: teamId,
},
});
}
async updateTeamMembershipById(teamId: number, membershipId: number, data: UpdateTeamMembershipInput) {
return this.dbWrite.prisma.membership.update({
data: { ...data },
where: {
id: membershipId,
teamId: teamId,
},
include: { user: { select: { username: true, email: true, avatarUrl: true, name: true } } },
});
}
}
@@ -1,7 +1,7 @@
import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { ApiProperty } from "@nestjs/swagger";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsOptional, IsTimeZone, IsString, IsEnum, IsIn, IsUrl } from "class-validator";
export type WeekDay = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
export type TimeFormat = 12 | 24;
@@ -42,4 +42,14 @@ export class CreateManagedUserInput {
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
locale?: Locales;
@IsUrl()
@IsOptional()
@ApiPropertyOptional({
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
})
avatarUrl?: string;
}
@@ -1,8 +1,8 @@
import { Locales } from "@/lib/enums/locales";
import { CapitalizeTimeZone } from "@/lib/inputs/capitalize-timezone";
import { TimeFormat, WeekDay } from "@/modules/users/inputs/create-managed-user.input";
import { ApiProperty } from "@nestjs/swagger";
import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone } from "class-validator";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsIn, IsNumber, IsOptional, IsString, IsTimeZone, IsUrl } from "class-validator";
export class UpdateManagedUserInput {
@IsString()
@@ -40,4 +40,14 @@ export class UpdateManagedUserInput {
@IsOptional()
@ApiProperty({ example: Locales.EN, enum: Locales })
locale?: Locales;
@IsUrl()
@IsOptional()
@ApiPropertyOptional({
type: String,
example: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
description: `URL of the user's avatar image`,
required: false,
})
avatarUrl?: string;
}
@@ -7,9 +7,9 @@ import { User } from "@calcom/prisma/client";
export class UsersService {
constructor(private readonly usersRepository: UsersRepository) {}
async getByUsernames(usernames: string[]) {
async getByUsernames(usernames: string[], orgSlug?: string, orgId?: number) {
const users = await Promise.all(
usernames.map((username) => this.usersRepository.findByUsername(username))
usernames.map((username) => this.usersRepository.findByUsername(username, orgSlug, orgId))
);
const usersFiltered: User[] = [];
@@ -142,11 +142,21 @@ export class UsersRepository {
});
}
async findByUsername(username: string) {
async findByUsername(username: string, orgSlug?: string, orgId?: number) {
return this.dbRead.prisma.user.findFirst({
where: {
username,
},
where:
orgId || orgSlug
? {
profiles: {
some: {
organization: orgSlug ? { slug: orgSlug } : { id: orgId },
username: username,
},
},
}
: {
username,
},
});
}
+693 -26
View File
@@ -2548,7 +2548,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetOrganizationUsersOutput"
"$ref": "#/components/schemas/GetOrganizationUsersResponseDTO"
}
}
}
@@ -3862,9 +3862,104 @@
]
}
},
"/v2/conferencing/{app}/oauth/auth-url": {
"get": {
"operationId": "ConferencingController_redirect",
"summary": "Get OAuth conferencing app auth url",
"parameters": [
{
"name": "Authorization",
"required": true,
"in": "header",
"schema": {
"type": "string"
}
},
{
"name": "app",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "returnTo",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "onErrorReturnTo",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetConferencingAppsOauthUrlResponseDto"
}
}
}
}
},
"tags": [
"Conferencing"
]
}
},
"/v2/conferencing/{app}/oauth/callback": {
"get": {
"operationId": "ConferencingController_save",
"summary": "conferencing apps oauths callback",
"parameters": [
{
"name": "state",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "app",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "code",
"required": true,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": ""
}
},
"tags": [
"Conferencing"
]
}
},
"/v2/conferencing": {
"get": {
"operationId": "ConferencingController_listConferencingApps",
"operationId": "ConferencingController_listInstalledConferencingApps",
"summary": "List your conferencing applications",
"parameters": [],
"responses": {
@@ -3915,6 +4010,28 @@
]
}
},
"/v2/conferencing/default": {
"get": {
"operationId": "ConferencingController_getDefault",
"summary": "Get your default conferencing application",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetDefaultConferencingAppOutputResponseDto"
}
}
}
}
},
"tags": [
"Conferencing"
]
}
},
"/v2/conferencing/{app}/disconnect": {
"delete": {
"operationId": "ConferencingController_disconnect",
@@ -3935,7 +4052,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ConferencingAppOutputResponseDto"
"$ref": "#/components/schemas/DisconnectConferencingAppOutputResponseDto"
}
}
}
@@ -4067,6 +4184,24 @@
"schema": {
"type": "string"
}
},
{
"name": "orgSlug",
"required": false,
"in": "query",
"description": "slug of the user's organization if he is in one, orgId is not required if using this parameter",
"schema": {
"type": "string"
}
},
{
"name": "orgId",
"required": false,
"in": "query",
"description": "ID of the organization of the user you want the get the event-types of, orgSlug is not needed when using this parameter",
"schema": {
"type": "number"
}
}
],
"responses": {
@@ -5005,6 +5140,20 @@
"schema": {
"type": "number"
}
},
{
"name": "slotFormat",
"required": false,
"in": "query",
"description": "Format of slot times in response. Use 'range' to get start and end times.",
"example": "range",
"schema": {
"enum": [
"range",
"time"
],
"type": "string"
}
}
],
"responses": {
@@ -5028,13 +5177,31 @@
"type": "array",
"items": {
"type": "object",
"properties": {
"time": {
"type": "string",
"format": "date-time",
"example": "2024-09-25T08:00:00.000Z"
"oneOf": [
{
"properties": {
"time": {
"type": "string",
"format": "date-time",
"example": "2024-09-25T08:00:00.000Z"
}
}
},
{
"properties": {
"startTime": {
"type": "string",
"format": "date-time",
"example": "2024-09-25T08:00:00.000Z"
},
"endTime": {
"type": "string",
"format": "date-time",
"example": "2024-09-25T08:30:00.000Z"
}
}
}
}
]
}
}
}
@@ -5055,13 +5222,12 @@
],
"2024-09-26": [
{
"time": "2024-09-26T08:00:00.000Z"
"startTime": "2024-09-26T08:00:00.000Z",
"endTime": "2024-09-26T08:30:00.000Z"
},
{
"time": "2024-09-26T08:15:00.000Z"
},
{
"time": "2024-09-26T08:30:00.000Z"
"startTime": "2024-09-26T08:15:00.000Z",
"endTime": "2024-09-26T08:45:00.000Z"
}
]
}
@@ -5200,6 +5366,219 @@
]
}
},
"/v2/teams/{teamId}/memberships": {
"post": {
"operationId": "TeamsMembershipsController_createTeamMembership",
"summary": "Create a membership",
"parameters": [
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateTeamMembershipInput"
}
}
}
},
"responses": {
"201": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/CreateTeamMembershipOutput"
}
}
}
}
},
"tags": [
"Teams / Memberships"
]
},
"get": {
"operationId": "TeamsMembershipsController_getTeamMemberships",
"summary": "Get all memberships",
"parameters": [
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "The number of items to return",
"example": 10,
"schema": {
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "The number of items to skip",
"example": 0,
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetTeamMembershipsOutput"
}
}
}
}
},
"tags": [
"Teams / Memberships"
]
}
},
"/v2/teams/{teamId}/memberships/{membershipId}": {
"get": {
"operationId": "TeamsMembershipsController_getTeamMembership",
"summary": "Get a membership",
"parameters": [
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "membershipId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetTeamMembershipOutput"
}
}
}
}
},
"tags": [
"Teams / Memberships"
]
},
"patch": {
"operationId": "TeamsMembershipsController_updateTeamMembership",
"summary": "Create a membership",
"parameters": [
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "membershipId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateTeamMembershipInput"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateTeamMembershipOutput"
}
}
}
}
},
"tags": [
"Teams / Memberships"
]
},
"delete": {
"operationId": "TeamsMembershipsController_deleteTeamMembership",
"summary": "Delete a membership",
"parameters": [
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "membershipId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DeleteTeamMembershipOutput"
}
}
}
}
},
"tags": [
"Teams / Memberships"
]
}
},
"/v2/timezones": {
"get": {
"operationId": "TimezonesController_getTimeZones",
@@ -6534,7 +6913,7 @@
"rolling": {
"type": "boolean",
"example": true,
"description": "\n Determines the behavior of the booking window:\n - If **true**, the window is rolling. This means the number of available days will always equal the specified 'value' \n and adjust dynamically as bookings are made. For example, if 'value' is 3 and availability is only on Mondays, \n a booker attempting to schedule on November 10 will see slots on November 11, 18, and 25. As one of these days \n becomes fully booked, a new day (e.g., December 2) will open up to ensure 3 available days are always visible.\n - If **false**, the window is fixed. This means the booking window only considers the next 'value' days from the\n moment someone is trying to book. For example, if 'value' is 3, availability is only on Mondays, and the current \n date is November 10, the booker will only see slots on November 11 because the window is restricted to the next \n 3 calendar days (November 10–12).\n "
"description": "\n Determines the behavior of the booking window:\n - If **true**, the window is rolling. This means the number of available days will always be equal the specified 'value' \n and adjust dynamically as bookings are made. For example, if 'value' is 3 and availability is only on Mondays, \n a booker attempting to schedule on November 10 will see slots on November 11, 18, and 25. As one of these days \n becomes fully booked, a new day (e.g., December 2) will open up to ensure 3 available days are always visible.\n - If **false**, the window is fixed. This means the booking window only considers the next 'value' days from the\n moment someone is trying to book. For example, if 'value' is 3, availability is only on Mondays, and the current \n date is November 10, the booker will only see slots on November 11 because the window is restricted to the next \n 3 calendar days (November 10–12).\n "
}
},
"required": [
@@ -6562,7 +6941,7 @@
"rolling": {
"type": "boolean",
"example": true,
"description": "\n Determines the behavior of the booking window:\n - If **true**, the window is rolling. This means the number of available days will always equal the specified 'value' \n and adjust dynamically as bookings are made. For example, if 'value' is 3 and availability is only on Mondays, \n a booker attempting to schedule on November 10 will see slots on November 11, 18, and 25. As one of these days \n becomes fully booked, a new day (e.g., December 2) will open up to ensure 3 available days are always visible.\n - If **false**, the window is fixed. This means the booking window only considers the next 'value' days from the\n moment someone is trying to book. For example, if 'value' is 3, availability is only on Mondays, and the current \n date is November 10, the booker will only see slots on November 11 because the window is restricted to the next \n 3 calendar days (November 10–12).\n "
"description": "\n Determines the behavior of the booking window:\n - If **true**, the window is rolling. This means the number of available days will always be equal the specified 'value' \n and adjust dynamically as bookings are made. For example, if 'value' is 3 and availability is only on Mondays, \n a booker attempting to schedule on November 10 will see slots on November 11, 18, and 25. As one of these days \n becomes fully booked, a new day (e.g., December 2) will open up to ensure 3 available days are always visible.\n - If **false**, the window is fixed. This means the booking window only considers the next 'value' days from the\n moment someone is trying to book. For example, if 'value' is 3, availability is only on Mondays, and the current \n date is November 10, the booker will only see slots on November 11 because the window is restricted to the next \n 3 calendar days (November 10–12).\n "
}
},
"required": [
@@ -9600,7 +9979,38 @@
"status"
]
},
"GetUserOutput": {
"ProfileOutput": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "The ID of the profile of user",
"example": 1
},
"organizationId": {
"type": "number",
"description": "The ID of the organization of user",
"example": 1
},
"userId": {
"type": "number",
"description": "The IDof the user",
"example": 1
},
"username": {
"type": "string",
"nullable": true,
"description": "The username of the user within the organization context",
"example": "john_doe"
}
},
"required": [
"id",
"organizationId",
"userId"
]
},
"GetOrgUsersWithProfileOutput": {
"type": "object",
"properties": {
"id": {
@@ -9724,6 +10134,14 @@
"nullable": true,
"description": "The ID of the user who invited this user",
"example": 1
},
"profile": {
"description": "organization user profile, contains user data within the organizaton context",
"allOf": [
{
"$ref": "#/components/schemas/ProfileOutput"
}
]
}
},
"required": [
@@ -9732,10 +10150,11 @@
"timeZone",
"weekStart",
"hideBranding",
"createdDate"
"createdDate",
"profile"
]
},
"GetOrganizationUsersOutput": {
"GetOrganizationUsersResponseDTO": {
"type": "object",
"properties": {
"status": {
@@ -9749,7 +10168,7 @@
"data": {
"type": "array",
"items": {
"$ref": "#/components/schemas/GetUserOutput"
"$ref": "#/components/schemas/GetOrgUsersWithProfileOutput"
}
}
},
@@ -9858,7 +10277,7 @@
]
},
"data": {
"$ref": "#/components/schemas/GetUserOutput"
"$ref": "#/components/schemas/GetOrgUsersWithProfileOutput"
}
},
"required": [
@@ -13450,7 +13869,7 @@
"hosts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
"$ref": "#/components/schemas/BookingHost"
}
},
"status": {
@@ -13571,7 +13990,7 @@
"hosts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
"$ref": "#/components/schemas/BookingHost"
}
},
"status": {
@@ -13737,7 +14156,7 @@
"hosts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
"$ref": "#/components/schemas/BookingHost"
}
},
"status": {
@@ -13853,7 +14272,7 @@
"hosts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Host"
"$ref": "#/components/schemas/BookingHost"
}
},
"status": {
@@ -14247,6 +14666,192 @@
}
}
},
"CreateTeamMembershipInput": {
"type": "object",
"properties": {
"role": {
"type": "string",
"default": "MEMBER",
"enum": [
"MEMBER",
"OWNER",
"ADMIN"
]
},
"userId": {
"type": "number"
},
"accepted": {
"type": "boolean",
"default": false
},
"disableImpersonation": {
"type": "boolean",
"default": false
}
},
"required": [
"role",
"userId"
]
},
"TeamMembershipOutput": {
"type": "object",
"properties": {
"role": {
"type": "string",
"enum": [
"MEMBER",
"OWNER",
"ADMIN"
]
},
"id": {
"type": "number"
},
"userId": {
"type": "number"
},
"teamId": {
"type": "number"
},
"accepted": {
"type": "boolean"
},
"disableImpersonation": {
"type": "boolean"
}
},
"required": [
"role",
"id",
"userId",
"teamId",
"accepted"
]
},
"CreateTeamMembershipOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
"status",
"data"
]
},
"GetTeamMembershipOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
"status",
"data"
]
},
"GetTeamMembershipsOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
"status",
"data"
]
},
"UpdateTeamMembershipInput": {
"type": "object",
"properties": {
"role": {
"type": "string",
"default": "MEMBER",
"enum": [
"MEMBER",
"OWNER",
"ADMIN"
]
},
"accepted": {
"type": "boolean",
"default": false
},
"disableImpersonation": {
"type": "boolean",
"default": false
}
}
},
"UpdateTeamMembershipOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
"status",
"data"
]
},
"DeleteTeamMembershipOutput": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/TeamMembershipOutput"
}
},
"required": [
"status",
"data"
]
},
"ReserveSlotInput": {
"type": "object",
"properties": {
@@ -14659,6 +15264,22 @@
"data"
]
},
"GetConferencingAppsOauthUrlResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
}
},
"required": [
"status"
]
},
"ConferencingAppsOutputResponseDto": {
"type": "object",
"properties": {
@@ -14697,7 +15318,53 @@
"required": [
"status"
]
},
"DefaultConferencingAppsOutputDto": {
"type": "object",
"properties": {
"appSlug": {
"type": "string"
},
"appLink": {
"type": "string"
}
}
},
"GetDefaultConferencingAppOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
},
"data": {
"$ref": "#/components/schemas/DefaultConferencingAppsOutputDto"
}
},
"required": [
"status"
]
},
"DisconnectConferencingAppOutputResponseDto": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "success",
"enum": [
"success",
"error"
]
}
},
"required": [
"status"
]
}
}
}
}
}
-7
View File
@@ -20,18 +20,11 @@ const ROUTES: [URLPattern, boolean][] = [
["/auth/error", process.env.APP_ROUTER_AUTH_ERROR_ENABLED === "1"] as const,
["/auth/platform/:path*", process.env.APP_ROUTER_AUTH_PLATFORM_ENABLED === "1"] as const,
["/auth/oauth2/:path*", process.env.APP_ROUTER_AUTH_OAUTH2_ENABLED === "1"] as const,
["/workflows/:path*", process.env.APP_ROUTER_WORKFLOWS_ENABLED === "1"] as const,
["/getting-started/:step", process.env.APP_ROUTER_GETTING_STARTED_STEP_ENABLED === "1"] as const,
["/apps", process.env.APP_ROUTER_APPS_ENABLED === "1"] as const,
["/bookings/:status", process.env.APP_ROUTER_BOOKINGS_STATUS_ENABLED === "1"] as const,
["/booking/:path*", process.env.APP_ROUTER_BOOKING_ENABLED === "1"] as const,
["/video/:path*", process.env.APP_ROUTER_VIDEO_ENABLED === "1"] as const,
["/team", process.env.APP_ROUTER_TEAM_ENABLED === "1"] as const,
["/teams", process.env.APP_ROUTER_TEAMS_ENABLED === "1"] as const,
["/more", process.env.APP_ROUTER_MORE_ENABLED === "1"] as const,
["/maintenance", process.env.APP_ROUTER_MAINTENANCE_ENABLED === "1"] as const,
["/upgrade", process.env.APP_ROUTER_UPGRADE_ENABLED === "1"] as const,
["/connect-and-join", process.env.APP_ROUTER_CONNECT_AND_JOIN_ENABLED === "1"] as const,
["/availability", process.env.APP_ROUTER_AVAILABILITY_ENABLED === "1"] as const,
].map(([pathname, enabled]) => [
new URLPattern({
+9
View File
@@ -27,6 +27,15 @@ export const getFixedT = async (locale: string, ns = "common") => {
return i18n.getFixedT(locale, ns);
};
export const getTranslate = async () => {
const headersList = await headers();
// If "x-locale" does not exist in header,
// ensure that config.matcher in middleware includes the page you are testing
const locale = headersList.get("x-locale");
const t = await getFixedT(locale ?? "en");
return t;
};
export const _generateMetadata = async (
getTitle: (t: TFunction<string, undefined>) => string,
getDescription: (t: TFunction<string, undefined>) => string,
@@ -5,7 +5,7 @@ import LegacyPage from "~/connect-and-join/connect-and-join-view";
export const generateMetadata = async () => {
return await _generateMetadata(
() => "Connect and Join",
(t) => t("connect_and_join"),
() => ""
);
};
-20
View File
@@ -1,20 +0,0 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { APP_NAME } from "@calcom/lib/constants";
import { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
import type { PageProps } from "~/videos/views/videos-single-view";
import VideosSingleView from "~/videos/views/videos-single-view";
export const generateMetadata = async () =>
await _generateMetadata(
() => `${APP_NAME} Video`,
(t) => t("quick_video_meeting")
);
const getData = withAppDirSsr<PageProps>(getServerSideProps);
export default WithLayout({ getData, Page: VideosSingleView, getLayout: null })<"P">;
-36
View File
@@ -1,36 +0,0 @@
import type { PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSessionForAppDir } from "@calcom/feature-auth/lib/get-server-session-for-app-dir";
import LegacyPage from "@calcom/features/ee/workflows/pages/index";
import { getTeamsFiltersFromQuery } from "@calcom/features/filters/lib/getTeamsFiltersFromQuery";
import { WorkflowRepository } from "@calcom/lib/server/repository/workflow";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("workflows"),
(t) => t("workflows_to_automate_notifications")
);
const Page = async ({ params, searchParams }: PageProps) => {
// FIXME: Refactor me once next-auth endpoint is migrated to App Router
const session = await getServerSessionForAppDir();
const user = session?.user;
const filters = getTeamsFiltersFromQuery({ ...searchParams, ...params });
let filteredList;
try {
filteredList = await WorkflowRepository.getFilteredList({
userId: user?.id,
input: {
filters,
},
});
} catch (err) {}
return <LegacyPage filteredList={filteredList} />;
};
export default WithLayout({ getLayout: null, ServerPage: Page })<"P">;
+17
View File
@@ -0,0 +1,17 @@
import { withAppDirSsr } from "app/WithAppDirSsr";
import { _generateMetadata } from "app/_utils";
import { WithLayout } from "app/layoutHOC";
import { getServerSideProps } from "@lib/insights/getServerSideProps";
import InsightsRoutingPage from "~/insights/insights-routing-view";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
(t) => t("insights_subtitle")
);
const getData = withAppDirSsr(getServerSideProps);
export default WithLayout({ getLayout: null, getData, Page: InsightsRoutingPage });
@@ -5,10 +5,17 @@ import { APP_NAME } from "@calcom/lib/constants";
import LegacyPage from "~/maintenance/maintenance-view";
export const generateMetadata = async () =>
await _generateMetadata(
export const generateMetadata = async () => {
const metadata = await _generateMetadata(
(t) => t("under_maintenance"),
(t) => t("under_maintenance_description", { appName: APP_NAME })
);
return {
...metadata,
icons: {
icon: "/favicon.ico",
},
};
};
export default WithLayout({ getLayout: null, Page: LegacyPage })<"P">;
@@ -5,7 +5,7 @@ import Page from "~/more/more-page-view";
export const generateMetadata = async () => {
return await _generateMetadata(
() => "More",
(t) => t("more"),
() => ""
);
};
@@ -1,7 +1,6 @@
import { _generateMetadata } from "app/_utils";
import { getFixedT } from "app/_utils";
import { getTranslate } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/feature-auth/lib/get-server-session-for-app-dir";
import AdminAppsList from "@calcom/features/apps/AdminAppsList";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -12,9 +11,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader title={t("apps")} description={t("admin_apps_description")}>
@@ -1,6 +1,5 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import { FlagListingView } from "@calcom/features/flags/pages/flag-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -11,8 +10,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader title={t("feature_flags")} description={t("admin_flags_description")}>
<FlagListingView />
@@ -1,7 +1,6 @@
import { _generateMetadata } from "app/_utils";
import { getFixedT } from "app/_utils";
import { getTranslate } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/feature-auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import ImpersonationView from "~/settings/admin/impersonation-view";
@@ -13,9 +12,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader title={t("admin")} description={t("impersonation")}>
<ImpersonationView />
@@ -1,6 +1,5 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LockedSMSView from "~/settings/admin/locked-sms-view";
@@ -12,8 +11,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader title={t("lockedSMS")} description={t("admin_lockedSMS_description")}>
<LockedSMSView />
@@ -1,6 +1,5 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import LegacyPage from "~/settings/admin/oauth-view";
@@ -12,8 +11,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader title={t("oAuth")} description={t("admin_oAuth_description")}>
<LegacyPage />
@@ -1,9 +1,8 @@
import { type Params } from "app/_types";
import { _generateMetadata, getFixedT } from "app/_utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { OrgForm } from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgEditPage";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -35,8 +34,7 @@ const Page = async ({ params }: { params: Params }) => {
try {
const org = await OrganizationRepository.adminFindById({ id: input.data.id });
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader
title={`${t("editing_org")}: ${org.name}`}
@@ -1,7 +1,6 @@
import { _generateMetadata } from "app/_utils";
import { getFixedT } from "app/_utils";
import { getTranslate } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/feature-auth/lib/get-server-session-for-app-dir";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import AdminOrgTable from "@calcom/features/ee/organizations/pages/settings/admin/AdminOrgPage";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -13,9 +12,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader title={t("organizations")} description={t("orgs_page_description")}>
<LicenseRequired>
@@ -1,9 +1,8 @@
import { type Params } from "app/_types";
import { _generateMetadata, getFixedT } from "app/_utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { z } from "zod";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { UsersEditView } from "@calcom/features/ee/users/pages/users-edit-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -37,8 +36,7 @@ const Page = async ({ params }: { params: Params }) => {
try {
const user = await UserRepository.adminFindById(input.data.id);
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader title={t("editing_user")} description={t("admin_users_edit_description")}>
@@ -1,6 +1,5 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import UsersAddView from "@calcom/features/ee/users/pages/users-add-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
@@ -11,8 +10,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader title={t("add_new_user")} description={t("admin_users_add_description")}>
@@ -1,6 +1,5 @@
import { _generateMetadata, getFixedT } from "app/_utils";
import { _generateMetadata, getTranslate } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/features/auth/lib/get-server-session-for-app-dir";
import UsersListingView from "@calcom/features/ee/users/pages/users-listing-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { Button } from "@calcom/ui";
@@ -12,8 +11,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader
title={t("users")}
@@ -0,0 +1,28 @@
import { _generateMetadata } from "app/_utils";
import { getFixedT } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/feature-auth/lib/get-server-session-for-app-dir";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import WorkspacePlatformsPage from "@calcom/features/ee/organizations/pages/settings/admin/WorkspacePlatformPage";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("workspace_platforms"),
(t) => t("workspace_platforms_description")
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
return (
<SettingsHeader title={t("workspace_platforms")} description={t("workspace_platforms_description")}>
<LicenseRequired>
<WorkspacePlatformsPage />
</LicenseRequired>
</SettingsHeader>
);
};
export default Page;
@@ -1,7 +1,6 @@
import { _generateMetadata } from "app/_utils";
import { getFixedT } from "app/_utils";
import { getTranslate } from "app/_utils";
import { getServerSessionForAppDir } from "@calcom/feature-auth/lib/get-server-session-for-app-dir";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import BillingView from "~/settings/billing/billing-view";
@@ -13,8 +12,7 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSessionForAppDir();
const t = await getFixedT(session?.user.locale || "en");
const t = await getTranslate();
return (
<SettingsHeader

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