Compare commits

...
Author SHA1 Message Date
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
github-actions[bot] f3630399ee chore: version packages (#23645)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-09-07 23:42:34 +00:00
Anik Dhabal Babu 5375c656af fix: unit test flake (#23648)
* fix: hosts is cancelling booking through apiV1

* fix: unit test flake

* Update booking.ts
2025-09-06 17:21:17 +00:00
Calcom BotandLingo.dev 1792d49634 feat: update translations via @LingoDotDev (#23644)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-06 15:41:11 +00:00
Rajiv Sahal de98578fdd fix: use disableToasts for handling notifications in EventTypeSettings atom (#23490)
* fix: use disableToasts for handling notifications

* add changesets
2025-09-06 19:56:31 +05:30
c0a59a2837 feat: implement recent impersonations list with localStorage storage (#23610)
* feat: implement recent impersonations list with localStorage storage

- Add RecentImpersonationsList component with quick action buttons
- Store up to 5 recent impersonations in localStorage using existing webstorage utility
- Integrate component into admin impersonation page above existing form
- Add translation keys for recent impersonations UI elements
- Update impersonation flow to save successful impersonations locally
- Follow existing List component patterns and button styling

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

* refactor: optimize RecentImpersonationsList to use lazy initial state

- Replace useEffect with useState lazy initializer for better performance
- Removes unnecessary re-render on component mount
- Remove unused setRecentImpersonations variable to fix ESLint warning
- Follows React best practices for initial state computation

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

* Add styling to match v3 and newer designs

* Update packages/lib/recentImpersonations.ts

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: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-09-06 19:56:08 +05:30
Benny Joo 29201006d5 perf: optimize public event query by using lightweight session lookup (#23617)
* refactor event endpoint

* export getSessino
2025-09-06 09:42:27 -03:00
Benny Joo 7a1c780088 refactor: Break @calcom/app-store/components into individual component files (#23637)
* wip

* get rid of app-store/components

* create independent files

* update imports
2025-09-06 09:39:26 -03:00
Anik Dhabal Babu 550f34c128 fix: all e2e flakes [WIP] (#23633)
* fix all e2e flakes

* Update workflow.e2e.ts
2025-09-06 15:16:24 +09:00
d6b17b76db test: add comprehensive unit tests for no-show fee payment functions (#23626)
* feat: add time-based cancellation fees for no-show fee events

- Add configurable time threshold (minutes/hours/days) for cancellation fees
- Show warnings during booking submission and cancellation
- Automatically charge fees when bookings cancelled within threshold
- Exempt organizer/admin cancellations from fees
- Extend existing no-show fee infrastructure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: replace any type with proper type guards for metadata

- Use Record<string, unknown> instead of any for metadata type
- Add proper type assertions for nested metadata properties
- Maintain type safety while avoiding ESLint no-explicit-any warning

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: handle JsonValue type compatibility in shouldChargeCancellationFee

- Add proper type guard for metadata JsonValue from Prisma
- Ensure metadata is object before casting to Record<string, unknown>
- Fixes TypeScript error on line 390 in handleCancelBooking.ts

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Refactor Devin changes to Stripe options

* Undo Devin changes to advanced tab

* Add translations

* Pass props to CancelBooking

* Display no show fee charge for attendee

* WIP

* Anstract shouldChargeNoSHowCancellationFee

* Abstract `handleNoShowFee`

* Add  to

* Refactor `chargeCard.handler`

* Remove Devin code

* Type fix in `shouldChargeNoShowCancellationFee`

* Create `processNoSHowFeeOnCancellation`

* Process no show fee on cancellation

* Type fix

* Skip processing no show fee if organizer or admin is cancelling

* Add translation

* Dynamically get  and  in

* Remove unused translations

* Undo dev change

* Refactor logic

* Type fix

* remove any

* revert WEBAPP_URL_FOR_OAUTH

* Clean up console.log remnants

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All 23 new tests pass successfully, covering:
- Time-based logic with different units (minutes, hours, days)
- Role-based exemptions (organizer, team admin vs regular attendee)
- Payment charging integration with HOLD payment option
- UI warning display during booking submission and cancellation
- Edge cases like invalid metadata and past bookings

Fixed lint issues:
- Replaced 'any' types with proper Record<string, unknown>
- Fixed Playwright test.skip() usage with ESLint disable comments

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All unit and integration tests pass successfully. E2E test implementation complete.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript issues and prettier formatting
- All tests follow existing Cal.com patterns and pass locally

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive unit and E2E tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions and attendee fee charging
- Add UI component tests for cancellation fee warning display in CancelBooking component
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript errors in CancelBooking.tsx with proper type annotations
- All tests follow existing Cal.com patterns and use proper mocking/test utilities

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Revert dev change

* Add missing translation

* Remove Devin code

* Revert Devin changes

* test: fix attendee cancellation test logic and ensure comprehensive test coverage

- Fix userId in attendee cancellation test to properly test fee charging
- Ensure all test files follow Cal.com patterns with proper mocking
- Add comprehensive coverage for time thresholds, role exemptions, and UI warnings
- All tests pass locally with proper timezone handling

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix tests

* test: add comprehensive unit tests for no-show fee payment functions

- Add unit tests for handleNoShowFee.ts covering successful scenarios, error handling, and edge cases
- Add unit tests for processNoShowFeeOnCancellation.ts covering cancellation logic and payment conditions
- Tests include proper mocking of dependencies and comprehensive coverage of all code paths
- All tests pass with TZ=UTC yarn test and type checking passes

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Don't block cancel flow for failed payment processing

* Await single attendee getTranslation in `handleNoShowFee`

* Add missing data

* Undo old strings

* Type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-09-05 23:01:52 +01:00
Udit TakkarandPeer Richelsen c42e803cfa chore: local testing retell (#23577)
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
2025-09-06 02:11:21 +05:30
Keith WilliamsandDevin AI 0233c3a11a fix: resolve Sentry version conflicts and import-in-the-middle error (#23551)
- Upgrade API v2 Sentry packages from v8.x to v9.15.0 to match web/API v1
- Add yarn resolution for import-in-the-middle to prevent version conflicts
- Standardize Sentry versions across the monorepo

Fixes import-in-the-middle package version mismatch error:
- Project was using 1.11.2 while Sentry v9.x requires 1.13.1
- Root cause: API v2 used Sentry v8.x while other apps used v9.x
- Solution: Upgrade all Sentry packages to v9.15.0 and force single import-in-the-middle version

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-09-05 17:12:02 -03:00
Keith Williams 71a2b6956f chore: Add .mdx to all checks ignore list (#23627)
* chore: Add .mdx to all checks ignore list

* Missing a *
2025-09-05 15:38:59 -03:00
Calcom BotandLingo.dev 8282557ebe feat: update translations via @LingoDotDev (#23631)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-05 17:54:29 +00:00
083a162d0e feat: add time-based cancellation fees for no-show fee events (#23595)
* feat: add time-based cancellation fees for no-show fee events

- Add configurable time threshold (minutes/hours/days) for cancellation fees
- Show warnings during booking submission and cancellation
- Automatically charge fees when bookings cancelled within threshold
- Exempt organizer/admin cancellations from fees
- Extend existing no-show fee infrastructure

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: replace any type with proper type guards for metadata

- Use Record<string, unknown> instead of any for metadata type
- Add proper type assertions for nested metadata properties
- Maintain type safety while avoiding ESLint no-explicit-any warning

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: handle JsonValue type compatibility in shouldChargeCancellationFee

- Add proper type guard for metadata JsonValue from Prisma
- Ensure metadata is object before casting to Record<string, unknown>
- Fixes TypeScript error on line 390 in handleCancelBooking.ts

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Refactor Devin changes to Stripe options

* Undo Devin changes to advanced tab

* Add translations

* Pass props to CancelBooking

* Display no show fee charge for attendee

* WIP

* Anstract shouldChargeNoSHowCancellationFee

* Abstract `handleNoShowFee`

* Add  to

* Refactor `chargeCard.handler`

* Remove Devin code

* Type fix in `shouldChargeNoShowCancellationFee`

* Create `processNoSHowFeeOnCancellation`

* Process no show fee on cancellation

* Type fix

* Skip processing no show fee if organizer or admin is cancelling

* Add translation

* Dynamically get  and  in

* Remove unused translations

* Undo dev change

* Refactor logic

* Type fix

* remove any

* revert WEBAPP_URL_FOR_OAUTH

* Clean up console.log remnants

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All 23 new tests pass successfully, covering:
- Time-based logic with different units (minutes, hours, days)
- Role-based exemptions (organizer, team admin vs regular attendee)
- Payment charging integration with HOLD payment option
- UI warning display during booking submission and cancellation
- Edge cases like invalid metadata and past bookings

Fixed lint issues:
- Replaced 'any' types with proper Record<string, unknown>
- Fixed Playwright test.skip() usage with ESLint disable comments

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow

All unit and integration tests pass successfully. E2E test implementation complete.

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions
- Add handleCancelBooking test for attendee fee charging within time threshold
- Add UI component tests for cancellation fee warning display
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript issues and prettier formatting
- All tests follow existing Cal.com patterns and pass locally

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: add comprehensive unit and E2E tests for time-based cancellation fees

- Add unit tests for shouldChargeCancellationFee function with time thresholds and role exemptions
- Add handleCancelBooking integration tests for organizer/team admin exemptions and attendee fee charging
- Add UI component tests for cancellation fee warning display in CancelBooking component
- Add E2E test for cancellation fee warning during booking flow
- Fix TypeScript errors in CancelBooking.tsx with proper type annotations
- All tests follow existing Cal.com patterns and use proper mocking/test utilities

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Revert dev change

* Add missing translation

* Remove Devin code

* Revert Devin changes

* test: fix attendee cancellation test logic and ensure comprehensive test coverage

- Fix userId in attendee cancellation test to properly test fee charging
- Ensure all test files follow Cal.com patterns with proper mocking
- Add comprehensive coverage for time thresholds, role exemptions, and UI warnings
- All tests pass locally with proper timezone handling

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix tests

* Remove reverted PR translations

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
2025-09-05 21:46:54 +04:00
Benny Joo 60487ea02e chore: remove unused code - sessionMiddleware (#23623) 2025-09-05 17:14:49 +00:00
Morgan 60a8841439 refactor: handle emails and sms side effects (#23578)
* refactor: handle emails and sms side effects

* fix: typo and import part

* fix: types import path

* fix: rename

* refactor: class with logger dependency

* fix: replace structured clone with cloneDeep

* fix: await send reschedule round robin notifs

* fixup! fix: await send reschedule round robin notifs

* fix: avoid oversharing data

* refactor and fix tests

* chore: bump platform lib

* chore: bump platform lib

* chore: use logger with event details

* chore: add try catches

* refactor: remove private methods to send sms and emails

* chore: address code review

* chore: bump platform lib

* chore: address code review
2025-09-05 19:08:35 +03:00
Keith Williams 184d1eb751 docs: Update docs for new Railway template (#23625) 2025-09-05 16:02:14 +00:00
Benny Joo 9fea30a44d perf: Delete viewer.public.session tRPC endpoint (#23615)
* delete unused files

* remove from router
2025-09-05 10:47:47 -03:00
Anshumancanrock 04efcbcdb6 fix: Switch component thumb positioning in RTL languages (#23600)
* Arabic RTL UI Fix

* optimal approach
2025-09-05 13:27:23 +00:00
Hariom Balhara 2b29b5607d Add logs to cancellation flow (#23585) 2025-09-05 13:25:57 +00:00
20f19c2509 docs: add getBusyTimes API documentation and update usage details (#22754)
* docs: add getBusyTimes API documentation and update usage details

* changes

---------

Co-authored-by: unknown <adhabal2002@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-09-05 18:23:07 +05:30
Calcom BotandLingo.dev 5d0e66763e feat: update translations via @LingoDotDev (#23611)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-05 14:07:41 +05:30
sean-brydon bcfbc24e93 add i18n (#23609) 2025-09-05 09:08:45 +01:00
Calcom BotandLingo.dev 37d805b007 feat: update translations via @LingoDotDev (#23608)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-09-05 09:35:22 +05:30
Alex van Andel 6a246e6c57 Revert "feat: enhance image upload validation across the application (#22766)"
This reverts commit 5acdb208f5.
2025-09-05 02:06:30 +01:00
383 changed files with 12396 additions and 7535 deletions
+8 -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
@@ -386,6 +381,11 @@ UNKEY_ROOT_KEY=
# Used for Cal.ai Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
# Local testing overrides for Retell AI
RETELL_AI_TEST_MODE=false
# JSON mapping of local to production event type IDs (e.g. {"50": 747280} without any string)
RETELL_AI_TEST_EVENT_TYPE_MAP=
RETELL_AI_TEST_CAL_API_KEY=
# Used for buying phone number for cal ai voice agent
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID=
+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/*"],
},
],
},
},
],
};
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
with:
filters: |
has-files-requiring-all-checks:
- "!(**.md|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json|i18n.lock)"
- "!(**.md|**.mdx|.github/CODEOWNERS|docs/**|help/**|apps/web/public/static/locales/**/common.json|i18n.lock)"
- name: Get Latest Commit SHA
id: get_sha
run: |
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
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,
+4 -21
View File
@@ -3,8 +3,6 @@ import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
import { uploadAvatar } from "@calcom/lib/server/avatar";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
@@ -126,25 +124,10 @@ export async function patchHandler(req: NextApiRequest) {
}
if (avatar) {
const validation = validateBase64Image(avatar);
if (!validation.isValid) {
throw new HttpError({
statusCode: 400,
message: `Invalid avatar image: ${validation.error}`,
});
}
try {
body.avatarUrl = await uploadAvatar({
userId: query.userId,
avatar: await resizeBase64Image(avatar),
});
} catch (error) {
throw new HttpError({
statusCode: 400,
message: error instanceof Error ? error.message : "Failed to upload avatar",
});
}
body.avatarUrl = await uploadAvatar({
userId: query.userId,
avatar: await (await import("@calcom/lib/server/resizeBase64Image")).resizeBase64Image(avatar),
});
}
const data = await prisma.user.update({
+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,
+4 -4
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.340",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.349",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -55,9 +55,9 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@nestjs/throttler": "6.2.1",
"@sentry/nestjs": "^8.37.1",
"@sentry/node": "^8.8.0",
"@sentry/profiling-node": "^8.37.1",
"@sentry/nestjs": "^9.15.0",
"@sentry/node": "^9.15.0",
"@sentry/profiling-node": "^9.15.0",
"@snyk/protect": "latest",
"body-parser": "^1.20.2",
"bull": "^4.12.4",
@@ -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,
@@ -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) {
@@ -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 -1
View File
@@ -9858,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",
@@ -9913,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": {
@@ -23638,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 />;
}
@@ -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(".");
}
-12
View File
@@ -6,7 +6,6 @@ import { z } from "zod";
import { AVATAR_FALLBACK, WEBAPP_URL } from "@calcom/lib/constants";
import { convertSvgToPng } from "@calcom/lib/server/imageUtils";
import { validateBase64Image } from "@calcom/lib/server/imageValidation";
import prisma from "@calcom/prisma";
const querySchema = z.object({
@@ -47,12 +46,6 @@ async function handler(req: NextRequest, { params }: { params: Promise<Params> }
},
});
const validation = validateBase64Image(data);
if (!validation.isValid) {
const url = new URL(AVATAR_FALLBACK, WEBAPP_URL).toString();
return NextResponse.redirect(url, 302);
}
// Convert SVG to PNG if needed and update the database
if (data.startsWith("data:image/svg+xml;base64,")) {
const pngData = await convertSvgToPng(data);
@@ -79,11 +72,6 @@ async function handler(req: NextRequest, { params }: { params: Promise<Params> }
"Content-Type": "image/png",
"Content-Length": imageResp.length.toString(),
"Cache-Control": "max-age=86400",
// Security headers to prevent XSS
"X-Content-Type-Options": "nosniff",
"Content-Disposition": "inline",
"X-Frame-Options": "DENY",
"Content-Security-Policy": "default-src 'none'; img-src 'self'",
},
status: 200,
});
-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>
+2 -1
View File
@@ -3,9 +3,10 @@ import { useRouter } from "next/navigation";
import type { IframeHTMLAttributes } from "react";
import React, { useEffect, useState } from "react";
import { AppDependencyComponent } from "@calcom/app-store/AppDependencyComponent";
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
import { isRedirectApp } from "@calcom/app-store/_utils/redirectApps";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { AppDependencyComponent, InstallAppButton } from "@calcom/app-store/components";
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
import DisconnectIntegration from "@calcom/features/apps/components/DisconnectIntegration";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
@@ -2,7 +2,7 @@
import { useEffect, Suspense } from "react";
import { InstallAppButton } from "@calcom/app-store/components";
import { InstallAppButton } from "@calcom/app-store/InstallAppButton";
import { DestinationCalendarSettingsWebWrapper } from "@calcom/atoms/destination-calendar/wrappers/DestinationCalendarSettingsWebWrapper";
import { SelectedCalendarsSettingsWebWrapper } from "@calcom/atoms/selected-calendars/wrappers/SelectedCalendarsSettingsWebWrapper";
import AppListCard from "@calcom/features/apps/components/AppListCard";
+63 -6
View File
@@ -6,10 +6,11 @@ import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { useRefreshData } from "@calcom/lib/hooks/useRefreshData";
import { useTelemetry } from "@calcom/lib/hooks/useTelemetry";
import { shouldChargeNoShowCancellationFee } from "@calcom/lib/payment/shouldChargeNoShowCancellationFee";
import { collectPageParameters, telemetryEventTypes } from "@calcom/lib/telemetry";
import type { RecurringEvent } from "@calcom/types/Calendar";
import { Button } from "@calcom/ui/components/button";
import { Label, Select, TextArea } from "@calcom/ui/components/form";
import { Label, Select, TextArea, CheckboxField } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
interface InternalNotePresetsSelectProps {
@@ -76,6 +77,12 @@ type Props = {
title?: string;
uid?: string;
id?: number;
startTime: Date;
payment?: {
amount: number;
currency: string;
appId: string | null;
} | null;
};
profile: {
name: string | null;
@@ -100,6 +107,7 @@ type Props = {
};
isHost: boolean;
internalNotePresets: { id: number; name: string; cancellationReason: string | null }[];
eventTypeMetadata?: Record<string, unknown> | null;
};
export default function CancelBooking(props: Props) {
@@ -112,13 +120,48 @@ export default function CancelBooking(props: Props) {
seatReferenceUid,
bookingCancelledEventProps,
currentUserEmail,
teamId,
eventTypeMetadata,
} = props;
const [loading, setLoading] = useState(false);
const telemetry = useTelemetry();
const [error, setError] = useState<string | null>(booking ? null : t("booking_already_cancelled"));
const [internalNote, setInternalNote] = useState<{ id: number; name: string } | null>(null);
const [acknowledgeCancellationNoShowFee, setAcknowledgeCancellationNoShowFee] = useState(false);
const getAppMetadata = (appId: string): Record<string, unknown> | null => {
if (!eventTypeMetadata?.apps || !appId) return null;
const apps = eventTypeMetadata.apps as Record<string, unknown>;
return (apps[appId] as Record<string, unknown>) || null;
};
const timeValue = booking?.payment?.appId
? (getAppMetadata(booking.payment.appId) as Record<string, unknown> | null)?.autoChargeNoShowFeeTimeValue
: null;
const timeUnit = booking?.payment?.appId
? (getAppMetadata(booking.payment.appId) as Record<string, unknown> | null)?.autoChargeNoShowFeeTimeUnit
: null;
const autoChargeNoShowFee = () => {
if (props.isHost) return false; // Hosts/organizers are exempt
if (!booking?.startTime) return false;
if (!booking?.payment) return false;
return shouldChargeNoShowCancellationFee({
eventTypeMetadata: eventTypeMetadata || null,
booking,
payment: booking.payment,
});
};
const cancellationNoShowFeeWarning = autoChargeNoShowFee();
const hostMissingCancellationReason =
props.isHost &&
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id));
const cancellationNoShowFeeNotAcknowledged =
!props.isHost && cancellationNoShowFeeWarning && !acknowledgeCancellationNoShowFee;
const cancelBookingRef = useCallback((node: HTMLTextAreaElement) => {
if (node !== null) {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- CancelBooking is not usually used in embed mode
@@ -187,6 +230,23 @@ export default function CancelBooking(props: Props) {
</p>
</div>
) : null}
{cancellationNoShowFeeWarning && booking?.payment && (
<div>
<div className="bg-attention mb-5 rounded-md p-3">
<CheckboxField
description={t("cancel_booking_acknowledge_no_show_fee", {
timeValue,
timeUnit,
amount: booking.payment.amount / 100,
formatParams: { amount: { currency: booking.payment.currency } },
})}
onChange={(e) => setAcknowledgeCancellationNoShowFee(e.target.checked)}
descriptionClassName="text-info font-semibold"
/>
<p className="text-subtle ml-9 mt-2 text-sm">{t("contact_organizer")}</p>
</div>
</div>
)}
<div className="flex flex-col-reverse rtl:space-x-reverse ">
<div className="ml-auto flex w-full space-x-4 ">
<Button
@@ -197,10 +257,7 @@ export default function CancelBooking(props: Props) {
</Button>
<Button
data-testid="confirm_cancel"
disabled={
props.isHost &&
(!cancellationReason?.trim() || (props.internalNotePresets.length > 0 && !internalNote?.id))
}
disabled={hostMissingCancellationReason || cancellationNoShowFeeNotAcknowledged}
onClick={async () => {
setLoading(true);
@@ -0,0 +1,257 @@
import { render, screen } from "@testing-library/react";
import * as React from "react";
import { describe, expect, it, vi, beforeAll } from "vitest";
import * as shouldChargeModule from "@calcom/lib/payment/shouldChargeNoShowCancellationFee";
import CancelBooking from "../CancelBooking";
beforeAll(() => {
Element.prototype.scrollIntoView = vi.fn();
});
vi.mock("@calcom/trpc", () => ({
trpc: {
viewer: {
bookings: {
requestReschedule: {
useMutation: () => ({
mutate: vi.fn(),
isLoading: false,
}),
},
},
},
},
}));
vi.mock("next-i18next", () => ({
useTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === "cancellation_fee_warning_cancel") {
const opts = options as {
time?: string;
unit?: string;
amount?: number;
formatParams?: { amount?: { currency?: string } };
};
return `Cancelling within ${opts?.time} ${opts?.unit} will result in a ${opts?.amount} ${opts?.formatParams?.amount?.currency} cancellation fee being charged to your card.`;
}
return key;
},
}),
}));
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => ({
t: (key: string, options?: Record<string, unknown>) => {
if (key === "cancel_booking_acknowledge_no_show_fee") {
const opts = options as {
timeValue?: number;
timeUnit?: string;
amount?: number;
formatParams?: { amount?: { currency?: string } };
};
return `I acknowledge that cancelling within ${opts?.timeValue} ${opts?.timeUnit} will result in a ${opts?.amount} ${opts?.formatParams?.amount?.currency} cancellation fee being charged to my card.`;
}
return key;
},
}),
}));
vi.mock("@calcom/lib/hooks/useTelemetry", () => ({
useTelemetry: () => ({
event: vi.fn(),
}),
}));
vi.mock("@calcom/lib/hooks/useRefreshData", () => ({
useRefreshData: () => vi.fn(),
}));
vi.mock("next/router", () => ({
useRouter: () => ({
push: vi.fn(),
query: {},
}),
}));
vi.mock("@calcom/lib/payment/shouldChargeNoShowCancellationFee", () => ({
shouldChargeNoShowCancellationFee: vi.fn(),
}));
const mockBookingWithCancellationFee = {
uid: "test-booking-uid",
title: "Test Meeting",
id: 123,
startTime: new Date(Date.now() + 30 * 60 * 1000),
payment: {
amount: 1000,
currency: "usd",
appId: "stripe",
},
};
const mockEventTypeMetadataWithFee = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "HOLD" as const,
},
},
};
const mockEventTypeMetadataWithoutFee = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "HOLD" as const,
},
},
};
const mockProps = {
recurringEvent: null,
setIsCancellationMode: vi.fn(),
theme: "light",
allRemainingBookings: false,
seatReferenceUid: undefined,
currentUserEmail: "test@example.com",
bookingCancelledEventProps: {
booking: {},
organizer: {
name: "Test Organizer",
email: "organizer@example.com",
timeZone: "UTC",
},
eventType: {},
},
internalNotePresets: [],
};
const mockBookingWithoutCancellationFee = {
uid: "test-booking-uid-2",
title: "Test Meeting 2",
id: 124,
startTime: new Date(Date.now() + 2 * 60 * 60 * 1000),
payment: {
amount: 1000,
currency: "usd",
appId: "stripe",
},
};
describe("CancelBooking Cancellation Fee Warning", () => {
it("should show cancellation fee warning when booking is within time threshold", () => {
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(true);
render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={mockEventTypeMetadataWithFee}
{...mockProps}
/>
);
expect(
screen.getByText(/I acknowledge that cancelling within 1 hours will result in a/)
).toBeInTheDocument();
});
it("should not show cancellation fee warning when booking is outside time threshold", () => {
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
render(
<CancelBooking
booking={mockBookingWithoutCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={mockEventTypeMetadataWithoutFee}
{...mockProps}
/>
);
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});
it("should not show cancellation fee warning when user is host", () => {
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={true}
eventTypeMetadata={mockEventTypeMetadataWithFee}
{...mockProps}
/>
);
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});
it("should not show cancellation fee warning when cancellation fee is disabled", () => {
const eventTypeMetadataWithDisabledFee = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: false,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "HOLD" as const,
},
},
};
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={eventTypeMetadataWithDisabledFee}
{...mockProps}
/>
);
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});
it("should not show cancellation fee warning when payment option is not HOLD", () => {
const eventTypeMetadataWithOnBookingPayment = {
apps: {
stripe: {
autoChargeNoShowFeeIfCancelled: true,
autoChargeNoShowFeeTimeValue: 1,
autoChargeNoShowFeeTimeUnit: "hours" as const,
paymentOption: "ON_BOOKING" as const,
},
},
};
vi.mocked(shouldChargeModule.shouldChargeNoShowCancellationFee).mockReturnValue(false);
render(
<CancelBooking
booking={mockBookingWithCancellationFee}
profile={{ name: "Test User", slug: "test-user" }}
team={null}
isHost={false}
eventTypeMetadata={eventTypeMetadataWithOnBookingPayment}
{...mockProps}
/>
);
expect(screen.queryByText(/I acknowledge that cancelling within/)).not.toBeInTheDocument();
});
});
@@ -1,8 +1,8 @@
import Link from "next/link";
import { useState } from "react";
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/InstallAppButtonWithoutPlanCheck";
import type { TDependencyData } from "@calcom/app-store/_appRegistry";
import { InstallAppButtonWithoutPlanCheck } from "@calcom/app-store/components";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
@@ -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);
@@ -1,236 +0,0 @@
"use client";
import { Dispatch, SetStateAction, useState } from "react";
import { Button } from "@calcom/ui/components/button";
import { FileUploader, type FileData } from "@calcom/ui/components/file-uploader";
import { Label, TextArea } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Popover, PopoverContent, PopoverTrigger } from "@calcom/ui/components/popover";
import { showToast } from "@calcom/ui/components/toast";
const PlainContactForm = ({ open, setIsOpen }: { open: boolean, setIsOpen: Dispatch<SetStateAction<boolean>> }) => {
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [isUploadingImage, setIsUploadingImage] = useState(false);
const [data, setData] = useState<{
message: string;
attachmentIds: string[];
}>({
message: "",
attachmentIds: [],
});
const [uploads, setUploads] = useState<
{
attachmentId?: string;
uploading: boolean;
file: File;
id: string;
}[]
>([]);
const handleUpload = async (allFiles: FileData[], newFiles: FileData[], removedFiles: FileData[]) => {
if (newFiles.length > 0) {
const newFile = newFiles[0];
setUploads((prev) => [...prev, { file: newFile.file, uploading: true, id: newFile.id }]);
setIsUploadingImage(true);
const { file } = newFile;
const res = await fetch(`/api/support/upload?name=${file.name}&size=${file.size}`);
if (!res.ok) {
showToast("Error uploading attachment", "error");
setIsUploadingImage(false);
return;
}
const {
uploadFormUrl,
uploadFormData,
attachment: { id: attachmentId },
} = await res.json();
setIsUploadingImage(false);
const formData = new FormData();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
uploadFormData.forEach(({ key, value }: any) => {
formData.append(key, value);
});
formData.append("file", file);
const uploadRes = await fetch(uploadFormUrl, {
method: "POST",
body: formData,
});
if (!uploadRes.ok) {
showToast(`Failed while uploading file: ${uploadRes.text}`, "error");
setIsUploadingImage(false);
return;
}
setUploads((prev) =>
prev.map((upload) => (upload.file === file ? { ...upload, uploading: false, attachmentId } : upload))
);
setData((prev) => ({
...prev,
attachmentIds: [...prev.attachmentIds, attachmentId],
}));
setIsUploadingImage(false);
showToast("File uploaded successfully", "success");
} else if (removedFiles.length > 0) {
const removedFile = removedFiles[0];
const file = uploads.find((upload) => upload.id === removedFile.id);
if (!file) {
console.warn("File not found in uploads: ", removedFile.id);
return;
}
setData((prev) => ({
...prev,
attachmentIds: prev.attachmentIds.filter((id) => id !== file.attachmentId),
}));
setUploads((prev) => prev.filter((upload) => upload.id !== removedFile.id));
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
const response = await fetch("/api/support", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json();
showToast(errorData.message ?? "Failed to submit contact form", "error");
setIsSubmitting(false);
return;
}
setIsSubmitted(true);
setIsSubmitting(false);
setData({
message: "",
attachmentIds: [],
});
} catch (err) {
showToast(err instanceof Error ? err.message : "An error occurred", "error");
setIsSubmitting(false);
}
};
const resetForm = () => {
setIsSubmitted(false);
setData({
message: "",
attachmentIds: [],
});
setUploads([]);
};
return (
<div className="fixed bottom-[1rem] right-[1rem] z-50">
<Popover open={open} onOpenChange={setIsOpen}>
<PopoverTrigger asChild className="enabled:hover:bg-subtle bg-subtle shadow-none">
<Button
onClick={() => setIsOpen(true)}
className="bg-subtle text-emphasis flex h-12 w-12 items-center justify-center rounded-full border-none">
<Icon name="message-circle" className="h-6 w-6" />
</Button>
</PopoverTrigger>
<PopoverContent
style={{ maxWidth: "450px", maxHeight: "650px" }}
className="!bg-muted no-scrollbar mb-2 mr-8 w-[450px] overflow-hidden overflow-y-scroll px-6 py-4">
<div className="flex w-full justify-between">
<p className="mb-5 text-lg font-semibold">Contact support</p>
<Button
color="minimal"
variant="button"
StartIcon="x"
size="sm"
onClick={() => setIsOpen(false)}
/>
</div>
<div>
{isSubmitted ? (
<div className="py-4 text-center">
<h4 className="mb-2 text-lg font-medium ">Message Sent</h4>
<p className="text-subtle mb-4 text-sm">
Thank you for contacting us. We&apos;ll get back to you as soon as possible.
</p>
<Button color="primary" className="my-2" onClick={resetForm} variant="button" size="base">
Send Another Message
</Button>
</div>
) : (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<Label htmlFor="message">Describe the issue</Label>
<TextArea
id="message"
name="message"
value={data.message}
onChange={(e) => setData((prev) => ({ ...prev, message: e.target.value }))}
placeholder="Please describe the issue you're facing, e.g. 'Busy slots are marked available', ..., etc."
required
rows={4}
/>
</div>
<div>
<Label>Attachments (optional)</Label>
<FileUploader
id="contact-attachments"
buttonMsg="Add Files"
onFilesChange={handleUpload}
acceptedFileTypes={["images", "videos"]}
multiple={false}
showFilesList
maxFiles={5}
maxFileSize={10 * 1024 * 1024}
disabled={isSubmitting || isUploadingImage}
testId="contact-form-file-upload"
/>
</div>
<div className="mt-4 flex w-full items-center">
<Button
color="secondary"
variant="button"
type="submit"
disabled={isSubmitting || isUploadingImage}
className="w-full">
<div className="flex w-full justify-center">
{isSubmitting ? (
<div className="flex items-center">
<Icon name="loader" className="mr-2 h-4 w-4 animate-spin rounded-full" />
Sending
</div>
) : (
<>
<Icon name="send" className="mr-2 h-4 w-4" />
Send Message
</>
)}
</div>
</Button>
</div>
</form>
)}
</div>
</PopoverContent>
</Popover>
</div>
);
};
export default PlainContactForm;
@@ -1,181 +0,0 @@
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { useSession } from "next-auth/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { showToast } from "@calcom/ui/components/toast";
import PlainContactForm from "../PlainContactForm";
vi.mock("next-auth/react", () => ({
useSession: vi.fn(),
}));
vi.mock("@calcom/ui/components/toast", () => ({
showToast: vi.fn(),
}));
const mockUseSession = vi.mocked(useSession);
const mockShowToast = vi.mocked(showToast);
const mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
describe("PlainContactForm", () => {
let mockSetIsOpen: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockSetIsOpen = vi.fn();
mockUseSession.mockReturnValue({
data: {
hasValidLicense: true,
upId: "test-up-id",
expires: "2025-12-31T23:59:59.999Z",
user: {
id: 123,
name: "Test User",
email: "test@example.com",
},
},
status: "authenticated",
update: vi.fn(),
} as any);
});
it("renders the contact button when closed", () => {
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
const button = screen.getByRole("button");
expect(button).toBeInTheDocument();
});
it("opens when the launcher button is clicked (calls setIsOpen(true))", () => {
render(<PlainContactForm open={false} setIsOpen={mockSetIsOpen} />);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(mockSetIsOpen).toHaveBeenCalledWith(true);
});
it("shows the form when open=true", () => {
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
expect(screen.getByText("Contact support")).toBeInTheDocument();
expect(screen.getByLabelText("Describe the issue")).toBeInTheDocument();
expect(screen.getByText("Attachments (optional)")).toBeInTheDocument();
});
it("shows empty form initially", () => {
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
expect(messageInput.value).toBe("");
});
it("closes when X button is clicked (calls setIsOpen(false))", () => {
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
const buttons = screen.getAllByRole("button");
const closeButton = buttons.find((button) => button.querySelector('svg use[href="#x"]'));
expect(closeButton).toBeDefined();
fireEvent.click(closeButton!);
expect(mockSetIsOpen).toHaveBeenCalledWith(false);
});
it("handles form submission successfully", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
});
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
fireEvent.change(screen.getByLabelText("Describe the issue"), {
target: { value: "Test message" },
});
const submitButton = screen.getByRole("button", { name: /send message/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText("Message Sent")).toBeInTheDocument();
});
expect(mockFetch).toHaveBeenCalledWith("/api/support", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "Test message",
attachmentIds: [],
}),
});
});
it("shows loading state during submission", async () => {
// never resolves → stays loading
mockFetch.mockImplementation(() => new Promise((_resolve) => {}));
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
fireEvent.change(screen.getByLabelText("Describe the issue"), {
target: { value: "Test message" },
});
const submitButton = screen.getByRole("button", { name: /send message/i });
fireEvent.click(submitButton);
expect(screen.getByText("Sending")).toBeInTheDocument();
await expect(submitButton).toBeDisabled();
});
it("resets form after successful submission", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ success: true }),
});
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
fireEvent.change(screen.getByLabelText("Describe the issue"), {
target: { value: "Test message" },
});
fireEvent.click(screen.getByRole("button", { name: /send message/i }));
await waitFor(() => {
expect(screen.getByText("Message Sent")).toBeInTheDocument();
});
const sendAnotherButton = screen.getByRole("button", { name: /send another message/i });
fireEvent.click(sendAnotherButton);
await waitFor(() => {
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
expect(messageInput.value).toBe("");
});
});
it("handles missing user session", () => {
mockUseSession.mockReturnValue({
data: null,
status: "unauthenticated",
update: vi.fn(),
} as any);
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
const messageInput = screen.getByLabelText("Describe the issue") as HTMLTextAreaElement;
expect(messageInput.value).toBe("");
});
it("requires message field", async () => {
render(<PlainContactForm open={true} setIsOpen={mockSetIsOpen} />);
await expect(screen.getByLabelText("Describe the issue")).toHaveAttribute("required");
});
});
-327
View File
@@ -1,327 +0,0 @@
"use client";
import { useSession } from "next-auth/react";
import { usePathname, useSearchParams } from "next/navigation";
import Script from "next/script";
import { useEffect, useState, useCallback, useMemo } from "react";
import { IS_PLAIN_CHAT_ENABLED } from "@calcom/lib/constants";
import PlainContactForm from "./PlainContactForm";
declare global {
interface Window {
Plain?: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
init: (config: any) => void;
open: () => void;
};
plainScriptLoaded?: () => void;
__PLAIN_CONFIG__?: PlainChatConfig;
}
}
interface PlainChatConfig {
appId: string;
customerDetails: {
email: string;
emailHash: string;
fullName: string;
shortName: string;
chatAvatarUrl: string;
};
links: Array<{
icon: string;
text: string;
url: string;
}>;
chatButtons: Array<{
icon: string;
text: string;
threadDetails?: {
labelTypeIds: Array<string>;
tierIdentifier: {
externalId: string;
};
};
form?: {
fields: Array<{
type: string;
placeholder: string;
options: Array<{
icon: string;
text: string;
threadDetails: {
labelTypeIds: Array<string>;
tierIdentifier: {
externalId: string;
};
};
onClick?: () => void;
}>;
}>;
};
}>;
entryPoint: {
type: string;
};
hideBranding: boolean;
theme: string;
style: {
brandColor: string;
launcherBackgroundColor: string;
launcherIconColor: string;
};
position: {
zIndex: string;
bottom: string;
right: string;
};
}
const PlainChat = IS_PLAIN_CHAT_ENABLED
? ({ nonce }: { nonce: string | undefined }) => {
const [config, setConfig] = useState<PlainChatConfig | null>(null);
const [isSmallScreen, setIsSmallScreen] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const { data: session } = useSession();
const pathname = usePathname();
const searchParams = useSearchParams();
const shouldOpenPlain = pathname === "/event-types" && searchParams?.has("openPlain");
const userEmail = session?.user?.email;
const isPaidUser = session?.user.belongsToActiveTeam || !!session?.user.org;
const isAppDomain = useMemo(() => {
const restrictedPathsSet = new Set(
(process.env.NEXT_PUBLIC_PLAIN_CHAT_EXCLUDED_PATHS?.split(",") || []).map((path) => path.trim())
);
const pathSegments = pathname?.split("/").filter(Boolean) || [];
return (
typeof window !== "undefined" &&
window.location.origin === process.env.NEXT_PUBLIC_WEBAPP_URL &&
!pathSegments.some((segment) => restrictedPathsSet.has(segment))
);
}, [pathname]);
const checkScreenSize = useCallback(() => {
if (typeof window === "undefined") return;
const isSmall = window.innerWidth < 768;
setIsSmallScreen(isSmall);
if (isSmall && window.Plain) {
const plainElement = document.querySelector("#plain-container");
plainElement?.remove();
window.Plain = undefined;
} else if (!isSmall && window.Plain === undefined) {
window.plainScriptLoaded?.();
}
}, []);
const initConfig = useCallback(async () => {
if (!userEmail) return;
// Check if Plain Chat is enabled
if (!IS_PLAIN_CHAT_ENABLED) {
return;
}
try {
const response = await fetch("/api/plain-hash", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to generate hash: ${errorText}`);
}
const data = await response.json();
if (!data.hash || !data.email || !data.appId) {
throw new Error("Missing required fields in API response");
}
const plainChatConfig: PlainChatConfig = {
appId: data.appId,
customerDetails: {
email: data.email,
shortName: data.shortName,
fullName: data.fullName,
emailHash: data.hash,
chatAvatarUrl: data.chatAvatarUrl,
},
links: [
{
icon: "book",
text: "Documentation",
url: "https://cal.com/docs",
},
{
icon: "chat",
text: "Ask the community",
url: "https://github.com/calcom/cal.com/discussions",
},
],
chatButtons: [
{
icon: "bulb",
text: "Send feedback",
threadDetails: {
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
tierIdentifier: { externalId: data.userTier },
},
},
{
icon: "error",
text: "Report an issue",
form: {
fields: [
{
type: "dropdown",
placeholder: "Select severity...",
options: [
{
icon: "support",
text: "I'm unable to use the app",
threadDetails: {
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
tierIdentifier: { externalId: data.userTier },
},
},
{
icon: "error",
text: "Major functionality degraded",
threadDetails: {
labelTypeIds: ["lt_01JFJWP3KECF1YQES6XF212RFW"],
tierIdentifier: { externalId: data.userTier },
},
},
{
icon: "bug",
text: "Minor annoyance",
threadDetails: {
labelTypeIds: ["lt_01JFJWPC8ADW0PK28JHMJR6NSS"],
tierIdentifier: { externalId: data.userTier },
},
},
],
},
],
},
},
],
entryPoint: {
type: "chat",
},
hideBranding: true,
theme: "auto",
style: {
brandColor: "#FFFFFF",
launcherBackgroundColor: "#262626",
launcherIconColor: "#FFFFFF",
},
position: {
zIndex: "1",
bottom: "20px",
right: "20px",
},
};
plainChatConfig.chatButtons.push({
icon: "chat",
text: "Ask a question",
threadDetails: {
labelTypeIds: ["lt_01JFJWNWAC464N8DZ6YE71YJRF"],
tierIdentifier: { externalId: data.userTier },
},
});
if (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") {
window.__PLAIN_CONFIG__ = plainChatConfig;
}
setConfig(plainChatConfig);
if (shouldOpenPlain) {
const timer = setTimeout(() => {
if (window.Plain) {
window.Plain.open();
}
}, 100);
return () => clearTimeout(timer);
}
} catch (error) {
console.error("Failed to initialize Plain Chat:", error);
}
}, [userEmail, shouldOpenPlain]);
useEffect(() => {
if (!isAppDomain) return;
// Skip initialization if Plain Chat is not enabled
if (!IS_PLAIN_CHAT_ENABLED) return;
checkScreenSize();
window.addEventListener("resize", checkScreenSize);
if (isPaidUser) {
initConfig();
} else if (typeof window !== "undefined" && !window.Plain) {
window.Plain = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
init: () => {},
open: () => {
setIsOpen(true);
}
};
}
return () => window.removeEventListener("resize", checkScreenSize);
}, [isAppDomain, checkScreenSize, initConfig, userEmail, isPaidUser]);
const plainChatScript = `
window.plainScriptLoaded = function() {
if (window.Plain && ${Boolean(config)}) {
try {
Plain.init(${config ? JSON.stringify(config) : null});
} catch (error) {
console.error("Failed to initialize Plain:", error);
}
}
}
`;
if (!isAppDomain || isSmallScreen || typeof window === "undefined") return null;
if (!isPaidUser) {
return <PlainContactForm open={isOpen} setIsOpen={setIsOpen} />;
}
if (!config) return null;
return (
<>
<Script
nonce={nonce}
id="plain-chat"
src="https://chat.cdn-plain.com/index.js"
strategy="afterInteractive"
onLoad={() => window.plainScriptLoaded?.()}
/>
<Script
nonce={nonce}
id="plain-chat-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{ __html: plainChatScript }}
/>
</>
);
}
: () => null;
export default PlainChat;
@@ -0,0 +1,79 @@
"use client";
import { signIn } from "next-auth/react";
import { useState } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getRecentImpersonations, type RecentImpersonation } from "@calcom/lib/recentImpersonations";
import { Button } from "@calcom/ui/components/button";
import { PanelCard } from "@calcom/ui/components/card";
import {
TableNew,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@calcom/ui/components/table";
interface RecentImpersonationsListProps {
onImpersonate?: (username: string) => void;
}
export default function RecentImpersonationsList({ onImpersonate }: RecentImpersonationsListProps) {
const { t } = useLocale();
const [recentImpersonations] = useState<RecentImpersonation[]>(() => getRecentImpersonations());
const handleQuickImpersonate = (username: string) => {
if (onImpersonate) {
onImpersonate(username);
} else {
signIn("impersonation-auth", {
username: username.toLowerCase(),
callbackUrl: `${WEBAPP_URL}/event-types`,
});
}
};
if (recentImpersonations.length === 0) {
return null;
}
return (
<PanelCard title={t("recent_impersonations")}>
<div className="overflow-x-auto">
<TableNew className="border-0">
<TableHeader>
<TableRow>
<TableHead className="w-[40%]">User</TableHead>
<TableHead>Impersonated At</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentImpersonations.map((impersonation) => (
<TableRow key={impersonation.username}>
<TableCell className="font-medium">{impersonation.username}</TableCell>
<TableCell className="text-muted-foreground">
{new Date(impersonation.timestamp).toLocaleDateString()}{" "}
{new Date(impersonation.timestamp).toLocaleTimeString()}
</TableCell>
<TableCell className="text-right">
<Button
type="button"
color="secondary"
size="sm"
onClick={() => handleQuickImpersonate(impersonation.username)}
data-testid={`quick-impersonate-${impersonation.username}`}>
{t("quick_impersonate")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</TableNew>
</div>
</PanelCard>
);
}

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