Compare commits

...
Author SHA1 Message Date
emrysal 2e6ad8be9a chore: release v5.6.17
Post release / release (push) Canceled after 0s
2025-09-10 23:17:01 +00:00
Calcom BotandLingo.dev 1aca18324d feat: update translations via @LingoDotDev (#23753)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-10 21:38:05 +05:30
Udit TakkarandPeer Richelsen a8a3e48465 feat: add web phone call for cal ai (#23527)
* feat: add web phone call for cal ai

* fix: web call

* refactor: improvements

* chore

* refactor: web call improvements

* chore: error message

* refactor: improvements

* fix: err

* refactor: feedback and improvements

* fix: type errors

* refactor: hasAvailable

* chore: use orgid

* fix: types

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-09-10 12:41:10 -03:00
Volnei Munhoz 2e96600529 Revert "fix(workflows): hide organizer email in workflow emails (#23525)" (#23739)
This reverts commit 7f200df1ac.
2025-09-10 11:49:38 +00:00
Hariom Balhara b9074aeb81 Better ioctopus type-safety (#23541) 2025-09-10 17:10:25 +05:30
Udit Takkar 13add03479 chore: update vitest (#23732) 2025-09-10 16:59:25 +05:30
Hariom Balhara bb2dcc51c6 Ignore requests from bot for headless router (#23733) 2025-09-10 16:59:04 +05:30
RomitandKartik Saini 8f82e78597 fix: loading skeleton is rendered even when no attributes filter is selected (#23656)
* fix

* fix tests

* fix tests

* test fidx

* chore

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-09-10 16:57:56 +05:30
Eunjae LeeandDevin AI 34d2a13080 fix: move admin permission check to server-side to prevent flash (#23517)
- Move permission validation from AdminLayoutAppDirClient.tsx (client) to layout.tsx (server)
- Add server-side redirect using next/navigation redirect() function
- Remove client-side useEffect permission check to eliminate flash
- Follow same pattern as organizations layout for consistent auth handling
- Users without admin role are redirected to /settings/my-account/profile before any content renders

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-10 11:15:37 +01:00
Eunjae LeeandDevin AI 6066ed206b fix: Fix booking count discrepancy between KPI cards and trends chart (#23619)
* fix: correct booking count in EventTrendsChart by restructuring SQL query

- Replace JOIN-based query with CTE approach to count distinct bookings
- Separate booking stats from guest stats to prevent duplicate counting
- Preserve no-show guest functionality with LEFT JOIN
- Ensure EventTrendsChart 'Created' sum matches BookingKPICards 'events_created'

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

* fix: use positional GROUP BY references to resolve PostgreSQL error

- Replace column expressions in GROUP BY with positional references (1, 2, 3)
- Fixes PostgreSQL error 42803 caused by parameter resolution in CTE queries
- Maintains the same query logic while avoiding column reference conflicts

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

* address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-10 11:13:29 +01:00
d30d2792cd fix: hide notes (#22061)
* fix: hide notes

* test: re-enable confirmHandler tests and add tests for hide notes fix

* fix: replace deprecated getDate with hardcoded dates for timezone safety

* fix: hide notes test

* refactor: move test from trpc to web package

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-09-10 10:51:41 +01:00
ba50268634 feat: support auto create agent (#23493)
* feat: support auto create agent

* feat: add prompts

* chore: add number to call

* fix typo

---------

Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-09-10 08:42:39 +00:00
Benny Joo 4ca78f46df chore: bump platform libraries version (#23730) 2025-09-10 08:37:17 +00:00
Eunjae LeeandAnik Dhabal Babu 3a7ca0246c docs: add knowledge base against unnecessary Day.js usage (#23719)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-10 09:54:48 +02:00
Vansh GilhotraandAnik Dhabal Babu 2cc8b1c2af fix: Use db-deploy instead of db-migrate for yarn dx in non-interactive environments (#23253)
Fixes the issue where 'yarn dx' fails in non-interactive environments
(CI/CD, Docker, automated scripts) by changing Turbo dependencies:

- @calcom/prisma#db-seed: db-migrate → db-deploy
- @calcom/prisma#dx: db-migrate → db-up

This ensures that non-interactive 'prisma migrate deploy' is used
instead of interactive 'prisma migrate dev', allowing yarn dx to
work correctly in automated environments.

Resolves: Cal.com App Store performance optimization - yarn dx failure

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-10 06:46:46 +00:00
Lauris Skraucis a2d0cbfe5b chore: v2 dont allow mixing platform teams/users with non-platform (#20296)
* chore: v2 dont allow mixing platform teams/users with non-platform

* chore: finish main merge

* test: e2e

* refactor: simplify can add user to team logic

* refactor: fetch only needed oauth data

* refactor: cleanup
2025-09-09 21:24:18 +02:00
PallavandKartik Saini d65c0753ef conventional left aligned dropdown (#23643)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-09-09 16:02:43 +00:00
github-actions[bot] 76bd90b228 chore: version packages (#23714)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-09 15:23:30 +00:00
Benny Joo d908403a99 refactor: circular deps between app store and lib [1] (#23653)
* move dailyApiFetcher.ts to app store package

* update imports

* bookingLocationService

* revert

* move findFieldValueByIdentifier

* wip

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* move to features package

* update imports

* update imports

* move

* fix typechecks

* move getCalendarLinks

* mv event test
2025-09-09 14:38:29 +00:00
Amit SharmaandUdit Takkar 0fe775962f fix: intercom showing on cal video page (#23713)
* feat: support auto create agent

* fix: intercom showing on cal.video page

* Revert "feat: support auto create agent"

This reverts commit 084319c419b3baa44a3a82c0a2d79a4242a64d4d.

---------

Co-authored-by: Udit Takkar <udit222001@gmail.com>
2025-09-09 14:28:59 +00:00
Calcom BotandLingo.dev 412a6992c7 feat: update translations via @LingoDotDev (#23715)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-09 11:04:39 -03:00
Rajiv Sahal cfd1992733 feat: add customReplyToEmail for EventTypeSettings atom (#23686)
* init: endpoint for fetching verified emails

* fix: enable custom reply to email in frontend

* init endpoint to add verified emails

* add verified emails option for platform in frontend

* fixup: move useGetVerifiedEmails hook to correct folder

* update atoms module

* fixup: teamId should be string

* add methond to fetch team member emails

* update logic to fetch and add emails

* fixup: append client id with email

* fixup: pass teamId for fetching verified emails

* fixup: simplify check for existing emails

* fix: cleanup comments

* fix: implement code rabbit feedback

* fix: add translations

* fixup: update transaltions

* fix: update logic for addVerifiedEmail

* add changesets

* fix: implement PR feedback
2025-09-09 16:52:28 +03:00
RomitandAnik Dhabal Babu e86d6169ba fix (#23705)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-09 19:05:17 +05:30
sean-brydonandcoderabbitai[bot] a379bd2b52 fix: org layout authentication (#23707)
* refactor layout to not check session

* add actions for orgs + org admins

* update types on actions to be correctly non nullable

* Add tests for utils

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* restore lock file

* rename _actions to actions

* update tests to be mocked

* restore lock

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-09 22:27:00 +09:00
Eunjae LeeandDevin AI 1137047606 feat: add PBAC permission checks to insights (#23592)
* feat: add PBAC permission checks to insights

- Add layout-level protection for insights pages with session.user.org.id check
- Add API-level protection for insights tRPC endpoints using insightsPbacProcedure
- Enhance routing form insights with PBAC team filtering
- Use insights.read permission with OWNER/ADMIN fallback roles
- Replace all userBelongsToTeamProcedure with insightsPbacProcedure in insights router
- Fix lint issues: remove unused variables and functions, fix non-null assertion

Implements Permission-Based Access Control (PBAC) for insights functionality following the same patterns as event type PBAC implementation from PR #22618.

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

* fix: clean up unused imports in routing-forms

- Remove unused isFormCreateEditAllowed import
- Remove unused TRPCError import
- Change MembershipRole to type import for better tree-shaking

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

* address feedback

* clean up permission check

* revert unnecessary changes

* fix procedure

* allow MEMBER for insights

* support teams not under orgs

* fix type error

* revert unnecessary changes

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-09 14:26:20 +01:00
Lauris Skraucis ad4cf0cc3a fix: v2 hidden event types (#23584)
* fix: return hidden event types only if owner auth provided

* chore: add tests

* chore: remove optional chaining when defined
2025-09-09 13:23:50 +02:00
RomitandKartik Saini e7d2e0dc2f fix: Prevent loading of dashboard GTM container on booking success page (#22620)
* moved bookings folder

* removed test

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-09-09 16:31:07 +05:30
Alex van Andel aa51218428 perf: move to disable prisma client extension inference (#23692)
* perf: move to disable prisma client extension inference

* Prisma doesn't like it when you pass Record<string, unknown>

* API v1 type fixes

* Missed one

* Fix unit test fail due to faulty expect

* Assigning to prisma InputJsonValue/Array must be done as object, not interface

* Fix @calcom/web ts error, teams not defined

* Run eslint formatter

* fixed the routingFormHelpers file causing a failing app store e2e test
2025-09-09 10:56:58 +00:00
Yadong (Adam) Zhang 38bc5fb71e chore(apps/web): add Scan for React performance issues and eliminate slow renders in web app (#23696)
* chore: add Scan for React performance issues and eliminate slow renders in web app

* chore(apps/web): always install latest `react-scan`.
2025-09-09 10:50:44 +00:00
411516ddb9 fix: duplicate event created in attendee calendar due to incorrect icalUid (#23658)
* chre: update reassigment to delete old events

* fix

* Update yarn.lock

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: unknown <adhabal2002@gmail.com>
2025-09-09 10:29:14 +00:00
Morgan c6c17ac388 fix: delegated credentials timezone outlook (#23700) 2025-09-09 11:25:48 +01:00
emrysal 6838133865 chore: release v5.6.16
Post release / release (push) Canceled after 0s
2025-09-09 10:21:58 +00:00
Calcom BotandLingo.dev 4b2bb2271a feat: update translations via @LingoDotDev (#23703)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-09 06:47:06 -03:00
20b7ac8c0d feat: implement tiered Intercom chat system replacing Plain (#23285)
* feat: implement tiered Intercom chat system replacing Plain

- Add TieredIntercomChat component with customer tier detection
- Add IntercomContactForm for free users using Intercom API
- Add /api/intercom-conversation endpoint for free user support
- Update UserDropdown to use tiered chat logic
- Replace Plain chat with Intercom in app providers
- Remove all Plain chat components and related files
- Use useHasPaidPlan hook for customer tier detection

Paying customers see Intercom widget, free users see contact form
that creates conversations via Intercom API.

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

* fix: add missing environment variables to turbo.json globalEnv

- Add NEXT_PUBLIC_WEBAPP_URL, NEXT_PUBLIC_WEBSITE_URL, NEXT_PUBLIC_STRIPE_PUBLIC_KEY
- Add NEXT_PUBLIC_IS_E2E, NEXT_PUBLIC_STRIPE_PREMIUM_PLAN_PRICE_MONTHLY
- Add NEXT_PUBLIC_BOOKER_NUMBER_OF_DAYS_TO_LOAD, NEXT_PUBLIC_STRIPE_CREDITS_PRICE_ID
- Resolves turbo/no-undeclared-env-vars ESLint errors blocking commits

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

* fix: resolve linting errors in platform examples base package

- Fix prettier/prettier formatting (quotes and comma)
- Add underscore prefix to unused variables
- Resolves CI failure in @calcom/base#lint check

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

* remove plain usage

* placement fixes

* fix: pop closing immediately issue

* stuff

* proper error and additional user info

* add key to .env.example and remove unused plain route

* fix conversation route

* refactor intercom dynamic provider

* code rabbit fixes

* feat: introduce tiered support feature flag

* fix: type check

* Apply suggestion from @coderabbitai[bot]

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Amit Sharma <74371312+Amit91848@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-09 18:20:22 +09:00
Calcom BotandLingo.dev ca7c432728 feat: update translations via @LingoDotDev (#23699)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-09 08:49:09 +00:00
SomandHariom Balhara 3b966191e3 fix: prevent footer doubling with CSS position absolute for grid layouts (#23368)
* fix: prevent footer doubling with CSS position absolute for grid layouts

* fix: use :host selector for Shadow DOM compatibility in footer doubling fix

* Add better comments and updae playground to be ab le to test it

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
2025-09-09 08:13:15 +00:00
a7889984a0 fix: design improvements for log in screen for cal video (#22359)
* fix: design improvements for log in screen for cal video

Signed-off-by: bandhan-majumder <bandhanmajumder16@gmail.com>

* fix: type

Signed-off-by: bandhan-majumder <bandhanmajumder16@gmail.com>

* Update videos-single-view.tsx

* Update videos-single-view.tsx

* Update videos-single-view.tsx

* add: light mode

* Update videos-single-view.tsx

* add suggestions 

added padding change and removed hard coded values

* Update videos-single-view.tsx

* Update videos-single-view.tsx

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

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* Update common.json

* Update videos-single-view.tsx

* remove isLoggedin from LogInOverlayProps

* type fix

* screen flash fixed

* rabbit suggestions

* rabbit suggestions

* Update videos-single-view.tsx

* Update videos-single-view.tsx

* Update videos-single-view.tsx

* flakey test

* Update videos-single-view.tsx

* Update videos-single-view.tsx

* Update videos-single-view.tsx

* add: deps

* coderabbit

---------

Signed-off-by: bandhan-majumder <bandhanmajumder16@gmail.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-09-09 07:44:29 +00:00
Anik Dhabal Babu 3c72d7aaa0 fix: Don’t allow hosts to cancel a booking without providing a reason through v1 (#23647)
* fix: hosts is cancelling booking through apiV1

* tweak
2025-09-09 05:30:51 +00:00
Romit ece0030978 fix (#23685) 2025-09-09 04:20:20 +00:00
Anik Dhabal Babu a26a769119 fix: send awaiting payment email only to the current seat (#23630) 2025-09-09 03:50:24 +00:00
Anik Dhabal Babu 3313de1eec fix: change internal note preset fkey to cascade (#23613) 2025-09-09 03:13:31 +00:00
Anik Dhabal Babu 7a6e16631b fix: guests are not adding when reschedule booking (#23620)
* fix: guests are not adding when reschedule booking

* Update addGuests.handler.ts

* add types
2025-09-09 03:12:41 +00:00
Anik Dhabal Babu ce4d71ba2e fix: workflow attach ics file is in base64 format (#23680) 2025-09-09 03:11:34 +00:00
300cd31efb chore: redis cache for calendars in api v2 (#23622)
* chore: implement redis cache for calendars api v2

* fixup! chore: implement redis cache for calendars api v2

* fixup! fixup! chore: implement redis cache for calendars api v2

* fix leverage genetic type of redis.get

* refactor: address supalarry's PR feedback

- Create CalendarsCacheService to separate cache responsibilities
- Rename cache methods to be more descriptive (getConnectedAndDestinationCalendarsCache, setConnectedAndDestinationCalendarsCache)
- Remove unused redisService injection from CalendarsController
- Move all cache-related functionality to dedicated service

Addresses comments 9, 10, and 11 from supalarry on PR #23622

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

* refactor: address supalarry's latest PR feedback

- Remove redundant deleteConnectedAndDestinationCalendarsCache from CalendarsService
- Update controller to directly inject and use CalendarsCacheService
- Extract TTL constant and remove manual JSON.stringify in cache service

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

* fix: update IcsFeedService to use CalendarsCacheService directly

- Inject CalendarsCacheService into IcsFeedService constructor
- Replace calendarsService.deleteCalendarCache with calendarsCacheService.deleteConnectedAndDestinationCalendarsCache
- Completes the refactoring to eliminate all old cache method references

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

* fix: update destination-calendars and selected-calendars services to use CalendarsCacheService directly

- Inject CalendarsCacheService in both service constructors
- Replace calendarsService.deleteCalendarCache calls with calendarsCacheService.deleteConnectedAndDestinationCalendarsCache
- Add CalendarsCacheService to module providers for proper dependency injection
- Completes migration of all cache method references to use dedicated cache service

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

* chore: use CalendarsCacheService

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-08 21:21:55 +00:00
Rajiv Sahal f5bb734441 fix: platform onboarding (#23581)
* fix: platform onboarding

* chore: better function naming

* fix: implement PR feedback
2025-09-08 19:04:33 +00:00
emrysal e33560ef82 chore: release v5.6.15
Post release / release (push) Canceled after 0s
2025-09-08 12:44:40 +00:00
devin-ai-integration[bot]andmorgan@cal.com 84f34b2244 feat: add 5000ms staleTime to useConnectedCalendars hook (#23674)
- Add staleTime: 5000 to useQuery configuration in useConnectedCalendars
- Prevents unnecessary refetches for 5 seconds after data becomes stale
- Improves performance by reducing redundant API calls

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-09-08 12:19:22 +00:00
Benny Joo 086bae03d3 refactor: move a constant to a dedicated file in App store package (#23671)
* define DailyLocationType in app-store/locations.ts

* update imports
2025-09-08 08:38:46 -03:00
4a0a429774 refactor: Migrate getRoutedUrl from @calcom/lib to @calcom/features (#23666)
* mkdir

* update imports

* chore: update platform libraries

---------

Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-09-08 11:28:22 +00:00
Benny Joo c4142ab4e7 refactor: Move handlePayment to Features Package (#23670)
* mv

* update imports
2025-09-08 07:32:29 -03:00
Benny Joo 95f8046db9 refactor: Error code - remove circular dep between app store <-> lib (#23669)
* remove stripe.types.ts

* use stripe-error defined in packages/lib

* refactor
2025-09-08 07:31:28 -03:00
Lauris Skraucis d8a87e9f04 fix: v2 seated bookings (#23514)
* fix: email manage link missing seat uid

* fix: toggle seated booking attendees

* fix: email manage link seat uid

* chore: update platform libraries
2025-09-08 09:50:36 +00:00
Keith Williams 86dcd6f0ff perf: Create 'heavy' eventTypes router (#23664)
* perf: Create 'heavy' eventTypes router

* Fixing type checks

* Fixing platform libraries imports
2025-09-08 08:24:30 +00:00
Anik Dhabal Babu 411a14dd3d fix: unit test flakiness (#23668)
* fix: unit test flakyness

* Update CalendarService.test.ts
2025-09-08 03:47:40 +00:00
Benny Joo 03bc8db64a refactor: Remove StripeService from @calcom/lib and define it in @calcom/trpc (#23655)
* remove packages/lib/server/stripe

* simplify metadata

* define business logic directly in trpc handler
2025-09-08 02:02:56 +00:00
Benny Joo fe7fd8bc79 chore: add an eslint rule to warn importing trpc package in app store package (#23665) 2025-09-08 10:36:34 +09:00
Benny Joo 92eb41c117 refactor: Migrate trpc routers in App store package to Trpc package (#23536)
* migrate trpc routers in app package to trpc package

* delete

* delete

* delete

* fix

* fix

* fix

* format

* format

* fix

* fix

* fix

* fix

* remove unused file

* fix

* fix

* wip
2025-09-07 22:17:33 -03:00
Benny Joo a1fca56aac refactor: Remove App Store's Type Dependency on Trpc Package (#23640)
* remove trpc type interface

* refactors

* refactors
2025-09-08 00:59:51 +00:00
416 changed files with 11726 additions and 4886 deletions
+9
View File
@@ -318,3 +318,12 @@ dates.map((date) => dayjs.utc(date).add(1, "day").format());
// ✅ Best - Use native Date when possible
dates.map((date) => new Date(date.valueOf() + 24 * 60 * 60 * 1000));
```
## Avoid using Dayjs if you don’t need to be strictly tz aware.
When doing logic like Dayjs.startOf(".."), you can instead use date-fns' `startOfMonth(dateObj)` / `endOfDay(dateObj)`;
When doing logic that depends on Browser locale, use i18n.language (prefer to deconstruct) like: `const { i18n: { language } } = useLocale();`, in combination with built-in Intl.
Note that with Date, you’re dealing with System time, so it’s not suited to everywhere (such as in the Booker, where instead we’ll likely migrate to Temporal) - but in most cases the above are suitable.
The main reason for doing so is that Dayjs uses a useful, but highly risky plugin system, which has led us to create `@calcom/dayjs` - this is heavy however, because it pre-loads ALL plugins, including locale handling. It’s a non-ideal solution to a problem that unfortunately exists due to Dayjs.
+3 -8
View File
@@ -103,19 +103,14 @@ NEXT_PUBLIC_INTERCOM_APP_ID=
# Secret to enable Intercom Identity Verification
INTERCOM_SECRET=
# Intercom api token for free users
INTERCOM_API_TOKEN=
# Posthog Config
NEXT_PUBLIC_POSTHOG_KEY=
NEXT_PUBLIC_POSTHOG_HOST=
# plain.com config
PLAIN_API_KEY=
PLAIN_API_URL=https://api.plain.com/v1
PLAIN_HMAC_SECRET_KEY=
NEXT_PUBLIC_PLAIN_CHAT_ID=
PLAIN_CHAT_HMAC_SECRET_KEY=
NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS=
# Dub Config
+12
View File
@@ -19,5 +19,17 @@ module.exports = {
],
},
},
{
files: ["packages/app-store/**/*.{ts,tsx,js,jsx}"],
rules: {
"no-restricted-imports": [
"warn",
{
paths: ["@calcom/trpc"],
patterns: ["@calcom/trpc/*"],
},
],
},
},
],
};
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
View File
View File
+1 -1
View File
@@ -43,7 +43,7 @@ export const bookingCancelSchema = z.object({
id: z.number(),
allRemainingBookings: z.boolean().optional(),
cancelSubsequentBookings: z.boolean().optional(),
cancellationReason: z.string().optional().default("Not Provided"),
cancellationReason: z.string().min(1).optional(),
seatReferenceUid: z.string().optional(),
cancelledBy: z.string().email({ message: "Invalid email" }).optional(),
internalNote: z
@@ -3,7 +3,8 @@ import type { NextApiRequest } from "next";
import { OAuth2UniversalSchema } from "@calcom/app-store/_utils/oauth/universalSchema";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { schemaCredentialPatchParams, schemaCredentialPatchBody } from "~/lib/validations/credential-sync";
@@ -71,7 +72,7 @@ async function handler(req: NextApiRequest) {
userId,
},
data: {
key,
key: key as unknown as Prisma.InputJsonValue,
},
select: {
id: true,
@@ -6,7 +6,8 @@ import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { symmetricDecrypt } from "@calcom/lib/crypto";
import { HttpError } from "@calcom/lib/http-error";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
import { schemaCredentialPostBody, schemaCredentialPostParams } from "~/lib/validations/credential-sync";
@@ -92,7 +93,7 @@ async function handler(req: NextApiRequest) {
data: {
userId,
appId: appSlug,
key,
key: key as unknown as Prisma.InputJsonValue,
type: appMetadata.type,
},
select: credentialForCalendarServiceSelect,
+1 -1
View File
@@ -77,7 +77,7 @@ describe("POST /api/attendees", () => {
const userBooking = { id: 1 };
prismaMock.booking.findFirst.mockResolvedValue(userBooking);
prismaMock.booking.findFirst.mockResolvedValue(userBooking as any);
const attendeeData = {
id: 1,
@@ -25,7 +25,7 @@ beforeEach(() => {
id: userId,
email: "test@example.com",
name: "Test User",
});
} as any);
});
afterEach(() => {
@@ -20,7 +20,7 @@ beforeEach(() => {
id: userId,
email: "test@example.com",
name: "Test User",
});
} as any);
});
afterEach(() => {
+3 -2
View File
@@ -30,12 +30,13 @@ beforeEach(() => {
id: userId,
email: "test@example.com",
name: "Test User",
});
} as any);
(getAccessibleUsers as any).mockResolvedValue([userId]);
(retrieveOrgScopedAccessibleUsers as any).mockResolvedValue([userId]);
prismaMock.membership.findMany.mockResolvedValue([
{
// @ts-expect-error Will be fixed by Prisma 6.7.0 upgrade mock changes - which uses vitest-mock-extended
team: {
id: 1,
isOrganization: true,
@@ -48,7 +49,7 @@ beforeEach(() => {
id: userId,
email: "test@example.com",
},
]);
] as any);
});
afterEach(() => {
+12 -4
View File
@@ -11,9 +11,9 @@ import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBook
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { buildBooking, buildEventType, buildWebhook, buildUser } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
import { prisma } from "@calcom/prisma";
import type { Booking } from "@calcom/prisma/client";
import { CreationSource } from "@calcom/prisma/enums";
import { CreationSource, BookingStatus } from "@calcom/prisma/enums";
import handler from "../../../pages/api/bookings/_post";
@@ -303,6 +303,7 @@ describe("POST /api/bookings", () => {
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
profile: { organizationId: null },
hosts: [],
users: [buildUser()],
@@ -340,6 +341,7 @@ describe("POST /api/bookings", () => {
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
profile: { organizationId: null },
hosts: [],
users: [buildUser()],
@@ -376,6 +378,7 @@ describe("POST /api/bookings", () => {
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
...buildEventType({ profileId: null }),
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
profile: { organizationId: null },
hosts: [],
users: [buildUser()],
@@ -417,6 +420,7 @@ describe("POST /api/bookings", () => {
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
...buildEventType({ profileId: null, length: 15 }),
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
profile: { organizationId: null },
hosts: [],
users: [buildUser()],
@@ -468,7 +472,7 @@ describe("POST /api/bookings", () => {
prismaMock.booking.findUnique.mockResolvedValue({
...originalBooking,
status: "cancelled",
status: "CANCELLED",
});
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
@@ -492,6 +496,7 @@ describe("POST /api/bookings", () => {
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
...buildEventType({ profileId: null, length: 15 }),
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
profile: { organizationId: null },
hosts: [],
users: [buildUser()],
@@ -532,7 +537,7 @@ describe("POST /api/bookings", () => {
const previousBooking = await prisma.booking.findUnique({
where: { uid: "original-booking-uid" },
});
expect(previousBooking?.status).toBe("cancelled");
expect(previousBooking?.status).toBe(BookingStatus.CANCELLED);
});
test("Creates source as api_v1", async () => {
@@ -556,6 +561,7 @@ describe("POST /api/bookings", () => {
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
...buildEventType({ profileId: null, length: 15 }),
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
profile: { organizationId: null },
hosts: [],
users: [buildUser()],
@@ -627,6 +633,7 @@ describe("POST /api/bookings", () => {
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
...buildEventType({ profileId: null, length: 15 }),
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
profile: { organizationId: null },
hosts: [],
users: [buildUser()],
@@ -684,6 +691,7 @@ describe("POST /api/bookings", () => {
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue({
...buildEventType({ recurringEvent: { freq: 2, count: 12, interval: 1 } }),
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
profile: { organizationId: null },
hosts: [],
users: [buildUser()],
@@ -50,6 +50,7 @@ describe("DELETE /api/event-types/[id]", () => {
// Mocking team.findUnique
prismaMock.team.findUnique.mockResolvedValue({
id: teamId,
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
members: [
{ userId: memberUser, role: MembershipRole.MEMBER, teamId: teamId },
{ userId: adminUser, role: MembershipRole.ADMIN, teamId: teamId },
@@ -84,6 +84,7 @@ describe("GET /api/event-types/[id]", () => {
prismaMock.team.findFirst.mockResolvedValue({
id: teamId,
// @ts-expect-error requires mockDeep which will be introduced in the Prisma 6.7.0 upgrade, ignore for now.
members: [
{
userId,
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.344",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.350",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -1,6 +1,7 @@
import { BookingsController_2024_04_15 } from "@/ee/bookings/2024-04-15/controllers/bookings.controller";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
@@ -41,6 +42,7 @@ import { Module } from "@nestjs/common";
OAuthClientUsersService,
PlatformBookingsService,
CalendarsService,
CalendarsCacheService,
CredentialsRepository,
AppsRepository,
CalendarsRepository,
@@ -10,6 +10,7 @@ import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
@@ -71,6 +72,7 @@ import { Module } from "@nestjs/common";
ApiKeysRepository,
PlatformBookingsService,
CalendarsService,
CalendarsCacheService,
CredentialsRepository,
AppsRepository,
CalendarsRepository,
@@ -117,6 +117,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
slug: seatedEventTypeSlug,
length: 60,
seatsPerTimeSlot: 3,
seatsShowAttendees: true,
},
user.id
);
@@ -53,8 +53,10 @@ describe("Bookings Endpoints 2024-08-13", () => {
let apiKeyString: string;
let seatedEventTypeId: number;
let seatedEventTypeIdAttendeesDisabledId: number;
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
const seatedEventSlugAttendeesDisabled = `seated-bookings-event-type-attendees-disabled-${randomString()}`;
let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13;
let createdSeatedBooking2: CreateSeatedBookingOutput_2024_08_13;
@@ -121,6 +123,28 @@ describe("Bookings Endpoints 2024-08-13", () => {
);
seatedEventTypeId = seatedEvent.id;
const seatedEventAttendeesDisabled = await eventTypesRepositoryFixture.create(
{
title: `seated-bookings-2024-08-13-event-type-attendees-disabled-${randomString()}`,
slug: seatedEventSlugAttendeesDisabled,
length: 60,
seatsPerTimeSlot: 5,
seatsShowAttendees: false,
seatsShowAvailabilityCount: false,
locations: [{ type: "inPerson", address: "via 10, rome, italy" }],
metadata: {
disableStandardEmails: {
all: {
attendee: true,
host: true,
},
},
},
},
user.id
);
seatedEventTypeIdAttendeesDisabledId = seatedEventAttendeesDisabled.id;
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -414,6 +438,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
it("should book an event type with attendees disabled", async () => {
const body: CreateBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
eventTypeId: seatedEventTypeIdAttendeesDisabledId,
attendee: {
name: nameAttendeeOne,
email: emailAttendeeOne,
timeZone: "Europe/Rome",
language: "it",
},
bookingFieldsResponses: {
codingLanguage: "TypeScript",
},
metadata: {
userId: "100",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: CreateBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true);
if (responseDataIsCreateSeatedBooking(responseBody.data)) {
const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data;
expect(data.seatUid).toBeDefined();
expect(data.id).toBeDefined();
expect(data.uid).toBeDefined();
expect(data.hosts[0].id).toEqual(user.id);
expect(data.status).toEqual("accepted");
expect(data.start).toEqual(body.start);
expect(data.end).toEqual(
DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO()
);
expect(data.duration).toEqual(60);
expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId);
expect(data.eventType).toEqual({
id: seatedEventTypeIdAttendeesDisabledId,
slug: seatedEventSlugAttendeesDisabled,
});
expect(data.attendees.length).toEqual(0);
expect(data.location).toBeDefined();
expect(data.absentHost).toEqual(false);
} else {
throw new Error(
"Invalid response data - expected seated booking but received non array response"
);
}
});
});
describe("cancel seated booking", () => {
describe("cancel seated booking as attendee", () => {
it("should cancel seated booking", async () => {
@@ -576,10 +576,13 @@ export class BookingsService_2024_08_13 {
return this.outputService.getOutputRecurringBooking(booking);
}
if (isRecurring && isSeated) {
return this.outputService.getOutputRecurringSeatedBooking(booking);
return this.outputService.getOutputRecurringSeatedBooking(
booking,
!!booking.eventType?.seatsShowAttendees
);
}
if (isSeated) {
return this.outputService.getOutputSeatedBooking(booking);
return this.outputService.getOutputSeatedBooking(booking, !!booking.eventType?.seatsShowAttendees);
}
return this.outputService.getOutputBooking(booking);
}
@@ -591,7 +594,10 @@ export class BookingsService_2024_08_13 {
const ids = recurringBooking.map((booking) => booking.id);
const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot;
if (isRecurringSeated) {
return this.outputService.getOutputRecurringSeatedBookings(ids);
return this.outputService.getOutputRecurringSeatedBookings(
ids,
!!recurringBooking[0].eventType?.seatsShowAttendees
);
}
return this.outputService.getOutputRecurringBookings(ids);
@@ -657,9 +663,19 @@ export class BookingsService_2024_08_13 {
if (isRecurring && !isSeated) {
formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted));
} else if (isRecurring && isSeated) {
formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted));
formattedBookings.push(
this.outputService.getOutputRecurringSeatedBooking(
formatted,
!!formatted.eventType?.seatsShowAttendees
)
);
} else if (isSeated) {
formattedBookings.push(await this.outputService.getOutputSeatedBooking(formatted));
formattedBookings.push(
await this.outputService.getOutputSeatedBooking(
formatted,
!!formatted.eventType?.seatsShowAttendees
)
);
} else {
formattedBookings.push(await this.outputService.getOutputBooking(formatted));
}
@@ -68,6 +68,7 @@ type DatabaseBooking = Booking & {
eventType: {
id: number;
slug: string;
seatsShowAttendees?: boolean | null;
} | null;
attendees: {
name: string;
@@ -167,6 +168,7 @@ export class OutputBookingsService_2024_08_13 {
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { videoCallUrl, ...userDefinedMetadata } = databaseMetadata;
return userDefinedMetadata;
@@ -266,11 +268,14 @@ export class OutputBookingsService_2024_08_13 {
databaseBooking: DatabaseBooking,
seatUid: string
): Promise<CreateSeatedBookingOutput_2024_08_13> {
const getSeatedBookingOutput = await this.getOutputSeatedBooking(databaseBooking);
const getSeatedBookingOutput = await this.getOutputSeatedBooking(
databaseBooking,
!!databaseBooking.eventType?.seatsShowAttendees
);
return { ...getSeatedBookingOutput, seatUid };
}
async getOutputSeatedBooking(databaseBooking: DatabaseBooking) {
async getOutputSeatedBooking(databaseBooking: DatabaseBooking, showAttendees: boolean) {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
@@ -317,41 +322,43 @@ export class OutputBookingsService_2024_08_13 {
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
parsed.attendees = databaseBooking.attendees.map((attendee) => {
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData,
false
);
parsed.attendees = showAttendees
? databaseBooking.attendees.map((attendee) => {
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData,
false
);
const attendeeData = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
seatUid: attendee.bookingSeat?.referenceUid,
bookingFieldsResponses: {},
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata,
false
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
const attendeeData = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
seatUid: attendee.bookingSeat?.referenceUid,
bookingFieldsResponses: {},
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata,
false
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
return attendeeParsed;
});
return attendeeParsed;
})
: [];
return parsed;
}
async getOutputRecurringSeatedBookings(bookingsIds: number[]) {
async getOutputRecurringSeatedBookings(bookingsIds: number[], showAttendees: boolean) {
const transformed = [];
for (const bookingId of bookingsIds) {
@@ -361,7 +368,7 @@ export class OutputBookingsService_2024_08_13 {
throw new Error(`Booking with id=${bookingId} was not found in the database`);
}
transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking));
transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking, showAttendees));
}
return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
@@ -386,11 +393,14 @@ export class OutputBookingsService_2024_08_13 {
databaseBooking: DatabaseBooking,
seatUid: string
): CreateRecurringSeatedBookingOutput_2024_08_13 {
const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(databaseBooking);
const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(
databaseBooking,
!!databaseBooking.eventType?.seatsShowAttendees
);
return { ...getRecurringSeatedBookingOutput, seatUid };
}
getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking) {
getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking, showAttendees: boolean) {
const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString());
const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString());
const duration = dateEnd.diff(dateStart, "minutes").minutes;
@@ -430,35 +440,37 @@ export class OutputBookingsService_2024_08_13 {
});
// note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually
parsed.attendees = databaseBooking.attendees.map((attendee) => {
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData,
false
);
parsed.attendees = showAttendees
? databaseBooking.attendees.map((attendee) => {
const { responses } = safeParse(
seatedBookingDataSchema,
attendee.bookingSeat?.data,
defaultSeatedBookingData,
false
);
const attendeeData = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
seatUid: attendee.bookingSeat?.referenceUid,
bookingFieldsResponses: {},
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata,
false
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
return attendeeParsed;
});
const attendeeData = {
name: attendee.name,
email: attendee.email,
timeZone: attendee.timeZone,
language: attendee.locale,
absent: !!attendee.noShow,
seatUid: attendee.bookingSeat?.referenceUid,
bookingFieldsResponses: {},
};
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
attendeeParsed.bookingFieldsResponses = responses || {};
attendeeParsed.metadata = safeParse(
seatedBookingMetadataSchema,
attendee.bookingSeat?.metadata,
defaultSeatedBookingMetadata,
false
);
// note(Lauris): as of now email is not returned for privacy
delete attendeeParsed.bookingFieldsResponses.email;
return attendeeParsed;
})
: [];
return parsed;
}
@@ -3,6 +3,7 @@ import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
@@ -10,16 +11,18 @@ import { OutlookService } from "@/ee/calendars/services/outlook.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, UsersModule, TokensModule],
imports: [PrismaModule, UsersModule, TokensModule, RedisModule],
providers: [
CredentialsRepository,
CalendarsService,
CalendarsCacheService,
OutlookService,
GoogleCalendarService,
AppleCalendarService,
@@ -9,6 +9,7 @@ import {
DeletedCalendarCredentialsOutputDto,
} from "@/ee/calendars/outputs/delete-calendar-credentials.output";
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService } from "@/ee/calendars/services/gcal.service";
import { IcsFeedService } from "@/ee/calendars/services/ics-feed.service";
@@ -79,6 +80,7 @@ const calendarStateSchema = z.object({
export class CalendarsController {
constructor(
private readonly calendarsService: CalendarsService,
private readonly calendarsCacheService: CalendarsCacheService,
private readonly outlookService: OutlookService,
private readonly googleCalendarService: GoogleCalendarService,
private readonly appleCalendarService: AppleCalendarService,
@@ -307,6 +309,8 @@ export class CalendarsController {
credentialId
);
this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(user.id);
return {
status: SUCCESS_STATUS,
data: plainToClass(
@@ -0,0 +1,36 @@
import { RedisService } from "@/modules/redis/redis.service";
import { Injectable } from "@nestjs/common";
import type { getConnectedDestinationCalendarsAndEnsureDefaultsInDb } from "@calcom/platform-libraries";
export const REDIS_CALENDARS_CACHE_KEY = (userId: number) => `apiv2:user:${userId}:calendars`;
export const CALENDARS_CACHE_TTL_MS = 10_000;
type ConnectedDestinationCalendars = Awaited<
ReturnType<typeof getConnectedDestinationCalendarsAndEnsureDefaultsInDb>
>;
@Injectable()
export class CalendarsCacheService {
constructor(private readonly redisService: RedisService) {}
async deleteConnectedAndDestinationCalendarsCache(userId: number) {
await this.redisService.del(REDIS_CALENDARS_CACHE_KEY(userId));
}
async getConnectedAndDestinationCalendarsCache(userId: number) {
const cachedResult = await this.redisService.get<ConnectedDestinationCalendars>(
REDIS_CALENDARS_CACHE_KEY(userId)
);
return cachedResult;
}
async setConnectedAndDestinationCalendarsCache(
userId: number,
calendars: ConnectedDestinationCalendars
): Promise<void> {
await this.redisService.set<ConnectedDestinationCalendars>(REDIS_CALENDARS_CACHE_KEY(userId), calendars, {
ttl: CALENDARS_CACHE_TTL_MS,
});
}
}
@@ -1,4 +1,5 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import {
CredentialsRepository,
@@ -37,7 +38,8 @@ export class CalendarsService {
private readonly appsRepository: AppsRepository,
private readonly calendarsRepository: CalendarsRepository,
private readonly dbWrite: PrismaWriteService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly calendarsCacheService: CalendarsCacheService
) {}
private buildNonDelegationCredentials<TCredential>(credentials: TCredential[]) {
@@ -52,11 +54,17 @@ export class CalendarsService {
}
async getCalendars(userId: number) {
const cachedResult = await this.calendarsCacheService.getConnectedAndDestinationCalendarsCache(userId);
if (cachedResult) {
return cachedResult;
}
const userWithCalendars = await this.usersRepository.findByIdWithCalendars(userId);
if (!userWithCalendars) {
throw new NotFoundException("User not found");
}
return getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
const result = await getConnectedDestinationCalendarsAndEnsureDefaultsInDb({
user: {
...userWithCalendars,
allSelectedCalendars: userWithCalendars.selectedCalendars,
@@ -68,6 +76,10 @@ export class CalendarsService {
eventTypeId: null,
prisma: this.dbWrite.prisma as unknown as PrismaClient,
});
console.log("saving cache", JSON.stringify(result));
await this.calendarsCacheService.setConnectedAndDestinationCalendarsCache(userId, result);
return result;
}
async getBusyTimes(
@@ -187,6 +199,8 @@ export class CalendarsService {
userId,
calendarType
);
await this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(userId);
}
async checkCalendarCredentialValidity(userId: number, credentialId: number, type: string) {
@@ -1,7 +1,9 @@
import { ICSFeedCalendarApp } from "@/ee/calendars/calendars.interface";
import { CreateIcsFeedOutputResponseDto } from "@/ee/calendars/input/create-ics.output";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { RedisService } from "@/modules/redis/redis.service";
import { BadRequestException, UnauthorizedException, Logger } from "@nestjs/common";
import { Injectable } from "@nestjs/common";
@@ -13,7 +15,9 @@ import { IcsFeedCalendarService } from "@calcom/platform-libraries/app-store";
export class IcsFeedService implements ICSFeedCalendarApp {
constructor(
private readonly calendarsService: CalendarsService,
private readonly credentialRepository: CredentialsRepository
private readonly calendarsCacheService: CalendarsCacheService,
private readonly credentialRepository: CredentialsRepository,
private readonly redisService: RedisService
) {}
private logger = new Logger("IcsFeedService");
@@ -58,6 +62,9 @@ export class IcsFeedService implements ICSFeedCalendarApp {
data.key,
userId
);
await this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(userId);
return {
status: SUCCESS_STATUS,
data: {
@@ -12,6 +12,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User, Schedule, EventType } from "@prisma/client";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
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";
@@ -88,11 +89,16 @@ describe("Event types Endpoints", () => {
let schedulesRepostoryFixture: SchedulesRepositoryFixture;
let profileRepositoryFixture: ProfileRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let apiKeyString: string;
let apiKeyOrgUser: string;
const userEmail = `event-types-2024-06-14-user-${randomString()}@api.com`;
const falseTestEmail = `event-types-2024-06-14-false-user-${randomString()}@api.com`;
const name = `event-types-2024-06-14-user-${randomString()}`;
const username = name;
let eventType: EventTypeOutput_2024_06_14;
let hiddenEventType: EventTypeOutput_2024_06_14;
let user: User;
let orgUser: User;
let falseTestUser: User;
@@ -165,13 +171,10 @@ describe("Event types Endpoints", () => {
};
beforeAll(async () => {
const moduleRef = await withApiAuth(
userEmail,
Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
)
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule, EventTypesModule_2024_06_14, TokensModule],
})
.overrideGuard(PermissionsGuard)
.useValue({
canActivate: () => true,
@@ -198,12 +201,20 @@ describe("Event types Endpoints", () => {
name,
username,
});
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null);
apiKeyString = `cal_test_${keyString}`;
orgUser = await userRepositoryFixture.create({
email: `event-types-2024-06-14-org-user-${randomString()}@example.com`,
name: `event-types-2024-06-14-org-user-${randomString()}`,
username: `event-types-2024-06-14-org-user-${randomString()}`,
});
const { keyString: orgUserApiKeyString } = await apiKeysRepositoryFixture.createApiKey(
orgUser.id,
null
);
apiKeyOrgUser = `cal_test_${orgUserApiKeyString}`;
profileRepositoryFixture.create({
uid: `usr-${orgUser.id}`,
@@ -334,6 +345,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(404);
});
@@ -370,6 +382,7 @@ describe("Event types Endpoints", () => {
const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(400);
@@ -395,6 +408,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(400);
});
@@ -504,6 +518,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201)
.then(async (response) => {
@@ -576,21 +591,52 @@ describe("Event types Endpoints", () => {
});
});
it("should create a hidden event type", async () => {
const body: CreateEventTypeInput_2024_06_14 = {
title: "Coding class hidden",
slug: "coding-class-hidden",
description: "Let's learn how to code like a pro.",
lengthInMinutes: 60,
locations: [
{
type: "integration",
integration: "cal-video",
},
],
hidden: true,
};
const response = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
const createdEventType = responseBody.data;
expect(createdEventType).toHaveProperty("id");
expect(createdEventType.title).toEqual(body.title);
expect(createdEventType.hidden).toEqual(body.hidden);
expect(createdEventType.ownerId).toEqual(user.id);
hiddenEventType = responseBody.data;
});
it(`/GET/event-types by username`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${username}`)
.get(`/api/v2/event-types?username=${user.username}`)
.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`)
.set("Authorization", `Bearer ${apiKeyString}`)
.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?.length).toEqual(2);
const fetchedEventType = responseBody.data?.[0];
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
const fetchedHiddenEventType = responseBody.data?.find((et) => et.id === hiddenEventType.id);
expect(fetchedEventType?.id).toEqual(eventType.id);
expect(fetchedEventType?.title).toEqual(eventType.title);
@@ -602,33 +648,149 @@ describe("Event types Endpoints", () => {
eventType.bookingFields.sort(orderBySlug).filter((f) => ("hidden" in f ? !f?.hidden : true))
);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType.bookerLayouts).toEqual(eventType.bookerLayouts);
expect(fetchedEventType.confirmationPolicy).toEqual(eventType.confirmationPolicy);
expect(fetchedEventType.recurrence).toEqual(eventType.recurrence);
expect(fetchedEventType.customName).toEqual(eventType.customName);
expect(fetchedEventType.requiresBookerEmailVerification).toEqual(
expect(fetchedEventType?.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType?.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType?.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType?.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType?.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType?.bookerLayouts).toEqual(eventType.bookerLayouts);
expect(fetchedEventType?.confirmationPolicy).toEqual(eventType.confirmationPolicy);
expect(fetchedEventType?.recurrence).toEqual(eventType.recurrence);
expect(fetchedEventType?.customName).toEqual(eventType.customName);
expect(fetchedEventType?.requiresBookerEmailVerification).toEqual(
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType.lockTimeZoneToggleOnBookingPage).toEqual(
expect(fetchedEventType?.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType?.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType?.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType?.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType.color).toEqual(eventType.color);
expect(fetchedEventType?.color).toEqual(eventType.color);
expect(fetchedEventType?.hidden).toEqual(false);
expect(fetchedHiddenEventType?.id).toEqual(hiddenEventType.id);
expect(fetchedHiddenEventType?.hidden).toEqual(true);
});
it(`/GET/event-types by username should not return hidden event type if auth of non event type owner provided`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyOrgUser}`)
.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);
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
expect(fetchedEventType?.id).toEqual(eventType.id);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType?.hidden).toEqual(false);
});
it(`/GET/event-types by username should not return hidden event type if no auth provided`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.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);
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
expect(fetchedEventType?.id).toEqual(eventType.id);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType?.hidden).toEqual(false);
});
it(`/GET/event-types by username and eventSlug`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}&eventSlug=${eventType.slug}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.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);
const fetchedEventType = responseBody.data?.find((et) => et.id === eventType.id);
expect(fetchedEventType?.id).toEqual(eventType.id);
expect(fetchedEventType?.title).toEqual(eventType.title);
expect(fetchedEventType?.description).toEqual(eventType.description);
expect(fetchedEventType?.lengthInMinutes).toEqual(eventType.lengthInMinutes);
expect(fetchedEventType?.lengthInMinutesOptions).toEqual(eventType.lengthInMinutesOptions);
expect(fetchedEventType?.locations).toEqual(eventType.locations);
expect(fetchedEventType?.bookingFields.sort(orderBySlug)).toEqual(
eventType.bookingFields.sort(orderBySlug).filter((f) => ("hidden" in f ? !f?.hidden : true))
);
expect(fetchedEventType?.ownerId).toEqual(user.id);
expect(fetchedEventType?.bookingLimitsCount).toEqual(eventType.bookingLimitsCount);
expect(fetchedEventType?.onlyShowFirstAvailableSlot).toEqual(eventType.onlyShowFirstAvailableSlot);
expect(fetchedEventType?.bookingLimitsDuration).toEqual(eventType.bookingLimitsDuration);
expect(fetchedEventType?.offsetStart).toEqual(eventType.offsetStart);
expect(fetchedEventType?.bookingWindow).toEqual(eventType.bookingWindow);
expect(fetchedEventType?.bookerLayouts).toEqual(eventType.bookerLayouts);
expect(fetchedEventType?.confirmationPolicy).toEqual(eventType.confirmationPolicy);
expect(fetchedEventType?.recurrence).toEqual(eventType.recurrence);
expect(fetchedEventType?.customName).toEqual(eventType.customName);
expect(fetchedEventType?.requiresBookerEmailVerification).toEqual(
eventType.requiresBookerEmailVerification
);
expect(fetchedEventType?.hideCalendarNotes).toEqual(eventType.hideCalendarNotes);
expect(fetchedEventType?.hideCalendarEventDetails).toEqual(eventType.hideCalendarEventDetails);
expect(fetchedEventType?.hideOrganizerEmail).toEqual(eventType.hideOrganizerEmail);
expect(fetchedEventType?.lockTimeZoneToggleOnBookingPage).toEqual(
eventType.lockTimeZoneToggleOnBookingPage
);
expect(fetchedEventType?.color).toEqual(eventType.color);
expect(fetchedEventType?.hidden).toEqual(false);
});
it(`/GET/event-types by username and eventSlug should not return hidden event type if auth of non event type owner provided`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}&eventSlug=${hiddenEventType.slug}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyOrgUser}`)
.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(0);
});
it(`/GET/event-types by username and eventSlug should not return hidden event type if no auth provided`, async () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${user.username}&eventSlug=${hiddenEventType.slug}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.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(0);
});
it(`/GET/event-types by username and orgSlug`, async () => {
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`)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
@@ -647,8 +809,7 @@ describe("Event types Endpoints", () => {
`/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`)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
@@ -663,8 +824,7 @@ describe("Event types Endpoints", () => {
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`)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
@@ -705,6 +865,7 @@ describe("Event types Endpoints", () => {
await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(400);
});
@@ -732,6 +893,7 @@ describe("Event types Endpoints", () => {
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201);
@@ -748,6 +910,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set("Authorization", `Bearer ${apiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
seats: {
@@ -779,6 +942,7 @@ describe("Event types Endpoints", () => {
await request(app.getHttpServer())
.post("/api/v2/event-types")
.set("Authorization", `Bearer ${apiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
@@ -799,6 +963,7 @@ describe("Event types Endpoints", () => {
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set("Authorization", `Bearer ${apiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
@@ -817,6 +982,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send({
seats: {
seatsPerTimeSlot: 4,
@@ -844,6 +1010,7 @@ describe("Event types Endpoints", () => {
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(201);
@@ -860,6 +1027,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set("Authorization", `Bearer ${apiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
locations: [
@@ -897,6 +1065,7 @@ describe("Event types Endpoints", () => {
await request(app.getHttpServer())
.post("/api/v2/event-types")
.set("Authorization", `Bearer ${apiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
@@ -918,6 +1087,7 @@ describe("Event types Endpoints", () => {
const createResponse = await request(app.getHttpServer())
.post("/api/v2/event-types")
.set("Authorization", `Bearer ${apiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(201);
@@ -935,6 +1105,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${createdEventType.id}`)
.set("Authorization", `Bearer ${apiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send({
confirmationPolicy: {
@@ -1034,6 +1205,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.send(body)
.expect(200)
.then(async (response) => {
@@ -1135,6 +1307,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.set("Authorization", `Bearer ${apiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(404);
@@ -1152,6 +1325,7 @@ describe("Event types Endpoints", () => {
return request(app.getHttpServer())
.patch(`/api/v2/event-types/${eventType.id}`)
.set("Authorization", `Bearer ${apiKeyString}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.send(body)
.expect(400);
@@ -1161,8 +1335,7 @@ describe("Event types Endpoints", () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types/${eventType.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`)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
@@ -1203,8 +1376,7 @@ describe("Event types Endpoints", () => {
const response = await request(app.getHttpServer())
.get(`/api/v2/event-types?username=${username}&eventSlug=${eventType.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`)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14[]> = response.body;
@@ -1245,13 +1417,16 @@ describe("Event types Endpoints", () => {
await request(app.getHttpServer())
.get(`/api/v2/event-types/1000`)
.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`)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(404);
});
it("should delete event type", async () => {
return request(app.getHttpServer()).delete(`/api/v2/event-types/${eventType.id}`).expect(200);
return request(app.getHttpServer())
.delete(`/api/v2/event-types/${eventType.id}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
.set("Authorization", `Bearer ${apiKeyString}`)
.expect(200);
});
afterAll(async () => {
@@ -6,11 +6,22 @@ import { UpdateEventTypeOutput_2024_06_14 } from "@/ee/event-types/event-types_2
import { EventTypeResponseTransformPipe } from "@/ee/event-types/event-types_2024_06_14/pipes/event-type-response.transformer";
import { EventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/event-types.service";
import { InputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/input-event-types.service";
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
import { VERSION_2024_06_14_VALUE } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import {
API_KEY_OR_ACCESS_TOKEN_HEADER,
OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER,
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
} from "@/lib/docs/headers";
import {
AuthOptionalUser,
GetOptionalUser,
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
@@ -40,7 +51,6 @@ import {
UpdateEventTypeInput_2024_06_14,
GetEventTypesQuery_2024_06_14,
CreateEventTypeInput_2024_06_14,
EventTypeOutput_2024_06_14,
} from "@calcom/platform-types";
@Controller({
@@ -62,7 +72,8 @@ export class EventTypesController_2024_06_14 {
constructor(
private readonly eventTypesService: EventTypesService_2024_06_14,
private readonly inputEventTypesService: InputEventTypesService_2024_06_14,
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe
private readonly eventTypeResponseTransformPipe: EventTypeResponseTransformPipe,
private readonly outputEventTypesService: OutputEventTypesService_2024_06_14
) {}
@Post("/")
@@ -109,30 +120,23 @@ export class EventTypesController_2024_06_14 {
}
@Get("/")
@ApiOperation({ summary: "Get all event types" })
@ApiOperation({
summary: "Get all event types",
description:
"Hidden event types are returned only if authentication is provided and it belongs to the event type owner.",
})
@UseGuards(OptionalApiAuthGuard)
@ApiHeader(OPTIONAL_X_CAL_CLIENT_ID_HEADER)
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
async getEventTypes(
@Query() queryParams: GetEventTypesQuery_2024_06_14
@Query() queryParams: GetEventTypesQuery_2024_06_14,
@GetOptionalUser() authUser: AuthOptionalUser
): Promise<GetEventTypesOutput_2024_06_14> {
const eventTypes = await this.eventTypesService.getEventTypes(queryParams);
if (!eventTypes || eventTypes.length === 0) {
throw new NotFoundException(`Event types not found`);
}
const eventTypes = await this.eventTypesService.getEventTypes(queryParams, authUser);
const eventTypesFormatted = this.eventTypeResponseTransformPipe.transform(eventTypes);
const eventTypesWithoutHiddenFields = eventTypesFormatted.map((eventType) => {
return {
...eventType,
bookingFields: Array.isArray(eventType?.bookingFields)
? eventType?.bookingFields
.map((field) => {
if ("hidden" in field) {
return field.hidden !== true ? field : null;
}
return field;
})
.filter((f) => f)
: [],
};
}) as EventTypeOutput_2024_06_14[];
const eventTypesWithoutHiddenFields =
this.outputEventTypesService.getResponseEventTypesWithoutHiddenFields(eventTypesFormatted);
return {
status: SUCCESS_STATUS,
@@ -1,4 +1,5 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesController_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/controllers/event-types.controller";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
@@ -11,6 +12,7 @@ import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { SelectedCalendarsModule } from "@/modules/selected-calendars/selected-calendars.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersService } from "@/modules/users/services/users.service";
@@ -18,7 +20,7 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule],
imports: [PrismaModule, MembershipsModule, TokensModule, SelectedCalendarsModule, RedisModule],
providers: [
EventTypesRepository_2024_06_14,
EventTypesService_2024_06_14,
@@ -29,6 +31,7 @@ import { Module } from "@nestjs/common";
SchedulesRepository_2024_06_11,
EventTypeResponseTransformPipe,
CalendarsService,
CalendarsCacheService,
CredentialsRepository,
AppsRepository,
CalendarsRepository,
@@ -75,6 +75,16 @@ export class EventTypesRepository_2024_06_14 {
});
}
async getUserEventTypesPublic(userId: number) {
return this.dbRead.prisma.eventType.findMany({
where: {
userId,
hidden: false,
},
include: { users: true, schedule: true, destinationCalendar: true },
});
}
async getEventTypeById(eventTypeId: number) {
return this.dbRead.prisma.eventType.findUnique({
where: { id: eventTypeId },
@@ -3,6 +3,7 @@ import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_20
import { InputEventTransformed_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/transformed";
import { SystemField, CustomField } from "@/ee/event-types/event-types_2024_06_14/transformers";
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
@@ -38,6 +39,7 @@ export class EventTypesService_2024_06_14 {
}
await this.checkCanCreateEventType(user.id, body);
const eventTypeUser = await this.getUserToCreateEvent(user);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { destinationCalendar: _destinationCalendar, ...rest } = body;
const { eventType: eventTypeCreated } = await createEventType({
@@ -93,34 +95,47 @@ export class EventTypesService_2024_06_14 {
}
}
async getEventTypeByUsernameAndSlug(
username: string,
eventTypeSlug: string,
orgSlug?: string,
orgId?: number
) {
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
async getEventTypeByUsernameAndSlug(params: {
username: string;
eventTypeSlug: string;
orgSlug?: string;
orgId?: number;
authUser?: AuthOptionalUser;
}) {
const user = await this.usersRepository.findByUsername(params.username, params.orgSlug, params.orgId);
if (!user) {
return null;
}
const eventType = await this.eventTypesRepository.getUserEventTypeBySlug(user.id, eventTypeSlug);
const eventType = await this.eventTypesRepository.getUserEventTypeBySlug(user.id, params.eventTypeSlug);
if (!eventType) {
return null;
}
if (eventType.hidden && params.authUser?.id !== user.id) {
return null;
}
return {
ownerId: user.id,
...eventType,
};
}
async getEventTypesByUsername(username: string, orgSlug?: string, orgId?: number) {
const user = await this.usersRepository.findByUsername(username, orgSlug, orgId);
async getEventTypesByUsername(params: {
username: string;
orgSlug?: string;
orgId?: number;
authUser?: AuthOptionalUser;
}) {
const user = await this.usersRepository.findByUsername(params.username, params.orgSlug, params.orgId);
if (!user) {
return [];
}
if (params.authUser?.id !== user.id) {
return await this.getUserEventTypesPublic(user.id);
}
return await this.getUserEventTypes(user.id);
}
@@ -172,6 +187,14 @@ export class EventTypesService_2024_06_14 {
});
}
async getUserEventTypesPublic(userId: number) {
const eventTypes = await this.eventTypesRepository.getUserEventTypesPublic(userId);
return eventTypes.map((eventType) => {
return { ownerId: userId, ...eventType };
});
}
async getEventTypesPublicByUsername(username: string): Promise<EventTypesPublic> {
const user = await this.usersRepository.findByUsername(username);
if (!user) {
@@ -181,15 +204,26 @@ export class EventTypesService_2024_06_14 {
return await getEventTypesPublic(user.id);
}
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14) {
async getEventTypes(queryParams: GetEventTypesQuery_2024_06_14, authUser?: AuthOptionalUser) {
const { username, eventSlug, usernames, orgSlug, orgId } = queryParams;
if (username && eventSlug) {
const eventType = await this.getEventTypeByUsernameAndSlug(username, eventSlug, orgSlug, orgId);
const eventType = await this.getEventTypeByUsernameAndSlug({
username,
eventTypeSlug: eventSlug,
orgSlug,
orgId,
authUser,
});
return eventType ? [eventType] : [];
}
if (username) {
return await this.getEventTypesByUsername(username, orgSlug, orgId);
return await this.getEventTypesByUsername({
username,
orgSlug,
orgId,
authUser,
});
}
if (usernames) {
@@ -31,6 +31,7 @@ import {
EventTypeOutput_2024_06_14,
OutputUnknownLocation_2024_06_14,
OutputUnknownBookingField_2024_06_14,
OutputBookingField_2024_06_14,
} from "@calcom/platform-types";
type EventTypeRelations = {
@@ -253,7 +254,7 @@ export class OutputEventTypesService_2024_06_14 {
type: "unknown",
slug: "unknown",
bookingField: JSON.stringify(bookingField),
});
} satisfies OutputUnknownBookingField_2024_06_14);
}
}
@@ -344,4 +345,26 @@ export class OutputEventTypesService_2024_06_14 {
seatsShowAvailabilityCount: !!seatsShowAvailabilityCount,
});
}
getResponseEventTypesWithoutHiddenFields(
eventTypes: EventTypeOutput_2024_06_14[]
): EventTypeOutput_2024_06_14[] {
return eventTypes.map((eventType) => this.getResponseEventTypeWithoutHiddenFields(eventType));
}
getResponseEventTypeWithoutHiddenFields(eventType: EventTypeOutput_2024_06_14): EventTypeOutput_2024_06_14 {
if (!Array.isArray(eventType?.bookingFields) || eventType.bookingFields.length === 0) return eventType;
const visibleBookingFields: OutputBookingField_2024_06_14[] = [];
for (const bookingField of eventType.bookingFields) {
if ("hidden" in bookingField && bookingField.hidden === true) {
continue;
}
visibleBookingFields.push(bookingField);
}
return {
...eventType,
bookingFields: visibleBookingFields,
};
}
}
+4 -1
View File
@@ -1,4 +1,5 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GcalController } from "@/ee/gcal/gcal.controller";
import { AppsRepository } from "@/modules/apps/apps.repository";
@@ -6,6 +7,7 @@ import { GCalService } from "@/modules/apps/services/gcal.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersRepository } from "@/modules/users/users.repository";
@@ -13,7 +15,7 @@ import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
@Module({
imports: [PrismaModule, TokensModule, OAuthClientModule],
imports: [PrismaModule, TokensModule, OAuthClientModule, RedisModule],
providers: [
AppsRepository,
ConfigService,
@@ -21,6 +23,7 @@ import { ConfigService } from "@nestjs/config";
SelectedCalendarsRepository,
GCalService,
CalendarsService,
CalendarsCacheService,
UsersRepository,
CalendarsRepository,
],
@@ -17,6 +17,7 @@ import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/inde
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisService } from "@/modules/redis/redis.service";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@@ -36,6 +37,7 @@ import { Module } from "@nestjs/common";
SchedulesAtomsService,
VerificationAtomsService,
RedisService,
TeamsRepository,
],
exports: [EventTypesAtomService],
controllers: [
@@ -64,4 +64,47 @@ export class AtomsRepository {
return userTeams;
}
async getSecondaryEmails(userId: number) {
return await this.dbRead.prisma.secondaryEmail.findMany({
where: {
userId,
emailVerified: {
not: null,
},
},
});
}
async getExistingSecondaryEmailByUserAndEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
userId_email: { userId, email },
},
});
return existingSecondaryEmailRecord?.email;
}
async getExistingSecondaryEmail(email: string) {
const existingSecondaryEmailRecord = await this.dbRead.prisma.secondaryEmail.findUnique({
where: {
email,
},
});
return existingSecondaryEmailRecord?.email;
}
async addSecondaryEmail(userId: number, email: string) {
const existingSecondaryEmailRecord = await this.dbWrite.prisma.secondaryEmail.create({
data: {
userId,
email,
emailVerified: new Date(),
},
});
return existingSecondaryEmailRecord?.email;
}
}
@@ -1,8 +1,11 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { AddVerifiedEmailInput } from "@/modules/atoms/inputs/add-verified-email.input";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { GetVerifiedEmailsParams } from "@/modules/atoms/inputs/get-verified-emails-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { GetVerifiedEmailsOutput } from "@/modules/atoms/outputs/get-verified-emails-output";
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
@@ -105,4 +108,44 @@ export class AtomsVerificationController {
status: SUCCESS_STATUS,
};
}
@Get("/emails/verified-emails")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async getVerifiedEmails(
@Query() query: GetVerifiedEmailsParams,
@GetUser() user: UserWithProfile
): Promise<GetVerifiedEmailsOutput> {
const verifiedEmails = await this.verificationService.getVerifiedEmails({
userId: user.id,
userEmail: user.email,
teamId: query.teamId ? Number(query.teamId) : undefined,
});
return {
data: verifiedEmails,
status: SUCCESS_STATUS,
};
}
@Post("/emails/verified-emails")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async addVerifiedEmails(
@Body() body: AddVerifiedEmailInput,
@GetUser() user: UserWithProfile
): Promise<ApiResponse<{ emailVerified: boolean }>> {
const emailVerified = await this.verificationService.addVerifiedEmail({
userId: user.id,
existingPrimaryEmail: user.email,
email: body.email,
});
return {
data: { emailVerified },
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,8 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail } from "class-validator";
export class AddVerifiedEmailInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNumber, IsOptional, IsString, IsEmail } from "class-validator";
export class GetVerifiedEmailsParams {
@ApiPropertyOptional({ example: "12345" })
@IsOptional()
@IsString()
teamId?: string;
}
export class GetVerifiedEmailsInput {
@ApiProperty()
@IsNumber()
userId!: number;
@ApiProperty()
@IsEmail()
@IsString()
userEmail!: string;
@ApiPropertyOptional()
@IsOptional()
@IsNumber()
teamId?: number;
}
@@ -0,0 +1,17 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose } from "class-transformer";
import { IsString, IsArray } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class GetVerifiedEmailsOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@IsArray()
@IsString({ each: true })
@ApiProperty({ type: [String] })
readonly data!: string[];
}
@@ -1,6 +1,9 @@
import { AtomsRepository } from "@/modules/atoms/atoms.repository";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { GetVerifiedEmailsInput } from "@/modules/atoms/inputs/get-verified-emails-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
@@ -13,6 +16,11 @@ import {
@Injectable()
export class VerificationAtomsService {
constructor(
private readonly atomsRepository: AtomsRepository,
private readonly teamsRepository: TeamsRepository
) {}
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
return await checkEmailVerificationRequired(input);
}
@@ -61,4 +69,78 @@ export class VerificationAtomsService {
isVerifyingEmail: input.isVerifyingEmail,
});
}
async getVerifiedEmails(input: GetVerifiedEmailsInput): Promise<string[]> {
const { userId, userEmail, teamId } = input;
const userEmailWithoutOauthClientId = this.removeClientIdFromEmail(userEmail);
if (teamId) {
const verifiedEmails: string[] = [];
const teamMembers = await this.teamsRepository.getTeamMemberEmails(teamId);
if (teamMembers.length === 0) {
return verifiedEmails;
}
teamMembers.forEach((member) => {
const memberEmailWithoutOauthClientId = this.removeClientIdFromEmail(member.email);
verifiedEmails.push(memberEmailWithoutOauthClientId);
member.secondaryEmails.forEach((secondaryEmail) => {
verifiedEmails.push(this.removeClientIdFromEmail(secondaryEmail.email));
});
});
return verifiedEmails;
}
let verifiedEmails = [userEmailWithoutOauthClientId];
const secondaryEmails = await this.atomsRepository.getSecondaryEmails(userId);
verifiedEmails = verifiedEmails.concat(
secondaryEmails.map((secondaryEmail) => this.removeClientIdFromEmail(secondaryEmail.email))
);
return verifiedEmails;
}
async addVerifiedEmail({
userId,
existingPrimaryEmail,
email,
}: {
userId: number;
existingPrimaryEmail: string;
email: string;
}): Promise<boolean> {
const existingSecondaryEmail = await this.atomsRepository.getExistingSecondaryEmailByUserAndEmail(
userId,
email
);
const alreadyExistingEmail = await this.atomsRepository.getExistingSecondaryEmail(email);
if (alreadyExistingEmail) {
throw new BadRequestException("Email already exists");
}
if (existingPrimaryEmail === email || existingSecondaryEmail === email) {
return true;
}
await this.atomsRepository.addSecondaryEmail(userId, email);
return true;
}
removeClientIdFromEmail(email: string): string {
const [localPart, domain] = email.split("@");
const localPartSegments = localPart.split("+");
localPartSegments.pop();
const baseEmail = localPartSegments.join("+");
const normalizedEmail = `${baseEmail}@${domain}`;
return normalizedEmail;
}
}
@@ -1,5 +1,6 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
@@ -8,13 +9,14 @@ import { GoogleCalendarService } from "@/modules/cal-unified-calendars/services/
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { RedisModule } from "@/modules/redis/redis.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [TokensModule],
imports: [TokensModule, RedisModule],
providers: [
GCalService,
GoogleCalendarService,
@@ -22,6 +24,7 @@ import { Module } from "@nestjs/common";
BookingReferencesRepository_2024_08_13,
CredentialsRepository,
CalendarsService,
CalendarsCacheService,
SelectedCalendarsRepository,
PrismaReadService,
PrismaWriteService,
@@ -1,4 +1,5 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@@ -6,15 +7,17 @@ import { DestinationCalendarsController } from "@/modules/destination-calendars/
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
import { DestinationCalendarsService } from "@/modules/destination-calendars/services/destination-calendars.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
imports: [PrismaModule, RedisModule],
providers: [
CalendarsRepository,
CalendarsService,
CalendarsCacheService,
DestinationCalendarsService,
DestinationCalendarsRepository,
UsersRepository,
@@ -1,4 +1,5 @@
import { ConnectedCalendar, Calendar } from "@/ee/calendars/outputs/connected-calendars.output";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { DestinationCalendarsRepository } from "@/modules/destination-calendars/destination-calendars.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
@@ -7,6 +8,7 @@ import { Injectable, NotFoundException } from "@nestjs/common";
export class DestinationCalendarsService {
constructor(
private readonly calendarsService: CalendarsService,
private readonly calendarsCacheService: CalendarsCacheService,
private readonly destinationCalendarsRepository: DestinationCalendarsRepository
) {}
@@ -60,6 +62,7 @@ export class DestinationCalendarsService {
delegatedCalendar ? delegationCredentialId : undefined
);
this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(userId);
return {
userId: updatedCalendarUserId,
integration: updatedCalendarIntegration,
@@ -1,4 +1,5 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
@@ -49,6 +50,7 @@ import { JwtService } from "@nestjs/jwt";
TokensRepository,
OAuthFlowService,
CalendarsService,
CalendarsCacheService,
CredentialsRepository,
AppsRepository,
CalendarsRepository,
@@ -119,6 +119,15 @@ export class OAuthClientRepository {
});
}
async getByOrgId(organizationId: number) {
return this.dbRead.prisma.platformOAuthClient.findMany({
where: {
organizationId,
},
select: { id: true },
});
}
async getByEventTypeHosts(eventTypeId: number) {
const hostWithUserPlatformClient = await this.dbRead.prisma.host.findFirst({
select: {
@@ -1,17 +1,23 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
import { Injectable, NotFoundException } from "@nestjs/common";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
export const PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR = `Can't add user to organization - the user is platform managed user but organization is not because organization probably was not created using OAuth credentials.`;
export const REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR = `Can't add user to organization - the user is not platform managed user but organization is platform managed. Both have to be created using OAuth credentials.`;
export const MANAGED_USER_AND_MANAGED_ORG_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR = `Can't add user to organization - managed user and organization were created using different OAuth clients.`;
@Injectable()
export class OrganizationsMembershipService {
constructor(
private readonly organizationsMembershipRepository: OrganizationsMembershipRepository,
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService,
private readonly oAuthClientsRepository: OAuthClientRepository
) {}
async getOrgMembership(organizationId: number, membershipId: number) {
@@ -95,7 +101,33 @@ export class OrganizationsMembershipService {
}
async createOrgMembership(organizationId: number, data: CreateOrgMembershipDto) {
await this.canUserBeAddedToOrg(data.userId, organizationId);
const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data);
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);
}
async canUserBeAddedToOrg(userId: number, orgId: number) {
const [userOAuthClient, orgOAuthClients] = await Promise.all([
this.oAuthClientsRepository.getByUserId(userId),
this.oAuthClientsRepository.getByOrgId(orgId),
]);
if (!userOAuthClient && orgOAuthClients.length === 0) {
return true;
}
if (userOAuthClient && orgOAuthClients.some((orgClient) => orgClient.id === userOAuthClient.id)) {
return true;
}
if (!userOAuthClient) {
throw new BadRequestException(REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR);
}
if (orgOAuthClients.length === 0) {
throw new BadRequestException(PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR);
}
throw new BadRequestException(MANAGED_USER_AND_MANAGED_ORG_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR);
}
}
@@ -13,7 +13,8 @@ import { ZoomVideoService } from "@/modules/conferencing/services/zoom-video.ser
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { EmailModule } from "@/modules/email/email.module";
import { EmailService } from "@/modules/email/email.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { UserOOORepository } from "@/modules/ooo/repositories/ooo.repository";
import { UserOOOService } from "@/modules/ooo/services/ooo.service";
import { OrganizationsAttributesController } from "@/modules/organizations/attributes/index/controllers/organizations-attributes.controller";
@@ -65,6 +66,8 @@ import { RedisModule } from "@/modules/redis/redis.module";
import { RedisService } from "@/modules/redis/redis.service";
import { StripeModule } from "@/modules/stripe/stripe.module";
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
import { TeamsModule } from "@/modules/teams/teams/teams.module";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
@@ -93,6 +96,7 @@ import { Module } from "@nestjs/common";
OrganizationsOrganizationsModule,
OrganizationsStripeModule,
OrganizationsTeamsRoutingFormsModule,
MembershipsModule,
OrganizationsConferencingModule,
EventTypesPrivateLinksModule,
],
@@ -101,7 +105,6 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsRepository,
OrganizationsService,
OrganizationsTeamsService,
MembershipsRepository,
OrganizationsSchedulesService,
OrganizationsUsersRepository,
OrganizationsUsersService,
@@ -147,6 +150,9 @@ import { Module } from "@nestjs/common";
TeamsSchedulesService,
SchedulesService_2024_06_11,
InputSchedulesService_2024_06_11,
TeamsMembershipsService,
TeamsMembershipsRepository,
OAuthClientRepository,
],
exports: [
OrganizationsService,
@@ -0,0 +1,470 @@
import { bootstrap } from "@/app";
import { AppModule } from "@/app.module";
import { EmailService } from "@/modules/email/email.service";
import {
CreateManagedUserData,
CreateManagedUserOutput,
} from "@/modules/oauth-clients/controllers/oauth-client-users/outputs/create-managed-user.output";
import {
PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR,
REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR,
} from "@/modules/organizations/memberships/services/organizations-membership.service";
import { CreateOrgTeamDto } from "@/modules/organizations/teams/index/inputs/create-organization-team.input";
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import {
PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR,
REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR,
PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR,
} from "@/modules/teams/memberships/services/teams-memberships.service";
import { TeamsMembershipsModule } from "@/modules/teams/memberships/teams-memberships.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { CreateManagedUserInput } from "@/modules/users/inputs/create-managed-user.input";
import { CreateUserInput } from "@/modules/users/inputs/create-user.input";
import { UsersModule } from "@/modules/users/users.module";
import { INestApplication } from "@nestjs/common";
import { NestExpressApplication } from "@nestjs/platform-express";
import { Test } from "@nestjs/testing";
import { PlatformOAuthClient, User } from "@prisma/client";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiSuccessResponse } from "@calcom/platform-types";
import { Team } from "@calcom/prisma/client";
describe("Organizations Teams Memberships Endpoints", () => {
let app: INestApplication;
let userRepositoryFixture: UserRepositoryFixture;
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let teamsRepositoryFixture: TeamRepositoryFixture;
let membershipsRepositoryFixture: MembershipRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let org: Team;
let orgOwner: User;
let orgUser: User;
let orgTeam: Team;
let orgApiKey: string;
let platformOrg: Team;
let platformOrgOwner: User;
let platformOrgUser: CreateManagedUserData;
let platformOrgTeam: Team;
let platformOAuthClient: PlatformOAuthClient;
let secondPlatformOAuthClient: PlatformOAuthClient;
let secondPlatformOrgUser: CreateManagedUserData;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule, PrismaModule, UsersModule, TokensModule, TeamsMembershipsModule],
}).compile();
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
const orgName = `organizations-teams-memberships-organization-${randomString()}`;
org = await organizationsRepositoryFixture.create({
name: orgName,
slug: orgName,
isOrganization: true,
});
const platformOrgName = `organizations-teams-memberships-platform-organization-${randomString()}`;
platformOrg = await organizationsRepositoryFixture.create({
isPlatform: true,
name: platformOrgName,
slug: platformOrgName,
isOrganization: true,
platformBilling: {
create: {
customerId: "cus_999",
plan: "ESSENTIALS",
subscriptionId: "sub_999",
},
},
});
platformOAuthClient = await createOAuthClient(platformOrg.id);
secondPlatformOAuthClient = await createOAuthClient(platformOrg.id);
orgOwner = await userRepositoryFixture.create({
email: `organizations-teams-memberships-org-owner-${randomString()}@api.com`,
username: `organizations-teams-memberships-org-owner-${randomString()}`,
});
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
const { keyString } = await apiKeysRepositoryFixture.createApiKey(orgOwner.id, null);
orgApiKey = `cal_test_${keyString}`;
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: orgOwner.id } },
team: { connect: { id: org.id } },
accepted: true,
});
platformOrgOwner = await userRepositoryFixture.create({
email: `platform-org-owner-${randomString()}@api.com`,
username: `platform-org-owner-${randomString()}`,
});
await membershipsRepositoryFixture.create({
role: "OWNER",
user: { connect: { id: platformOrgOwner.id } },
team: { connect: { id: platformOrg.id } },
accepted: true,
});
await profilesRepositoryFixture.create({
uid: "asd1qwwqeqw-asddsadasd",
username: `platform-org-owner-${randomString()}`,
organization: { connect: { id: platformOrg.id } },
user: {
connect: { id: platformOrgOwner.id },
},
});
await profilesRepositoryFixture.create({
uid: "asd1qwwqeqw-asddsadasd-2",
username: `org-owner-${randomString()}`,
organization: { connect: { id: org.id } },
user: {
connect: { id: orgOwner.id },
},
});
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
await app.init();
});
async function createOAuthClient(organizationId: number) {
const data = {
logo: "logo-url",
name: "name",
redirectUris: ["http://localhost:4321"],
permissions: 1023,
};
const secret = "secret";
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
return client;
}
describe("should create org users", () => {
it("should create a new org user", async () => {
const newOrgUser: CreateUserInput = {
email: `organization-user-${randomString()}@api.com`,
};
jest
.spyOn(EmailService.prototype, "sendSignupToOrganizationEmail")
.mockImplementation(() => Promise.resolve());
const { body } = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/users`)
.send(newOrgUser)
.set("Content-Type", "application/json")
.set("Authorization", `Bearer ${orgApiKey}`)
.set("Accept", "application/json");
const userData = body.data;
expect(body.status).toBe(SUCCESS_STATUS);
expect(userData.email).toBe(newOrgUser.email);
orgUser = userData;
});
it(`should create a new platform org manager user using first oAuth client`, async () => {
const managedUserEmail = `platform-organization-manager-user-${randomString()}@api.com`;
const requestBody: CreateManagedUserInput = {
email: managedUserEmail,
timeZone: "Europe/Berlin",
weekStart: "Monday",
timeFormat: 24,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
bio: "I am a bio",
metadata: {
key: "value",
},
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${platformOAuthClient.id}/users`)
.set("x-cal-secret-key", platformOAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
expect(responseBody.data.user.name).toEqual(requestBody.name);
await userConnectedToOAuth(platformOAuthClient.id, responseBody.data.user.email, 1);
platformOrgUser = responseBody.data;
});
it(`should create a new platform org manager user using second oAuth client`, async () => {
const managedUserEmail = `platform-organization-manager-user-${randomString()}@api.com`;
const requestBody: CreateManagedUserInput = {
email: managedUserEmail,
timeZone: "Europe/Berlin",
weekStart: "Monday",
timeFormat: 24,
name: "Alice Smith",
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
bio: "I am a bio",
metadata: {
key: "value",
},
};
const response = await request(app.getHttpServer())
.post(`/api/v2/oauth-clients/${secondPlatformOAuthClient.id}/users`)
.set("x-cal-secret-key", secondPlatformOAuthClient.secret)
.send(requestBody)
.expect(201);
const responseBody: CreateManagedUserOutput = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseBody.data.user.timeZone).toEqual(requestBody.timeZone);
expect(responseBody.data.user.name).toEqual(requestBody.name);
await userConnectedToOAuth(secondPlatformOAuthClient.id, responseBody.data.user.email, 1);
secondPlatformOrgUser = responseBody.data;
});
});
describe("should create org teams", () => {
it("should create the team for org", async () => {
const teamName = `organization team ${randomString()}`;
return request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams`)
.set("Authorization", `Bearer ${orgApiKey}`)
.send({
name: teamName,
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
orgTeam = responseBody.data;
expect(orgTeam.name).toEqual(teamName);
expect(orgTeam.parentId).toEqual(org.id);
});
});
it("should create the team for platform org", async () => {
const teamName = `platform organization team ${randomString()}`;
return request(app.getHttpServer())
.post(`/v2/organizations/${platformOrg.id}/teams`)
.set("x-cal-client-id", platformOAuthClient.id)
.set("x-cal-secret-key", platformOAuthClient.secret)
.send({
name: teamName,
} satisfies CreateOrgTeamDto)
.expect(201)
.then(async (response) => {
const responseBody: ApiSuccessResponse<Team> = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
platformOrgTeam = responseBody.data;
expect(platformOrgTeam.name).toEqual(teamName);
expect(platformOrgTeam.parentId).toEqual(platformOrg.id);
});
});
});
describe("organization memberships", () => {
describe("negative tests", () => {
it("should not add managed user to organization", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/memberships`)
.set("Authorization", `Bearer ${orgApiKey}`)
.send({
userId: platformOrgUser.user.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(400);
expect(response.body.error.message).toEqual(PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR);
});
it("should not add managed user to organization team", async () => {
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.set("Authorization", `Bearer ${orgApiKey}`)
.send({
userId: platformOrgUser.user.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(422);
});
it("should not add managed user to organization team", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/teams/${orgTeam.id}/memberships`)
.set("Authorization", `Bearer ${orgApiKey}`)
.send({
userId: platformOrgUser.user.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(400);
expect(response.body.error.message).toEqual(PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR);
});
});
describe("positive tests", () => {
it("should add user to organization", async () => {
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/memberships`)
.set("Authorization", `Bearer ${orgApiKey}`)
.send({
userId: orgUser.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);
});
it("should add user to organization team", async () => {
await request(app.getHttpServer())
.post(`/v2/organizations/${org.id}/teams/${orgTeam.id}/memberships`)
.set("Authorization", `Bearer ${orgApiKey}`)
.send({
userId: orgUser.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);
});
});
});
describe("platform organization memberships", () => {
describe("negative tests", () => {
it("should not add user to platform organization", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${platformOrg.id}/memberships`)
.set("x-cal-client-id", platformOAuthClient.id)
.set("x-cal-secret-key", platformOAuthClient.secret)
.send({
userId: orgUser.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(400);
expect(response.body.error.message).toEqual(REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR);
});
it("should not add user to platform organization team", async () => {
await request(app.getHttpServer())
.post(`/v2/organizations/${platformOrg.id}/teams/${platformOrgTeam.id}/memberships`)
.set("x-cal-client-id", platformOAuthClient.id)
.set("x-cal-secret-key", platformOAuthClient.secret)
.send({
userId: orgUser.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(422);
});
it("should not add user to platform organization team", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/teams/${platformOrgTeam.id}/memberships`)
.set("x-cal-client-id", platformOAuthClient.id)
.set("x-cal-secret-key", platformOAuthClient.secret)
.send({
userId: orgUser.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(400);
expect(response.body.error.message).toEqual(REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR);
});
it("should not add user to platform organization team because user is created using different oAuth client", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/organizations/${platformOrg.id}/teams/${platformOrgTeam.id}/memberships`)
.set("x-cal-client-id", platformOAuthClient.id)
.set("x-cal-secret-key", platformOAuthClient.secret)
.send({
userId: secondPlatformOrgUser.user.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(400);
expect(response.body.error.message).toEqual(
PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR
);
});
});
describe("positive tests", () => {
it("should add user to organization", async () => {
await request(app.getHttpServer())
.post(`/v2/organizations/${platformOrg.id}/memberships`)
.set("x-cal-client-id", platformOAuthClient.id)
.set("x-cal-secret-key", platformOAuthClient.secret)
.send({
userId: platformOrgUser.user.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);
});
it("should add user to organization team", async () => {
await request(app.getHttpServer())
.post(`/v2/organizations/${platformOrg.id}/teams/${platformOrgTeam.id}/memberships`)
.set("x-cal-client-id", platformOAuthClient.id)
.set("x-cal-secret-key", platformOAuthClient.secret)
.send({
userId: platformOrgUser.user.id,
accepted: true,
role: "MEMBER",
} satisfies CreateOrgTeamMembershipDto)
.expect(201);
});
});
});
async function userConnectedToOAuth(oAuthClientId: string, userEmail: string, usersCount: number) {
const oAuthUsers = await oauthClientRepositoryFixture.getUsers(oAuthClientId);
const newOAuthUser = oAuthUsers?.find((user) => user.email === userEmail);
expect(oAuthUsers?.length).toEqual(usersCount);
expect(newOAuthUser?.email).toEqual(userEmail);
}
afterAll(async () => {
await organizationsRepositoryFixture.delete(org.id);
await teamsRepositoryFixture.delete(orgTeam.id);
await organizationsRepositoryFixture.delete(platformOrg.id);
await teamsRepositoryFixture.delete(platformOrgTeam.id);
await userRepositoryFixture.deleteByEmail(platformOrgUser.user.email);
await userRepositoryFixture.deleteByEmail(orgUser.email);
await userRepositoryFixture.deleteByEmail(orgOwner.email);
await userRepositoryFixture.deleteByEmail(platformOrgOwner.email);
await userRepositoryFixture.deleteByEmail(secondPlatformOrgUser.user.email);
await app.close();
});
});
@@ -1,6 +1,7 @@
import { CreateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/create-organization-team-membership.input";
import { UpdateOrgTeamMembershipDto } from "@/modules/organizations/teams/memberships/inputs/update-organization-team-membership.input";
import { OrganizationsTeamsMembershipsRepository } from "@/modules/organizations/teams/memberships/organizations-teams-memberships.repository";
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
import { Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
@@ -8,10 +9,12 @@ import { TeamService } from "@calcom/platform-libraries";
@Injectable()
export class OrganizationsTeamsMembershipsService {
constructor(
private readonly organizationsTeamsMembershipsRepository: OrganizationsTeamsMembershipsRepository
private readonly organizationsTeamsMembershipsRepository: OrganizationsTeamsMembershipsRepository,
private readonly teamsMembershipsService: TeamsMembershipsService
) {}
async createOrgTeamMembership(teamId: number, data: CreateOrgTeamMembershipDto) {
await this.teamsMembershipsService.canUserBeAddedToTeam(data.userId, teamId);
const teamMembership = await this.organizationsTeamsMembershipsRepository.createOrgTeamMembership(
teamId,
data
@@ -1,4 +1,5 @@
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { AppsRepository } from "@/modules/apps/apps.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
@@ -7,6 +8,7 @@ import { OrganizationsMembershipRepository } from "@/modules/organizations/membe
import { OrganizationsMembershipOutputService } from "@/modules/organizations/memberships/services/organizations-membership-output.service";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { SelectedCalendarsController } from "@/modules/selected-calendars/controllers/selected-calendars.controller";
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
import { SelectedCalendarsService } from "@/modules/selected-calendars/services/selected-calendars.service";
@@ -14,11 +16,12 @@ import { UsersRepository } from "@/modules/users/users.repository";
import { Module } from "@nestjs/common";
@Module({
imports: [PrismaModule],
imports: [PrismaModule, RedisModule],
providers: [
SelectedCalendarsRepository,
CalendarsRepository,
CalendarsService,
CalendarsCacheService,
UsersRepository,
CredentialsRepository,
AppsRepository,
@@ -1,3 +1,4 @@
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
import { OrganizationsDelegationCredentialRepository } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.repository";
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
@@ -23,6 +24,7 @@ type SelectedCalendarsInputDelegationCredential = SelectedCalendarsInputDto & {
export class SelectedCalendarsService {
constructor(
private readonly calendarsService: CalendarsService,
private readonly calendarsCacheService: CalendarsCacheService,
private readonly selectedCalendarsRepository: SelectedCalendarsRepository,
private readonly organizationsMembershipService: OrganizationsMembershipService,
private readonly organizationsDelegationCredentialRepository: OrganizationsDelegationCredentialRepository
@@ -50,6 +52,8 @@ export class SelectedCalendarsService {
credentialId
);
await this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(user.id);
return userSelectedCalendar;
}
@@ -122,7 +126,7 @@ export class SelectedCalendarsService {
externalId,
delegationCredentialId
);
await this.calendarsCacheService.deleteConnectedAndDestinationCalendarsCache(user.id);
return removedCalendarEntry;
} catch (error) {
if (error instanceof Error) {
@@ -1,15 +1,24 @@
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
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";
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { TeamService } from "@calcom/platform-libraries";
export const PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR = `Can't add user to team - the user is platform managed user but team is not because team probably was not created using OAuth credentials.`;
export const REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR = `Can't add user to team - the user is not platform managed user but team is platform managed. Both have to be created using OAuth credentials.`;
export const PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR = `Can't add user to team - managed user and team were created using different OAuth clients.`;
@Injectable()
export class TeamsMembershipsService {
constructor(private readonly teamsMembershipsRepository: TeamsMembershipsRepository) {}
constructor(
private readonly teamsMembershipsRepository: TeamsMembershipsRepository,
private readonly oAuthClientsRepository: OAuthClientRepository
) {}
async createTeamMembership(teamId: number, data: CreateTeamMembershipInput) {
await this.canUserBeAddedToTeam(data.userId, teamId);
const teamMembership = await this.teamsMembershipsRepository.createTeamMembership(teamId, data);
return teamMembership;
}
@@ -54,4 +63,29 @@ export class TeamsMembershipsService {
return teamMembership;
}
async canUserBeAddedToTeam(userId: number, teamId: number) {
const [userOAuthClient, teamOAuthClient] = await Promise.all([
this.oAuthClientsRepository.getByUserId(userId),
this.oAuthClientsRepository.getByTeamId(teamId),
]);
if (!userOAuthClient && !teamOAuthClient) {
return true;
}
if (userOAuthClient && teamOAuthClient && userOAuthClient.id === teamOAuthClient.id) {
return true;
}
if (!teamOAuthClient) {
throw new BadRequestException(PLATFORM_USER_BEING_ADDED_TO_REGULAR_TEAM_ERROR);
}
if (!userOAuthClient) {
throw new BadRequestException(REGULAR_USER_BEING_ADDED_TO_PLATFORM_TEAM_ERROR);
}
throw new BadRequestException(PLATFORM_USER_AND_PLATFORM_TEAM_CREATED_WITH_DIFFERENT_OAUTH_CLIENTS_ERROR);
}
}
@@ -12,5 +12,6 @@ import { Module } from "@nestjs/common";
imports: [PrismaModule, RedisModule, OrganizationsModule, MembershipsModule, TeamsEventTypesModule],
providers: [TeamsMembershipsRepository, TeamsMembershipsService],
controllers: [TeamsMembershipsController],
exports: [TeamsMembershipsService],
})
export class TeamsMembershipsModule {}
@@ -120,4 +120,30 @@ export class TeamsRepository {
},
});
}
async getTeamMemberEmails(teamId: number) {
return this.dbRead.prisma.user.findMany({
where: {
teams: {
some: {
teamId,
},
},
},
select: {
id: true,
email: true,
secondaryEmails: {
where: {
emailVerified: {
not: null,
},
},
select: {
email: true,
},
},
},
});
}
}
+34 -5
View File
@@ -9132,8 +9132,6 @@
{
"name": "credentialId",
"in": "query",
"description": "The ID of the user's calendar credential used to access the calendar. Retrieve this from your connected calendar credentials via the `/v2/credentials` endpoint.",
"example": 135,
"required": true,
"schema": {
"type": "number"
@@ -9142,8 +9140,6 @@
{
"name": "externalId",
"in": "query",
"description": "The external calendar identifier from the calendar provider (e.g., email address for Google Calendar). This is returned when listing connected calendars.",
"example": "user@gmail.com",
"required": true,
"schema": {
"type": "string"
@@ -9862,6 +9858,7 @@
"get": {
"operationId": "EventTypesController_2024_06_14_getEventTypes",
"summary": "Get all event types",
"description": "Hidden event types are returned only if authentication is provided and it belongs to the event type owner.",
"parameters": [
{
"name": "cal-api-version",
@@ -9917,6 +9914,33 @@
"schema": {
"type": "number"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@@ -23642,7 +23666,12 @@
"afterEvent",
"rescheduleEvent",
"afterHostsCalVideoNoShow",
"afterGuestsCalVideoNoShow"
"afterGuestsCalVideoNoShow",
"bookingRejected",
"bookingRequested",
"bookingPaymentInitiated",
"bookingPaid",
"bookingNoShowUpdated"
]
],
"description": "Trigger type for the workflow"
@@ -2,8 +2,6 @@ import type { PageProps as _PageProps } from "app/_types";
import { _generateMetadata } from "app/_utils";
import { z } from "zod";
import { StripeService } from "@calcom/lib/server/service/stripe";
import VerifyPage from "~/auth/verify-view";
const querySchema = z.object({
@@ -14,18 +12,10 @@ const querySchema = z.object({
export const generateMetadata = async ({ params, searchParams }: _PageProps) => {
const p = { ...(await params), ...(await searchParams) };
const { sessionId, stripeCustomerId } = querySchema.parse(p);
const data = await StripeService.getCheckoutSession({
stripeCustomerId,
checkoutSessionId: sessionId,
});
const { hasPaymentFailed } = data;
const { sessionId } = querySchema.parse(p);
return await _generateMetadata(
() =>
hasPaymentFailed ? "Your payment failed" : sessionId ? "Payment successful!" : `Verify your email`,
() => (sessionId ? "Payment Page" : `Verify your email`),
() => "",
undefined,
undefined,
@@ -0,0 +1,34 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { hasInsightsPermission } from "@calcom/features/insights/server/hasInsightsPermission";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export async function checkInsightsPagePermission() {
const featuresRepository = new FeaturesRepository(prisma);
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
redirect("/");
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
const hasPermission = await hasInsightsPermission({
userId: session.user.id,
organizationId: session.user.org?.id,
});
if (!hasPermission) {
redirect("/");
}
return session;
}
@@ -1,34 +1,22 @@
import { getTranslate } from "app/_utils";
import { notFound } from "next/navigation";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import Shell from "@calcom/features/shell/Shell";
import { prisma } from "@calcom/prisma";
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository(prisma);
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
return notFound();
}
const t = await getTranslate();
return (
<>
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
</Shell>
</div>
</>
<div>
<Shell
withoutMain={false}
heading={t("insights")}
subtitle={t("insights_subtitle")}
actions={<div className={`mb-2 flex items-center gap-2 ${CTA_CONTAINER_CLASS_NAME}`} />}>
<UpgradeTipWrapper>{children}</UpgradeTipWrapper>
</Shell>
</div>
);
}
@@ -1,13 +1,11 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import InsightsPage from "~/insights/insights-view";
import { checkInsightsPagePermission } from "./checkInsightsPagePermission";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
@@ -18,7 +16,7 @@ export const generateMetadata = async () =>
);
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const session = await checkInsightsPagePermission();
const { timeZone } = await prisma.user.findUniqueOrThrow({
where: { id: session?.user.id ?? -1 },
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
import InsightsVirtualQueuesPage from "~/insights/insights-virtual-queues-view";
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
@@ -12,5 +14,7 @@ export const generateMetadata = async () =>
);
export default async function Page() {
await checkInsightsPagePermission();
return <InsightsVirtualQueuesPage />;
}
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
import InsightsRoutingPage from "~/insights/insights-routing-view";
import { checkInsightsPagePermission } from "../checkInsightsPagePermission";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("insights"),
@@ -12,5 +14,7 @@ export const generateMetadata = async () =>
);
export default async function Page() {
await checkInsightsPagePermission();
return <InsightsRoutingPage />;
}
@@ -1,11 +1,11 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import type { ComponentProps } from "react";
import React, { useEffect } from "react";
import React from "react";
import type Shell from "@calcom/features/shell/Shell";
import { UserPermissionRole } from "@calcom/prisma/enums";
import type { UserPermissionRole } from "@calcom/prisma/enums";
import { ErrorBoundary } from "@calcom/ui/components/errorBoundary";
export type AdminLayoutProps = {
@@ -14,14 +14,6 @@ export type AdminLayoutProps = {
} & ComponentProps<typeof Shell>;
export default function AdminLayoutAppDirClient({ userRole, children }: AdminLayoutProps) {
const pathname = usePathname();
const router = useRouter();
// Force redirect on component level
useEffect(() => {
if (userRole !== UserPermissionRole.ADMIN) {
router.replace("/settings/my-account/profile");
}
}, [userRole, router]);
const isAppsPage = pathname?.startsWith("/settings/admin/apps");
return (
@@ -1,7 +1,9 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import React from "react";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -15,5 +17,9 @@ export default async function AdminLayoutAppDir(props: AdminLayoutAppDirProps) {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const userRole = session?.user?.role;
if (userRole !== UserPermissionRole.ADMIN) {
return redirect("/settings/my-account/profile");
}
return await SettingsLayoutAppDir({ children: <AdminLayoutAppDirClient {...props} userRole={userRole} /> });
}
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
import OrgAttributesEditPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-edit-view";
import { validateUserHasOrgAdmin } from "../../../../actions/validateUserHasOrgAdmin";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
(t) => t("attribute"),
@@ -12,6 +14,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
);
const OrgAttributesEditPageWrapper = async () => {
await validateUserHasOrgAdmin();
return <OrgAttributesEditPage />;
};
@@ -2,6 +2,8 @@ import { _generateMetadata } from "app/_utils";
import OrgAttributesCreatePage from "@calcom/ee/organizations/pages/settings/attributes/attributes-create-view";
import { validateUserHasOrgAdmin } from "../../../actions/validateUserHasOrgAdmin";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("attribute"),
@@ -12,6 +14,8 @@ export const generateMetadata = async () =>
);
const OrgAttributesCreatePageWrapper = async () => {
await validateUserHasOrgAdmin();
return <OrgAttributesCreatePage />;
};
@@ -1,15 +1,13 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import OrgSettingsAttributesPage from "@calcom/ee/organizations/pages/settings/attributes/attributes-list-view";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -22,11 +20,7 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const session = await validateUserHasOrgAdmin();
const { canRead, canEdit, canDelete, canCreate } = await getResourcePermissions({
userId: session.user.id,
@@ -1,5 +1,35 @@
import BillingPage, { generateMetadata } from "../../../billing/page";
import { _generateMetadata } from "app/_utils";
import { getTranslate } from "app/_utils";
export { generateMetadata };
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
export default BillingPage;
import BillingView from "~/settings/billing/billing-view";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("billing"),
(t) => t("manage_billing_description"),
undefined,
undefined,
"/settings/organizations/billing"
);
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrgAdmin();
// TODO(SEAN): Add PBAC to this page in the next PR
return (
<SettingsHeader
title={t("billing")}
description={t("manage_billing_description")}
borderInShellHeader={true}>
<BillingView />
</SettingsHeader>
);
};
export default Page;
@@ -3,6 +3,8 @@ import { _generateMetadata, getTranslate } from "app/_utils";
import DelegationCredentialList from "@calcom/features/ee/organizations/pages/settings/delegationCredential";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("delegation_credential"),
@@ -15,6 +17,8 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrgAdmin();
return (
<SettingsHeader
borderInShellHeader
@@ -1,15 +1,12 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import DirectorySyncTeamView from "@calcom/features/ee/dsync/page/team-dsync-view";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -22,11 +19,7 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/organizations/general");
}
const session = await validateUserHasOrgAdmin();
const { canEdit } = await getResourcePermissions({
userId: session.user.id,
@@ -1,15 +1,13 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import PrivacyView from "@calcom/features/ee/organizations/pages/settings/privacy";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -21,9 +19,9 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const [t, _headers, _cookies] = await Promise.all([getTranslate(), headers(), cookies()]);
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
const session = await validateUserHasOrgAdmin();
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
@@ -1,15 +1,13 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import OrgSSOView from "@calcom/features/ee/sso/page/orgs-sso-view";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validateUserHasOrgAdmin } from "../../actions/validateUserHasOrgAdmin";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -22,7 +20,7 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const session = await validateUserHasOrgAdmin();
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/organizations/general");
@@ -0,0 +1,229 @@
import type { Session } from "next-auth";
import { redirect } from "next/navigation";
import { describe, it, vi, expect, beforeEach, type MockedFunction } from "vitest";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrg, type ValidatedOrgSession } from "./validateUserHasOrg";
// Mock the dependencies
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("next/headers", () => ({
cookies: vi.fn(() => Promise.resolve({})),
headers: vi.fn(() => Promise.resolve({})),
}));
vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn(() => ({})),
}));
const mockedGetServerSession = getServerSession as MockedFunction<typeof getServerSession>;
const mockedRedirect = vi.mocked(redirect);
describe("validateUserHasOrg", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockSession = (overrides: Partial<Session> = {}): Session => ({
expires: "2025-01-01",
hasValidLicense: true,
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: {
id: 456,
name: "Test Org",
slug: "test-org",
logoUrl: null,
fullDomain: "test-org.cal.com",
domainSuffix: "cal.com",
role: MembershipRole.ADMIN,
},
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
},
},
},
profileId: 789,
upId: "usr-123",
...overrides,
});
describe("when user has valid organization", () => {
it("should return session when user has org in user.org", async () => {
const mockSession = createMockSession();
mockedGetServerSession.mockResolvedValue(mockSession);
const result = await validateUserHasOrg();
expect(result).toEqual(mockSession);
expect(mockedRedirect).not.toHaveBeenCalled();
expect(result.user.id).toBe(123);
expect(result.user.org?.id).toBe(456);
expect(result.user.profile?.organizationId).toBe(456);
});
it("should return session when user has organizationId but no org", async () => {
const mockSession = createMockSession({
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: undefined,
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
},
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
const result = await validateUserHasOrg();
expect(result).toEqual(mockSession);
expect(mockedRedirect).not.toHaveBeenCalled();
});
});
describe("when user does not have valid organization", () => {
it("should redirect when session is null", async () => {
mockedGetServerSession.mockResolvedValue(null);
const redirectError = new Error("NEXT_REDIRECT");
mockedRedirect.mockImplementation(() => {
throw redirectError;
});
await expect(validateUserHasOrg()).rejects.toThrow("NEXT_REDIRECT");
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
});
it("should redirect when user has no organization data", async () => {
const mockSession = createMockSession({
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: undefined,
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: null,
organization: null,
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
const redirectError = new Error("NEXT_REDIRECT");
mockedRedirect.mockImplementation(() => {
throw redirectError;
});
await expect(validateUserHasOrg()).rejects.toThrow("NEXT_REDIRECT");
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
});
it("should redirect when user has no user id", async () => {
const mockSession = createMockSession({
user: {
id: undefined as unknown as number,
email: "test@example.com",
name: "Test User",
org: {
id: 456,
name: "Test Org",
slug: "test-org",
logoUrl: null,
fullDomain: "test-org.cal.com",
domainSuffix: "cal.com",
role: MembershipRole.ADMIN,
},
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
},
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
const redirectError = new Error("NEXT_REDIRECT");
mockedRedirect.mockImplementation(() => {
throw redirectError;
});
await expect(validateUserHasOrg()).rejects.toThrow("NEXT_REDIRECT");
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
});
});
describe("return type validation", () => {
it("should return ValidatedOrgSession type with non-nullable properties", async () => {
const mockSession = createMockSession();
mockedGetServerSession.mockResolvedValue(mockSession);
const result: ValidatedOrgSession = await validateUserHasOrg();
// Type assertions to ensure proper typing
expect(typeof result.user.id).toBe("number");
expect(result.user.org).toBeTruthy();
expect(result.user.profile).toBeTruthy();
expect(typeof result.user.profile.organizationId).toBe("number");
// Runtime checks that the types guarantee these properties exist
expect(result.user.id).toBe(123);
expect(result.user.org.id).toBe(456);
expect(result.user.profile.organizationId).toBe(456);
});
});
describe("error handling", () => {
it("should propagate errors from getServerSession", async () => {
const error = new Error("Session error");
mockedGetServerSession.mockRejectedValue(error);
await expect(validateUserHasOrg()).rejects.toThrow("Session error");
});
});
});
@@ -0,0 +1,30 @@
import type { Session } from "next-auth";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export type ValidatedOrgSession = NonNullable<Session> & {
user: NonNullable<Session["user"]> & {
id: number;
org: NonNullable<NonNullable<Session["user"]>["org"]>;
profile: NonNullable<NonNullable<Session["user"]>["profile"]> & {
organizationId: number;
};
};
};
export async function validateUserHasOrg(): Promise<ValidatedOrgSession> {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const orgExists =
session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization;
if (!orgExists || !session?.user?.id || !session?.user?.profile?.organizationId) {
redirect("/settings/my-account/profile");
}
return session as ValidatedOrgSession;
}
@@ -0,0 +1,418 @@
import type { Session } from "next-auth";
import { redirect } from "next/navigation";
import { describe, it, vi, expect, beforeEach, type MockedFunction } from "vitest";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrgAdmin, type ValidatedOrgAdminSession } from "./validateUserHasOrgAdmin";
// Mock the dependencies
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
vi.mock("next/headers", () => ({
cookies: vi.fn(() => Promise.resolve({})),
headers: vi.fn(() => Promise.resolve({})),
}));
vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@calcom/features/auth/lib/checkAdminOrOwner", () => ({
checkAdminOrOwner: vi.fn(),
}));
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn(() => ({})),
}));
const mockedGetServerSession = getServerSession as MockedFunction<typeof getServerSession>;
const mockedCheckAdminOrOwner = vi.mocked(checkAdminOrOwner);
const mockedRedirect = vi.mocked(redirect);
describe("validateUserHasOrgAdmin", () => {
beforeEach(() => {
vi.clearAllMocks();
});
const createMockSession = (overrides: Partial<Session> = {}): Session => ({
expires: "2025-01-01",
hasValidLicense: true,
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: {
id: 456,
name: "Test Org",
slug: "test-org",
logoUrl: null,
fullDomain: "test-org.cal.com",
domainSuffix: "cal.com",
role: MembershipRole.ADMIN,
},
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
},
},
},
profileId: 789,
upId: "usr-123",
...overrides,
});
describe("when user has valid organization and admin role", () => {
it("should return session when user is admin via user.org.role", async () => {
const mockSession = createMockSession();
mockedGetServerSession.mockResolvedValue(mockSession);
mockedCheckAdminOrOwner.mockReturnValue(true);
const result = await validateUserHasOrgAdmin();
expect(result).toEqual(mockSession);
expect(mockedRedirect).not.toHaveBeenCalled();
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.ADMIN);
expect(result.user.id).toBe(123);
expect(result.user.org?.id).toBe(456);
expect(result.user.profile?.organizationId).toBe(456);
});
it("should return session when user is owner", async () => {
const mockSession = createMockSession({
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: {
id: 456,
name: "Test Org",
slug: "test-org",
logoUrl: null,
fullDomain: "test-org.cal.com",
domainSuffix: "cal.com",
role: MembershipRole.OWNER,
},
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
},
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
mockedCheckAdminOrOwner.mockReturnValue(true);
const result = await validateUserHasOrgAdmin();
expect(result).toEqual(mockSession);
expect(mockedRedirect).not.toHaveBeenCalled();
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.OWNER);
});
it("should return session when role comes from organization members", async () => {
const mockSession = createMockSession({
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: undefined,
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
members: [
{ userId: 123, role: MembershipRole.ADMIN },
{ userId: 124, role: MembershipRole.MEMBER },
],
},
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
mockedCheckAdminOrOwner.mockReturnValue(true);
const result = await validateUserHasOrgAdmin();
expect(result).toEqual(mockSession);
expect(mockedRedirect).not.toHaveBeenCalled();
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.ADMIN);
});
});
describe("when user does not have valid organization", () => {
it("should redirect when session is null", async () => {
mockedGetServerSession.mockResolvedValue(null);
const redirectError = new Error("NEXT_REDIRECT");
mockedRedirect.mockImplementation(() => {
throw redirectError;
});
await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT");
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
});
it("should redirect when user has no organization data", async () => {
const mockSession = createMockSession({
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: undefined,
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: null,
organization: null,
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
const redirectError = new Error("NEXT_REDIRECT");
mockedRedirect.mockImplementation(() => {
throw redirectError;
});
await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT");
expect(mockedRedirect).toHaveBeenCalledWith("/settings/my-account/profile");
});
});
describe("when user has organization but is not admin", () => {
it("should redirect when user is only a member", async () => {
const mockSession = createMockSession({
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: {
id: 456,
name: "Test Org",
slug: "test-org",
logoUrl: null,
fullDomain: "test-org.cal.com",
domainSuffix: "cal.com",
role: MembershipRole.MEMBER,
},
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
},
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
mockedCheckAdminOrOwner.mockReturnValue(false);
const redirectError = new Error("NEXT_REDIRECT");
mockedRedirect.mockImplementation(() => {
throw redirectError;
});
await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT");
expect(mockedRedirect).toHaveBeenCalledWith("/settings/organizations/profile");
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.MEMBER);
});
it("should redirect when role is undefined", async () => {
const mockSession = createMockSession({
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: undefined,
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
members: [
{ userId: 999, role: MembershipRole.ADMIN }, // Different user
],
},
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
mockedCheckAdminOrOwner.mockReturnValue(false);
const redirectError = new Error("NEXT_REDIRECT");
mockedRedirect.mockImplementation(() => {
throw redirectError;
});
await expect(validateUserHasOrgAdmin()).rejects.toThrow("NEXT_REDIRECT");
expect(mockedRedirect).toHaveBeenCalledWith("/settings/organizations/profile");
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(undefined);
});
});
describe("return type validation", () => {
it("should return ValidatedOrgAdminSession type with non-nullable properties", async () => {
const mockSession = createMockSession();
mockedGetServerSession.mockResolvedValue(mockSession);
mockedCheckAdminOrOwner.mockReturnValue(true);
const result: ValidatedOrgAdminSession = await validateUserHasOrgAdmin();
// Type assertions to ensure proper typing
expect(typeof result.user.id).toBe("number");
expect(result.user.org).toBeTruthy();
expect(result.user.profile).toBeTruthy();
expect(typeof result.user.profile.organizationId).toBe("number");
expect(result.user.org.role).toBeTruthy();
// Runtime checks that the types guarantee these properties exist
expect(result.user.id).toBe(123);
expect(result.user.org.id).toBe(456);
expect(result.user.org.role).toBe(MembershipRole.ADMIN);
expect(result.user.profile.organizationId).toBe(456);
});
});
describe("role resolution logic", () => {
it("should prefer org role over organization members role", async () => {
const mockSession = createMockSession({
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: {
id: 456,
name: "Test Org",
slug: "test-org",
logoUrl: null,
fullDomain: "test-org.cal.com",
domainSuffix: "cal.com",
role: MembershipRole.OWNER, // This should be preferred
},
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
members: [
{ userId: 123, role: MembershipRole.MEMBER }, // Different role
],
},
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
mockedCheckAdminOrOwner.mockReturnValue(true);
await validateUserHasOrgAdmin();
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.OWNER);
});
it("should fall back to organization members role when org role is not available", async () => {
const mockSession = createMockSession({
user: {
id: 123,
email: "test@example.com",
name: "Test User",
org: undefined,
profile: {
id: 789,
upId: "usr-123",
username: "testuser",
organizationId: 456,
organization: {
id: 456,
name: "Test Org",
slug: "test-org",
calVideoLogo: null,
bannerUrl: null,
requestedSlug: null,
members: [{ userId: 123, role: MembershipRole.ADMIN }],
},
},
},
});
mockedGetServerSession.mockResolvedValue(mockSession);
mockedCheckAdminOrOwner.mockReturnValue(true);
await validateUserHasOrgAdmin();
expect(mockedCheckAdminOrOwner).toHaveBeenCalledWith(MembershipRole.ADMIN);
});
});
describe("error handling", () => {
it("should propagate errors from getServerSession", async () => {
const error = new Error("Session error");
mockedGetServerSession.mockRejectedValue(error);
await expect(validateUserHasOrgAdmin()).rejects.toThrow("Session error");
});
it("should propagate errors from checkAdminOrOwner", async () => {
const mockSession = createMockSession();
mockedGetServerSession.mockResolvedValue(mockSession);
const error = new Error("Permission check error");
mockedCheckAdminOrOwner.mockImplementation(() => {
throw error;
});
await expect(validateUserHasOrgAdmin()).rejects.toThrow("Permission check error");
});
});
});
@@ -0,0 +1,45 @@
import type { Session } from "next-auth";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export type ValidatedOrgAdminSession = NonNullable<Session> & {
user: NonNullable<Session["user"]> & {
id: number;
org: NonNullable<NonNullable<Session["user"]>["org"]> & {
role: MembershipRole;
};
profile: NonNullable<NonNullable<Session["user"]>["profile"]> & {
organizationId: number;
};
};
};
export async function validateUserHasOrgAdmin(): Promise<ValidatedOrgAdminSession> {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const orgExists =
session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization;
if (!orgExists || !session?.user?.id || !session?.user?.profile?.organizationId) {
redirect("/settings/my-account/profile");
}
const userProfile = session?.user?.profile;
const userId = session?.user?.id;
const orgRole =
session?.user?.org?.role ??
userProfile?.organization?.members.find((m: { userId: number }) => m.userId === userId)?.role;
const isOrgAdminOrOwner = checkAdminOrOwner(orgRole);
if (!isOrgAdminOrOwner) {
redirect("/settings/organizations/profile");
}
return session as ValidatedOrgAdminSession;
}
@@ -3,6 +3,8 @@ import { getTranslate, _generateMetadata } from "app/_utils";
import { AdminAPIView } from "@calcom/features/ee/organizations/pages/settings/admin-api";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => `${t("admin")} ${t("api_reference")}`,
@@ -15,6 +17,8 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrg();
return (
<SettingsHeader
title={`${t("admin")} ${t("api_reference")}`}
@@ -1,15 +1,12 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { headers, cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/general";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -23,11 +20,7 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const session = await validateUserHasOrg();
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
@@ -1,19 +1,4 @@
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const SettingsOrganizationsLayout = async ({ children }: { children: React.ReactNode }) => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const orgExists =
session?.user?.org || session?.user?.profile?.organizationId || session?.user?.profile?.organization;
if (!orgExists) {
return redirect("/settings/my-account/profile");
}
return children;
};
@@ -1,8 +1,6 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/profile";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
@@ -10,7 +8,7 @@ import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import type { Membership } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -22,20 +20,20 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const session = await validateUserHasOrg();
const t = await getTranslate();
const orgRole = session?.user.profile?.organization.members?.find(
(member: Membership) => member.userId === session?.user.id
const orgRole = session.user.profile.organization.members?.find(
(member: Membership) => member.userId === session.user.id
)?.role;
if (!session?.user.id || !session?.user.profile?.organizationId || !orgRole) {
if (!orgRole) {
return redirect("/settings/profile");
}
const { canRead, canEdit, canDelete } = await getResourcePermissions({
userId: session.user.id,
teamId: session?.user.profile?.organizationId,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: orgRole,
fallbackRoles: {
@@ -1,9 +1,7 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
@@ -13,8 +11,7 @@ import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validateUserHasOrg } from "../actions/validateUserHasOrg";
import { CreateRoleCTA } from "./_components/CreateRoleCta";
import { RolesList } from "./_components/RolesList";
import { roleSearchParamsCache } from "./_components/searchParams";
@@ -58,7 +55,7 @@ export const generateMetadata = async () =>
const Page = async ({ searchParams }: { searchParams: Record<string, string | string[] | undefined> }) => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const session = await validateUserHasOrg();
if (!session?.user?.org?.id || !session.user.id) {
return notFound();
@@ -1,13 +1,11 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { OtherTeamsListing } from "@calcom/features/ee/organizations/pages/components/OtherTeamsListing";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validateUserHasOrg } from "../../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -20,7 +18,7 @@ export const generateMetadata = async () =>
const Page = async () => {
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const session = await validateUserHasOrg();
if (!session?.user?.id) {
redirect("/auth/login");
@@ -3,6 +3,8 @@ import { _generateMetadata, getTranslate } from "app/_utils";
import LegacyPage from "@calcom/features/ee/teams/pages/team-appearance-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { validateUserHasOrg } from "../../../../actions/validateUserHasOrg";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
(t) => t("booking_appearance"),
@@ -15,6 +17,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrg();
return (
<SettingsHeader
title={t("booking_appearance")}
@@ -5,6 +5,8 @@ import LegacyPage, {
} from "@calcom/features/ee/organizations/pages/settings/other-team-members-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { validateUserHasOrg } from "../../../../actions/validateUserHasOrg";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
(t) => t("team_members"),
@@ -17,6 +19,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrg();
return (
<SettingsHeader
title={t("team_members")}
@@ -3,6 +3,8 @@ import { _generateMetadata, getTranslate } from "app/_utils";
import LegacyPage from "@calcom/features/ee/organizations/pages/settings/other-team-profile-view";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { validateUserHasOrg } from "../../../../actions/validateUserHasOrg";
export const generateMetadata = async ({ params }: { params: Promise<{ id: string }> }) =>
await _generateMetadata(
(t) => t("profile"),
@@ -15,6 +17,8 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
const Page = async () => {
const t = await getTranslate();
await validateUserHasOrg();
return (
<SettingsHeader title={t("profile")} description={t("profile_team_description")}>
<LegacyPage />
+6 -1
View File
@@ -15,6 +15,7 @@ const resolveEndpoint = (links: any) => {
// to the correct API endpoints.
// - viewer.me - 2 segment paths like this are for logged in requests
// - viewer.public.i18n - 3 segments paths can be public or authed
// - viewer.eventTypes.heavy.create - 4 segments paths for heavy sub-router
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (ctx: any) => {
const parts = ctx.op.path.split(".");
@@ -23,7 +24,11 @@ const resolveEndpoint = (links: any) => {
if (parts.length == 2) {
endpoint = parts[0] as keyof typeof links;
path = parts[1];
} else {
} else if (parts.length >= 3 && parts[2] === "heavy") {
endpoint = parts[1] + "/heavy" as keyof typeof links;
path = parts[3];
}
else {
endpoint = parts[1] as keyof typeof links;
path = parts.splice(2, parts.length - 2).join(".");
}
-546
View File
@@ -1,546 +0,0 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { createHmac } from "crypto";
import { headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";
import dayjs from "@calcom/dayjs";
import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { userMetadata } from "@calcom/prisma/zod-utils";
import { CardComponent } from "@lib/plain/card-components";
const customerCardDisplay = (
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
): Card => {
return {
key: "customer-card",
timeToLiveSeconds: null,
components: [
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentText: {
text: "Basic Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: name || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: email || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Username",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: username || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "User ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: id || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Account Settings Section Title
{
componentText: {
text: "Account Settings",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Time Zone",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: timeZone || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Identity Provider",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: identityProvider || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Last Active At",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: lastActiveAt ? dayjs(lastActiveAt).fromNow() : "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
// Team Information Section Title
{
componentText: {
text: "Team Information",
textColor: "NORMAL",
textSize: "L",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Name",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamName || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Team Slug",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: teamSlug || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Is Organization",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: isOrganization ? "Yes" : "No",
badgeColor: isOrganization ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentDivider: {
dividerSpacingSize: "M",
},
},
{
componentText: {
text: "Security & Billing",
textColor: "NORMAL",
textSize: "M",
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Stripe Customer ID",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentText: {
text: stripeCustomerId || "Unknown",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Email Verified?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: emailVerified === null ? "No" : emailVerified ? "Yes" : "No",
badgeColor: emailVerified === null ? "RED" : emailVerified ? "GREEN" : "RED",
},
},
],
},
},
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentRow: {
rowMainContent: [
{
componentText: {
text: "Two Factor Enabled?",
textColor: "MUTED",
},
},
],
rowAsideContent: [
{
componentBadge: {
badgeLabel: twoFactorEnabled === null ? "No" : twoFactorEnabled ? "Yes" : "No",
badgeColor: twoFactorEnabled === null ? "RED" : twoFactorEnabled ? "GREEN" : "RED",
},
},
],
},
},
],
};
};
const Card = z.object({
key: z.string(),
timeToLiveSeconds: z.number().int().min(0).nullish().default(null),
components: z.array(CardComponent).nullable(),
});
type Card = z.infer<typeof Card>;
const cardExamples: ((
name: string,
email: string,
id: string,
username: string,
timeZone: string,
emailVerified: Date | null,
twoFactorEnabled: boolean | null,
identityProvider: string | null,
lastActiveAt: Date | null,
teamName: string | null,
teamSlug: string | null,
isOrganization: boolean | null,
stripeCustomerId: string | null
) => Card)[] = [customerCardDisplay];
const inputSchema = z.object({
customer: z.object({
name: z.string().optional(),
email: z.string().email(),
username: z.string().optional(),
timeZone: timeZoneSchema.optional(),
emailVerified: z.boolean().optional(),
identityProvider: z.string().optional(),
twoFactorEnabled: z.boolean().optional(),
lastActiveAt: z.string().optional(),
teamName: z.string().optional(),
teamSlug: z.string().optional(),
isOrganization: z.boolean().optional(),
stripeCustomerId: z.string().optional(),
}),
cardKeys: z.array(z.string()),
});
async function handler(request: NextRequest) {
const headersList = await headers();
const requestBody = await request.json();
// HMAC verification
const incomingSignature = headersList.get("plain-request-signature");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const expectedSignature = createHmac("sha-256", process.env.PLAIN_HMAC_SECRET_KEY!)
.update(JSON.stringify(requestBody))
.digest("hex");
if (incomingSignature !== expectedSignature) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
// Validate request body
const { cardKeys, customer } = inputSchema.parse(requestBody);
const userRepo = new UserRepository(prisma);
const user = await userRepo.findByEmail({ email: customer.email });
if (!user) {
return NextResponse.json({
cards: [
{
key: "customer-card",
timeToLiveSeconds: null,
components: [
{
componentSpacer: {
spacerSize: "M",
},
},
{
componentText: {
textSize: "L",
textColor: "MUTED",
text: "User does not exist!",
},
},
],
},
],
});
}
// Fetch team details including userId and team name
const teamMemberships = await userRepo.findTeamsByUserId({ userId: user.id });
const firstTeam = teamMemberships.teams[0] ?? null;
// Parse user metadata
const parsedMetadata = userMetadata.parse(user.metadata);
const cards = await Promise.all(
cardExamples.map(async (cardFn) => {
return cardFn(
user.name || "Unknown",
user.email,
user.id.toString(),
user.username || "Unknown",
user.timeZone,
user.emailVerified,
user.twoFactorEnabled,
user.identityProvider,
user.lastActiveAt,
firstTeam?.name || "Unknown",
firstTeam?.slug || "Unknown",
firstTeam?.isOrganization || false,
(user.metadata as { stripeCustomerId?: string })?.stripeCustomerId || "Unknown"
);
})
);
const filteredCards = cards.filter((card) => {
return cardKeys.length === 0 || cardKeys.includes(card.key);
});
return NextResponse.json({
cards: filteredCards,
user: {
...user,
metadata: parsedMetadata,
},
});
}
export const POST = defaultResponderForAppDir(handler);
-88
View File
@@ -1,88 +0,0 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { createHmac } from "crypto";
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const responseSchema = z.object({
hash: z.string(),
email: z.string().email(),
shortName: z.string(),
appId: z.string(),
fullName: z.string(),
chatAvatarUrl: z.string(),
userTier: z.enum(["free", "teams", "enterprise"]),
});
async function handler() {
// Early return if Plain Chat is not enabled
if (!IS_PLAIN_CHAT_ENABLED) {
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized - No session email found" }, { status: 401 });
}
const secret = process.env.PLAIN_CHAT_HMAC_SECRET_KEY;
if (!secret) {
return NextResponse.json({ error: "Missing Plain Chat secret" }, { status: 500 });
}
// Get user's team membership info
const user = await prisma.user.findUnique({
where: { id: session.user.id },
include: {
teams: {
select: {
team: {
select: {
isOrganization: true,
},
},
},
},
},
});
// Check if user is part of a team and determine tier
let userTier = "free";
if (user?.teams.length) {
const teamMemberships = user.teams;
const isEnterprise = teamMemberships.some((membership) => membership.team.isOrganization === true);
const isTeams = user?.teams.length > 0;
userTier = isEnterprise ? "enterprise" : isTeams ? "teams" : "free";
}
const hmac = createHmac("sha256", secret);
hmac.update(session.user.email.toLowerCase().trim());
const hash = hmac.digest("hex");
const shortName =
(session.user.name?.split(" ")[0] || session.user.email).charAt(0).toUpperCase() +
(session.user.name?.split(" ")[0] || session.user.email).slice(1) || "User";
const response = responseSchema.parse({
hash,
email: session.user.email || "user@example.com",
shortName,
appId: process.env.NEXT_PUBLIC_PLAIN_CHAT_ID,
fullName: session.user.name || "User",
chatAvatarUrl: session.user.avatarUrl || "",
userTier,
});
return NextResponse.json(response);
}
export const POST = defaultResponderForAppDir(handler);
@@ -1,358 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { POST } from "../route";
vi.mock("@calcom/features/auth/lib/getServerSession", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@calcom/lib/constants", async () => {
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
return {
...actual,
IS_PRODUCTION: true,
IS_PLAIN_CHAT_ENABLED: true,
};
});
vi.mock("@lib/buildLegacyCtx", () => ({
buildLegacyRequest: vi.fn(() => ({ headers: {}, cookies: {} })),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(() => new Map()),
cookies: vi.fn(() => ({ getAll: () => [] })),
}));
vi.mock("next/server", () => ({
NextResponse: {
json: vi.fn((data, options) => ({
json: () => Promise.resolve(data),
status: options?.status || 200,
})),
},
}));
vi.mock("@lib/plain/plain", () => {
const mockGetCustomerByEmail = vi.fn();
const mockCreateThread = vi.fn();
const mockUpsertPlainCustomer = vi.fn();
return {
plain: {
getCustomerByEmail: mockGetCustomerByEmail,
createThread: mockCreateThread,
},
upsertPlainCustomer: mockUpsertPlainCustomer,
};
});
const mockGetServerSession = vi.mocked(getServerSession);
describe("/api/support", () => {
beforeEach(() => {
vi.clearAllMocks();
process.env.PLAIN_API_KEY = "test-api-key";
});
it("should return 404 when Plain Chat is disabled", async () => {
expect(true).toBe(true);
});
it("should return 401 when user is not authenticated", async () => {
mockGetServerSession.mockResolvedValue(null);
const request = new Request("http://localhost:3000/api/support", {
method: "POST",
body: JSON.stringify({
message: "Test message",
attachmentIds: [],
}),
});
const response = await POST(request);
expect(response.status).toBe(401);
});
it("should return 500 for invalid form data", async () => {
mockGetServerSession.mockResolvedValue({
hasValidLicense: true,
upId: "test-up-id",
expires: "2025-12-31T23:59:59.999Z",
user: { id: 123, email: "test@example.com" },
});
const request = new Request("http://localhost:3000/api/support", {
method: "POST",
body: JSON.stringify({
message: "",
attachmentIds: [],
}),
});
const response = await POST(request);
expect(response.status).toBe(500);
});
it("should return 500 when Plain API key is not configured", async () => {
delete process.env.PLAIN_API_KEY;
mockGetServerSession.mockResolvedValue({
hasValidLicense: true,
upId: "test-up-id",
expires: "2025-12-31T23:59:59.999Z",
user: { id: 123, email: "test@example.com" },
});
const request = new Request("http://localhost:3000/api/support", {
method: "POST",
body: JSON.stringify({
message: "Test message",
attachmentIds: [],
}),
});
const response = await POST(request);
expect(response.status).toBe(500);
});
it("should successfully create customer and thread", async () => {
mockGetServerSession.mockResolvedValue({
hasValidLicense: true,
upId: "test-up-id",
expires: "2025-12-31T23:59:59.999Z",
user: { id: 123, email: "test@example.com" },
});
const { plain } = vi.mocked(await import("@lib/plain/plain"));
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
data: {
id: "customer-123",
__typename: "Customer",
fullName: "Test User",
shortName: "Test",
externalId: "123",
email: {
email: "test@example.com",
isVerified: true,
verifiedAt: {
__typename: "DateTime",
iso8601: "2025-01-01T00:00:00Z",
unixTimestamp: "1735689600",
},
},
company: null,
createdBy: { __typename: "SystemActor" as const, systemId: "system" },
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
markedAsSpamAt: null,
},
});
vi.mocked(plain.createThread).mockResolvedValue({
data: {
id: "thread-123",
__typename: "Thread",
externalId: null,
title: "Test message",
description: null,
status: "Todo" as any,
statusChangedAt: {
__typename: "DateTime",
iso8601: "2025-01-01T00:00:00Z",
unixTimestamp: "1735689600",
},
statusDetail: null,
customer: { id: "customer-123" },
priority: 0,
previewText: "Test message",
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
} as any,
});
const request = new Request("http://localhost:3000/api/support", {
method: "POST",
body: JSON.stringify({
message: "Test message",
attachmentIds: [],
}),
});
const response = await POST(request);
expect(response.status).toBe(200);
const responseData = await response.json();
expect(responseData).toBeDefined();
expect(vi.mocked(plain.getCustomerByEmail)).toHaveBeenCalledWith({ email: "test@example.com" });
expect(vi.mocked(plain.createThread)).toHaveBeenCalled();
});
it("should handle form submission with file attachments", async () => {
mockGetServerSession.mockResolvedValue({
hasValidLicense: true,
upId: "test-up-id",
expires: "2025-12-31T23:59:59.999Z",
user: { id: 123, email: "test@example.com" },
});
const { plain, upsertPlainCustomer } = vi.mocked(await import("@lib/plain/plain"));
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
data: null,
});
vi.mocked(upsertPlainCustomer).mockResolvedValue({
data: {
result: "Created" as any,
customer: {
id: "customer-456",
__typename: "Customer",
fullName: "Test User",
shortName: "Test",
externalId: "123",
email: {
email: "test@example.com",
isVerified: true,
verifiedAt: {
__typename: "DateTime",
iso8601: "2025-01-01T00:00:00Z",
unixTimestamp: "1735689600",
},
},
company: null,
createdBy: { __typename: "SystemActor" as const, systemId: "system" },
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
markedAsSpamAt: null,
},
},
});
vi.mocked(plain.createThread).mockResolvedValue({
data: {
id: "thread-456",
__typename: "Thread",
externalId: null,
title: "Test message",
description: null,
status: "Todo" as any,
statusChangedAt: {
__typename: "DateTime",
iso8601: "2025-01-01T00:00:00Z",
unixTimestamp: "1735689600",
},
statusDetail: null,
customer: { id: "customer-456" },
priority: 0,
previewText: "Test message",
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
} as any,
});
const request = new Request("http://localhost:3000/api/support", {
method: "POST",
body: JSON.stringify({
message: "Test message",
attachmentIds: ["attachment-123"],
}),
});
const response = await POST(request);
expect(response.status).toBe(200);
const responseData = await response.json();
expect(responseData).toBeDefined();
expect(vi.mocked(plain.getCustomerByEmail)).toHaveBeenCalledWith({ email: "test@example.com" });
expect(vi.mocked(upsertPlainCustomer)).toHaveBeenCalled();
expect(vi.mocked(plain.createThread)).toHaveBeenCalled();
});
it("should handle Plain customer creation error", async () => {
mockGetServerSession.mockResolvedValue({
hasValidLicense: true,
upId: "test-up-id",
expires: "2025-12-31T23:59:59.999Z",
user: { id: 123, email: "test@example.com" },
});
const { plain, upsertPlainCustomer } = vi.mocked(await import("@lib/plain/plain"));
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
data: null,
});
vi.mocked(upsertPlainCustomer).mockResolvedValue({
error: {
type: "unknown",
message: "Customer creation failed",
},
});
const request = new Request("http://localhost:3000/api/support", {
method: "POST",
body: JSON.stringify({
message: "Test message",
attachmentIds: [],
}),
});
const response = await POST(request);
expect(response.status).toBe(500);
});
it("should handle Plain thread creation error", async () => {
mockGetServerSession.mockResolvedValue({
hasValidLicense: true,
upId: "test-up-id",
expires: "2025-12-31T23:59:59.999Z",
user: { id: 123, email: "test@example.com" },
});
const { plain } = vi.mocked(await import("@lib/plain/plain"));
vi.mocked(plain.getCustomerByEmail).mockResolvedValue({
data: {
id: "customer-123",
__typename: "Customer",
fullName: "Test User",
shortName: "Test",
externalId: "123",
email: {
email: "test@example.com",
isVerified: true,
verifiedAt: {
__typename: "DateTime",
iso8601: "2025-01-01T00:00:00Z",
unixTimestamp: "1735689600",
},
},
company: null,
createdBy: { __typename: "SystemActor" as const, systemId: "system" },
updatedAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
createdAt: { __typename: "DateTime", iso8601: "2025-01-01T00:00:00Z", unixTimestamp: "1735689600" },
markedAsSpamAt: null,
},
});
vi.mocked(plain.createThread).mockResolvedValue({
error: {
type: "unknown",
message: "Thread creation failed",
},
});
const request = new Request("http://localhost:3000/api/support", {
method: "POST",
body: JSON.stringify({
message: "Test message",
attachmentIds: [],
}),
});
const response = await POST(request);
expect(response.status).toBe(500);
});
});
@@ -0,0 +1,122 @@
import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import type { Contact } from "@calcom/features/ee/support/lib/intercom/intercom";
import { intercom } from "@calcom/features/ee/support/lib/intercom/intercom";
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { UserRepository } from "@calcom/lib/server/repository/user";
import prisma from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
const log = logger.getSubLogger({ prefix: [`/api/support/conversation`] });
export async function POST(req: NextRequest) {
const headersList = await headers();
const cookiesList = await cookies();
const legacyReq = buildLegacyRequest(headersList, cookiesList);
const session = await getServerSession({ req: legacyReq });
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 });
}
const intercomApiKey = process.env.INTERCOM_API_TOKEN;
if (!intercomApiKey) {
return NextResponse.json({ error: "Intercom API key not configured" }, { status: 500 });
}
let contact: Contact;
const existingContact = await intercom.getContactByEmail(session.user.email);
if (existingContact.error) {
return NextResponse.json(
{ error: existingContact?.error ?? "Error fetching intercom contact for user" },
{ status: 502 }
);
}
const { user } = session;
if (!existingContact.data) {
const additionalUserInfo = await new UserRepository(prisma).getUserStats({ userId: session.user.id });
const sumOfTeamEventTypes = additionalUserInfo?.teams.reduce(
(sum, team) => sum + team.team.eventTypes.length,
0
);
const newContact = await intercom.createContact({
email: session.user.email,
external_id: session.user.id.toString(),
name: session.user.name ?? session.user.email,
type: "contact",
custom_attributes: {
user_name: user?.username,
link: `${WEBSITE_URL}/${encodeURIComponent(user?.username ?? "")}`,
admin_link: `${WEBAPP_URL}/settings/admin/users/${user?.id}/edit`,
impersonate_user: `${WEBAPP_URL}/settings/admin/impersonation?username=${encodeURIComponent(
user?.email ?? user?.username ?? ""
)}`,
locale: user?.locale,
completed_onboarding: user?.completedOnboarding,
is_logged_in: !!user,
has_orgs_plan: !!user?.org,
organization: user?.org?.slug,
sum_of_bookings: additionalUserInfo?._count?.bookings,
sum_of_calendars: additionalUserInfo?._count?.userLevelSelectedCalendars,
sum_of_teams: additionalUserInfo?._count?.teams,
sum_of_event_types: additionalUserInfo?._count?.eventTypes,
sum_of_team_event_types: sumOfTeamEventTypes,
},
});
if (newContact.error || !newContact.data) {
return NextResponse.json(
{ error: newContact.error ?? "Error creating contact from email" },
{ status: 500 }
);
}
contact = newContact.data;
} else {
contact = existingContact.data;
}
if (!contact) {
return NextResponse.json({ error: "No contactId found" }, { status: 404 });
}
try {
const { message } = await req.json();
if (!message.trim()) {
return NextResponse.json({ error: "Cannot start a conversation without message" }, { status: 400 });
}
const conversation = await intercom.createConversation({
body: message,
from: {
id: contact.id,
type: "user",
},
});
if (conversation.error) {
return NextResponse.json(
{ error: conversation.error ?? "Error creating conversation" },
{ status: 400 }
);
}
return NextResponse.json({ message: "Created conversation in Intercom" }, { status: 200 });
} catch (err) {
console.error(err);
log.error(`Error creating Intercom conversation:`, safeStringify(err));
return NextResponse.json({ error: "Unexpected error occurred" }, { status: 500 });
}
}
-95
View File
@@ -1,95 +0,0 @@
import { cookies, headers } from "next/headers";
import { NextResponse } from "next/server";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { plain, upsertPlainCustomer } from "@lib/plain/plain";
const contactFormSchema = z.object({
message: z.string().min(1, "Message is required"),
attachmentIds: z.array(z.string()).optional(),
});
const log = logger.getSubLogger({ prefix: [`/api/support`] });
export async function POST(req: Request) {
if (!IS_PLAIN_CHAT_ENABLED) {
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 });
}
try {
const body = await req.json();
const { message, attachmentIds } = contactFormSchema.parse(body);
const plainApiKey = process.env.PLAIN_API_KEY;
if (!plainApiKey) {
return NextResponse.json({ error: "Plain API key not configured" }, { status: 500 });
}
let plainCustomerId: string | null = null;
const plainCustomer = await plain.getCustomerByEmail({ email: session.user.email });
if (plainCustomer.data) {
plainCustomerId = plainCustomer.data.id;
} else {
const { data, error } = await upsertPlainCustomer({
email: session.user.email,
id: session.user.id,
name: session.user.name,
});
if (error) {
log.error(`Error submitting plain contact form: `, safeStringify(error));
return NextResponse.json(
{
message: error.message,
},
{ status: 500 }
);
}
if (data) {
plainCustomerId = data.customer.id;
}
}
if (!plainCustomerId) {
return NextResponse.json({ message: "Plain customer not found" }, { status: 404 });
}
const { data, error } = await plain.createThread({
customerIdentifier: {
customerId: plainCustomerId,
},
components: [
{
componentText: {
text: message,
},
},
],
attachmentIds,
});
if (error) {
log.error("Error creating plain contact form thread: ", safeStringify(error));
return NextResponse.json({ message: error.message }, { status: 500 });
}
return NextResponse.json(data);
} catch (err) {
log.error(`Error submitting plain contact form: `, safeStringify(err));
return NextResponse.json({ message: "Unexpected error occured" }, { status: 500 });
}
}
-84
View File
@@ -1,84 +0,0 @@
import { AttachmentType } from "@team-plain/typescript-sdk";
import { cookies, headers } from "next/headers";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { plain, upsertPlainCustomer } from "@lib/plain/plain";
const log = logger.getSubLogger({ prefix: ["/api/support/upload"] });
/**
* Returns a signed url from plain to upload the attachment
*/
export async function GET(req: NextRequest) {
if (!IS_PLAIN_CHAT_ENABLED) {
return NextResponse.json({ error: "Plain Chat is not enabled" }, { status: 404 });
}
const searchParams = req.nextUrl.searchParams;
const name = searchParams.get("name");
const size = searchParams.get("size");
if (!name || !size) {
return NextResponse.json({ error: "Missing required parameters: name and size" }, { status: 400 });
}
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.email) {
return NextResponse.json({ error: "Unauthorized - No session found" }, { status: 401 });
}
let plainCustomerId: string | null = null;
const plainCustomer = await plain.getCustomerByEmail({
email: session.user.email,
});
if (plainCustomer.data) {
plainCustomerId = plainCustomer.data.id;
} else {
const { data, error } = await upsertPlainCustomer({
name: session.user.name,
email: session.user.email,
id: session.user.id,
});
if (error) {
log.error("Error getting customer info: ", safeStringify(error));
return NextResponse.json({ error: error.message }, { status: 500 });
}
if (data) {
plainCustomerId = data.customer.id;
}
}
if (!plainCustomerId) {
return NextResponse.json({ error: "Plain customer not found" }, { status: 404 });
}
const { data, error } = await plain.createAttachmentUploadUrl({
customerId: plainCustomerId,
fileName: name,
fileSizeBytes: parseInt(size),
attachmentType: AttachmentType.CustomTimelineEntry,
});
if (error) {
log.error(`Error getting signed url for attachment upload: `, safeStringify(error));
return NextResponse.json(
{
error: error.message,
},
{ status: 500 }
);
}
return NextResponse.json(data);
}
-5
View File
@@ -2,14 +2,12 @@
import { TrpcProvider } from "app/_trpc/trpc-provider";
import { SessionProvider } from "next-auth/react";
import { usePathname } from "next/navigation";
import CacheProvider from "react-inlinesvg/provider";
import { WebPushProvider } from "@calcom/features/notifications/WebPushContext";
import { NotificationSoundHandler } from "@calcom/web/components/notification-sound-handler";
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
import PlainChat from "@lib/plain/dynamicProvider";
type ProvidersProps = {
isEmbed: boolean;
@@ -18,13 +16,10 @@ type ProvidersProps = {
};
export function Providers({ isEmbed, children, nonce }: ProvidersProps) {
const isBookingPage = useIsBookingPage();
const pathname = usePathname();
const isOnboardingPage = pathname?.startsWith("/getting-started");
return (
<SessionProvider>
<TrpcProvider>
{!isBookingPage && !isOnboardingPage ? <PlainChat nonce={nonce} /> : null}
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
@@ -37,7 +37,7 @@ const UserProfile = ({ user }: UserProfileProps) => {
const [imageSrc, setImageSrc] = useState<string>(user?.avatar || "");
const utils = trpc.useUtils();
const router = useRouter();
const createEventType = trpc.viewer.eventTypes.create.useMutation();
const createEventType = trpc.viewer.eventTypes.heavy.create.useMutation();
const telemetry = useTelemetry();
const [firstRender, setFirstRender] = useState(true);

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