Compare commits

...
Author SHA1 Message Date
emrysal bfacd8a780 chore: release v5.6.9
Post release / release (push) Canceled after 0s
2025-08-29 04:31:17 +00:00
d4bff9d6b1 feat: Cal.ai Self Serve #2 (#22995)
* feat: Cal.ai Self Serve #2

* chore: fix import and remove logs

* fix: update checkout session

* fix: type errors and test

* fix: imports

* fix: type err

* fix: type error

* fix: tests

* chore: save progress

* fix: workflow flow

* fix: workflow update bug

* tests: add unit tests for retell ai webhoo

* fix: status code

* fix: test and delete bug

* fix: add dynamic variables

* fix: type err

* chore: update unit test

* fix: type error

* chore: update default prompt

* fix: type errors

* fix: workflow permissions

* fix: workflow page

* fix: translations

* feat: add call duration

* chore: add booking uid

* fix: button positioning

* chore: update tests

* chore: improvements

* chore: some more improvements

* refactor: improvements

* refactor: code feedback

* refactor: improvements

* feat: enable credits for orgs (#23077)

* Show credits UI for orgs

* fix stripe callback url when buying credits

* give orgs 20% credits

* add test for calulating credits

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>

* fix: types

* fix: types

* chore: error

* fix: type error

* fix: type error

* chore: mock env

* feat: add idempotency key to prevent double charging

* chore: add userId and teamId

* fix: skip inbound calls

* chore: update tests

* feat: add feature flag for voice agent

* feat: finish test call and other improvements

* chore: add alert

* chore: update .env.example

* chore: improvements

* fix: update tests

* refactor: remove un necessary

* feat: add setup badge

* chore: improvements

* fix: use referene id

* chore: improvements

* fix: type error

* fix: type

* refactor: change pricing logic

* refactor: update tests

* fix: conflicts

* fix: billing link for orgs

* fix: types

* refactor: move feature flag up

* fix: alert and test call credit check

* fix: update unit tests

* fix: feedback

* refactor: improvements

* refactor: move handlers to separate files

* fix: types

* fix: missing import

* fix: type

* refactor: change general tools functions handling

* refactor: use repository

* refactor: improvements

* fix: types

* fix: type errorr

* fix: auth check

* feat: add creditFor

* fix: update defualt prompt

* fix: throw error on frontend

* fix: update unit tests

* fix: use deleteAllWorkflowReminders

* refactor: add connect phone number

* refactor: improvements

* chore: translation

* chore: update message

* chore: translation

* design improvements buy number dialog

* add translation for error message

* use translation key in error message

* refactor: improve connect phone number tab

* feat: support un saved workflow to tests

* chore: remove un used

* fix: remove un used

* fix: remove un used

* refactor: similify billing

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-29 05:04:05 +01:00
6defa1cd7d refactor: replace @prisma/client/runtime imports with public API (#23087)
- Replace PrismaClientKnownRequestError and other error classes with Prisma namespace equivalents
- Remove internal DefaultArgs and InternalArgs type imports from Prisma extensions
- Ensure all error handling uses stable public API exports
- Maintain compatibility with future Prisma versions by avoiding runtime dependencies

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-29 03:09:52 +01:00
Benny Joo 5b2b5fd967 chore: remove unused code including /api/nope (#23323)
* remove some unused code

* remove /api/nope
2025-08-29 01:09:02 +00:00
Vitor Monteiro 6932498d48 fix: event type empty title validation (#23220) 2025-08-28 19:22:59 +00:00
Anirban SinghaandKartik Saini e07f800417 fix: incorrect placeholder for start of week (#23402)
* fix: incorrect placeholder for start of week

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

* Trigger workflows

---------

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-28 23:20:33 +05:30
Anik Dhabal Babu 464e5f1496 Revert "chore: Add zoom waiting room setting (#22888)" (#23427)
This reverts commit c64b663514.
2025-08-28 16:16:57 +00:00
724e3f2755 fix: type error in the EventAvailabilityTabPlatformWrapper component (#21983)
* fix: add required props in scheduleQueryData

* chore: remove any to fix runtime type checking

* chore: add a fallback to avoid runtime err

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-28 16:11:51 +00:00
Eunjae LeeandDevin AI 59c76141ce feat: add Recent No-Show Guests chart to insights page (#23381)
* feat: add Recent No-Show Guests chart to insights page

- Add RecentNoShowGuestsChart component with ChartCard wrapper
- Add tRPC handler for recentNoShowGuests query
- Add getRecentNoShowGuests method to InsightsBookingBaseService
- Display guest name, booking time, event type, and copy email button
- Filter for bookings where ALL attendees are no-shows
- Add translation strings for new UI elements
- Integrate chart into insights view grid layout

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

* re-order charts

* clean up

* feat: add optional tooltip functionality to PanelCard

- Add titleTooltip prop to PanelCard component with InfoBadge
- Pass through titleTooltip prop in ChartCard
- Add tooltip to RecentNoShowGuestsChart explaining complete no-show filtering
- Add translation string for tooltip explanation

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

* style adjustment

* update style

* address feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-28 14:01:27 +00:00
Eunjae LeeandDevin AI a494428334 refactor: FailedBookingsByField to use Insights Routing Service (#23259)
* refactor: FailedBookingsByField to use Insights Routing Service

- Move getFailedBookingsByRoutingFormGroup logic to InsightsRoutingBaseService
- Replace legacy getWhereForTeamOrAllTeams with getBaseConditions()
- Add failedBookingsByFieldInputSchema with date filtering support
- Update tRPC endpoint to use createInsightsRoutingService pattern
- Update component to pass startDate/endDate parameters
- Remove legacy method from routing-events.ts

Follows same pattern as PR #23031 for RoutedToPerPeriod refactoring

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

* fix: use useInsightsRoutingParameters hook in FailedBookingsByField

- Add missing import for useInsightsRoutingParameters
- Update component to follow same pattern as PR #23031
- Add columnFilters support to failedBookingsByFieldInputSchema
- Maintain routingFormId parameter from useInsightsParameters

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

* refactor: remove separate routingFormId parameter

- Remove routingFormId parameter from getFailedBookingsByFieldData method
- Update component to only use insightsRoutingParameters
- Remove routingFormId from failedBookingsByFieldInputSchema
- Let getBaseConditions handle all filtering including routing form filtering
- Simplify tRPC endpoint to not pass separate routingFormId

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

* clean up

* fix

* refactor: use RoutingFormResponseDenormalized directly in getFailedBookingsByFieldData

- Replace complex CTE approach with direct denormalized table query
- Join with App_RoutingForms_Form to access field definitions from JSON
- Use RoutingFormResponseField for response values
- Filter failed bookings with bookingUid IS NULL
- Maintain same data structure for UI compatibility
- Follow existing service patterns for authorization and date filtering

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

* fixing failed bookings query

* fix query

* fix integration test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-28 22:26:32 +09:00
Eunjae Lee 7145a2d640 refactor: Routed to Per Period to use Insights Routing Service (#23031)
* fix: refactor RoutedToPerPeriod to use Insights Routing Service

* clean up

* debounce search and increase stale time

* fix wrongn conflict merge

* clean up DownloadButton

* fix query error

* address feedback

* apply feedback

* remove unnecessary CTE

* fix limit

* make startDate and endDate required

* fix type

* remove irrelevant test case

* address feedback
2025-08-28 22:25:55 +09:00
Keith WilliamsandDevin AI baaa04151d perf: optimize payment app imports to avoid loading entire app store (#23408)
* perf: optimize payment app imports to avoid loading entire app store

- Add PaymentServiceMap generation to app-store-cli build process
- Generate payment.services.generated.ts with lazy imports for 6 payment services
- Update handlePayment.ts, deletePayment.ts, handlePaymentRefund.ts to use PaymentServiceMap
- Update getConnectedApps.ts and tRPC payment routers to use PaymentServiceMap
- Follow same pattern as analytics optimization in PR #23372
- Reduces bundle size by avoiding import of 100+ apps when only payment functionality needed

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Update build.ts

* fix: update payment service test mocking to work with PaymentServiceMap

- Remove obsolete appStoreMock line from bookingScenario.ts since handlePayment now uses PaymentServiceMap
- Update setupVitest.ts to import prismaMock from correct PrismockClient instance
- Add PaymentServiceMap mock following PR #22450 pattern for calendar services
- Ensure MockPaymentService uses consistent externalId across test files
- Fix webhook handler to return 200 status by ensuring payment records are found correctly

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert prismaMock import to avoid interfering with other tests' vi.spyOn() calls

- Remove global prismaMock import from setupVitest.ts that was causing 'is not a spy' errors
- Update MockPaymentService to import prismaMock locally to maintain payment test functionality
- Fixes organization and outOfOffice tests while preserving payment service optimization

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: remove E2E conditional check from payment services map generation

- Payment services map now always includes all payment apps regardless of E2E environment
- Ensures payment functionality is consistently available across all environments
- Addresses CI failures caused by conditional payment service loading

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* refactor: use direct PaymentService imports instead of .lib structure

- Update app-store-cli to import directly from lib/PaymentService.ts files
- Modify all payment handlers to access PaymentService directly
- Update test mocks to match new direct import structure
- Remove .lib property access pattern across payment system
- Maintain backward compatibility while improving import efficiency

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert chargeCard booking.id parameter additions

- Remove booking.id parameter from chargeCard calls in chargeCard.handler.ts and payments.tsx
- Addresses GitHub feedback to investigate chargeCard signature changes in separate PR
- Keeps all other direct PaymentService import refactor changes intact

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-28 10:05:36 -03:00
Benny Joo 381125becd chore: CODEOWNER change for /apps/web/lib files (#23423) 2025-08-28 12:11:06 +00:00
emrysal 091327afee chore: release v5.6.8
Post release / release (push) Canceled after 0s
2025-08-28 11:15:53 +00:00
Anirban SinghaandKartik Saini f1d7d7f02a fix: Missing personal event types in All filter (#23343)
* fix: Missing personal event types in All filter

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

* chore: coderabbit suggestion

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

* chore: export function

* chore: Add test file

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

---------

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-28 11:56:58 +02:00
Joe Au-Yeung 84bd4d3d38 Remove payment fee from chargeCard (#23411) 2025-08-28 08:07:52 +00:00
Rudra Gupta 0367cf8e41 docs: update api step 1 path in self hosting documentation (#23393) 2025-08-27 16:37:15 +00:00
Kartik SainiandAnik Dhabal Babu afea724e4d fix: improve cancellation email of reschedule RR booking (#17768)
* fix: improve cancellation email of reschedule RR booking

* fix failing test

* fix: uses same lucky user when rescheduling RR booking also has a fixed host

* chore

* chore

* better message in the cancelled email

* fix

* fix

* add booker rescheduled in reassigned email to old host

* test

* add reassigned subtite

* fix: use same lucky user when reschedule RR booking has a fixed host

* fix: test

* fix: subtitle

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-27 13:28:33 -03:00
Calcom BotandLingo.dev a4915184cd feat: update translations via @LingoDotDev (#22894)
Co-authored-by: Lingo.dev <support@lingo.dev>
2025-08-27 13:26:14 -03:00
b7a7797293 feat: add paid, userEmail, userName, and rating filters to insights page (#23299)
* feat: add paid, userEmail, and userName filters to insights page

- Add paid filter as MULTI_SELECT with 'Paid'/'Free' options
- Add userEmail filter as TEXT for searching user emails
- Add userName filter as TEXT for searching user names
- Update DummyTableRow type to include new filter columns
- Add backend filtering logic in buildColumnFilterCondition method
- Use existing columns from BookingTimeStatusDenormalized view
- Follow established patterns from /bookings page implementation

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

* fix: change paid filter from MULTI_SELECT to SINGLE_SELECT

- Updated useInsightsBookings.ts to use ColumnFilterType.SINGLE_SELECT for paid filter
- Modified buildColumnFilterCondition to handle single select with isSingleSelectFilterValue
- Simplified backend logic to handle single boolean value instead of array

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

* fix paid filter

* feat: add rating filter to insights page

- Add rating field to DummyTableRow type
- Add rating column with SINGLE_SELECT filter type
- Add rating options 1-5 in faceted unique values
- Add rating filtering logic in backend service
- Rating filter allows filtering bookings by 1-5 star ratings

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

* feat: change rating filter to NUMBER type

- Update rating filter from SINGLE_SELECT to NUMBER in useInsightsBookings.ts
- Remove rating options from useInsightsBookingFacetedUniqueValues.ts since NUMBER filters don't need predefined options
- Add isNumberFilterValue import and implement number filter logic in InsightsBookingBaseService.ts
- Support eq, gt, gte, lt, lte operators for rating number filter

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

* refactor: use makeSqlCondition utility for filter logic

- Import makeSqlCondition from @calcom/features/data-table/lib/server
- Replace manual SQL condition building with makeSqlCondition utility for userEmail, userName, and rating filters
- Maintain existing paid filter logic for boolean conversion
- Follow established pattern from InsightsRoutingBaseService
- Cleaner, more maintainable code with consistent filter handling

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

* add missing texts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-27 16:00:10 +00:00
Lauris Skraucis d0e59f1824 fix: platform confirmed/declined meeting attendee reschedule email url (#23335)
* fix: platform confirmed/declined meeting attendee reschedule email url

* chore: bump platform libraries
2025-08-27 14:35:10 +00:00
Lauris SkraucisandAnik Dhabal Babu 67befea66e fix: dutch appearing in booking field name (#23364)
* fix: dutch appearing in booking field name

* fix: tests

---------

Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-27 19:43:37 +05:30
Morganandsupalarry f18ebc4d67 chore: create standalone i18n utility for platform libraries (#23386)
* chore: create i18n for platform libraries

* fixup! chore: create i18n for platform libraries

* refactor: use previous version of i18n

* Revert "refactor: use previous version of i18n"

This reverts commit 0181ae1018d99ea6d5455d6dde5f3727e70422e1.

* refactor: use previous version of i18n

---------

Co-authored-by: supalarry <laurisskraucis@gmail.com>
2025-08-27 10:55:57 -03:00
Eunjae LeeandDevin AI 5324832f13 refactor: extract rating values and emojis into reusable constants (#23340)
* refactor: extract rating values and emojis into reusable constants

- Create packages/lib/rating.ts with RATING_OPTIONS array and validation utilities
- Update bookings-single-view.tsx to use extracted constants
- Add TypeScript interfaces for type safety
- Enable reuse of rating constants across the codebase

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

* refactor: move rating utilities to bookings feature directory

- Move packages/lib/rating.ts to packages/features/bookings/lib/rating.ts
- Update import path in bookings-single-view.tsx to use new location
- Maintain domain-specific organization for booking-related utilities

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

* fix: resolve linting issues in event-types.tsx

- Fix quote style consistency (single to double quotes)
- Add missing trailing comma
- Resolve pre-commit hook formatting changes

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-27 15:54:52 +02:00
Benny Joo 2d9162a4dd perf: use promise all when possible in RSCs (#23384) 2025-08-27 08:52:27 -03:00
Yadong (Adam) Zhang d9443162ef fix: replace useTranslation with useLocale in AppCard component (#23379) 2025-08-27 19:09:03 +09:00
e8dbab99eb feat: replace window.todesktop with official @todesktop/client-core package (#23354)
* feat: replace window.todesktop with official @todesktop/client-core package

- Import platform from @todesktop/client-core in _document.tsx
- Pass isDesktopApp result via window.calIsDesktopApp to stringified function
- Update applyToDesktopClass to use passed-in value instead of window.todesktop
- Maintains existing architecture while using official package API

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

* fix: move ToDesktop detection to client-side to resolve SSR issue

- Remove server-side call to platform.todesktop.isDesktopApp() which always returns false
- Move detection logic to client-side stringified script using same logic as official package
- Maintain client-side detection while ensuring proper functionality in ToDesktop environment

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

* feat: use official platform.todesktop.isDesktopApp() function

- Import platform from @todesktop/client-core in _document.tsx
- Call platform.todesktop.isDesktopApp() directly in stringified script
- Replace duplicated logic with official package function call
- Maintain client-side detection while using official API

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

* feat: properly use official platform.todesktop.isDesktopApp() function

- Call platform.todesktop.isDesktopApp() outside stringified context
- Pass result as template variable to avoid SSR issues
- Now actually uses the official @todesktop/client-core package
- Eliminates duplicated detection logic as requested

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

* fix: add SSR safety guards around platform.todesktop.isDesktopApp() call

- Wrap platform.todesktop.isDesktopApp() in try/catch to prevent SSR crashes
- Return false as fallback if library touches browser globals during SSR
- Addresses keithwillcode's comment about SSR safety concerns

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-27 09:21:37 +00:00
cdcc04c8ff refactor: extract PanelCard from ChartCard for reusability (#23360)
* feat: extract PanelCard from ChartCard for reusability

- Create base PanelCard and PanelCardItem components in UI package
- Refactor ChartCard to use PanelCard as foundation while keeping legend functionality
- Add headerActions prop to PanelCard for extensibility
- Maintain backward compatibility for existing ChartCard usage
- Export PanelCard and PanelCardItem from UI card components

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

* chore: update yarn.lock after dependency resolution

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

* refactor: rename prop from actions to headerContent

- Rename 'actions' prop to 'headerContent' in PanelCard component
- Update ChartCard to use new 'headerContent' prop name
- More accurate naming since content can be text or other elements, not just buttons

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

* clean up

* revert yarn.lock

* fix type error

* type fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-27 06:18:44 -03:00
Anwar SadathandUdit Takkar 740a1c991e fix: Role not getting updated immediately in edit profile modal (#23236)
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-27 09:03:20 +00:00
emrysal 70cfe8d693 chore: release v5.6.7
Post release / release (push) Canceled after 0s
2025-08-26 23:13:36 +00:00
Keith WilliamsandDevin AI 520e711ca6 perf: optimize analytics app imports to avoid loading entire app store (#23372)
* perf: optimize analytics app imports to avoid loading entire app store

- Add analytics service generation to app-store-cli build process
- Generate analytics.services.generated.ts with only analytics apps (dub)
- Update getAnalytics.ts to use AnalyticsServiceMap instead of full appStore
- Add NEXT_PUBLIC_IS_E2E to turbo.json globalEnv for generated files
- Reduces import footprint from 100+ apps to only analytics apps with AnalyticsService
- Follows same pattern as calendar services optimization from PR #22450

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Reorder cli output

* fix: follow getCalendar pattern in getAnalytics and maintain alphabetical order in turbo.json

- Remove unnecessary object wrapping in getAnalytics.ts to match getCalendar.ts pattern
- Move NEXT_PUBLIC_IS_E2E to correct alphabetical position in turbo.json globalEnv
- Address PR feedback from keithwillcode

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* chore: update yarn.lock after analytics optimization changes

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-26 18:57:38 -03:00
Keith WilliamsandDevin AI 6d1fe32a39 feat: add self-hosted to ignored labels in team labeler workflow (#23373)
- Add self-hosted to the ignore-labels list in .github/workflows/labeler.yml
- This prevents the team-label-action from automatically applying the self-hosted label to PRs
- Maintains alphabetical ordering in the comma-separated list

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-26 20:21:38 +00:00
Benny JooandDevin AI 1cc548284d fix: remove TypeScript path mapping and standardize @calcom/ui imports (#23359)
- Remove '@calcom/ui/*': ['../../packages/ui/components/*'] path mapping from apps/web/tsconfig.json
- Fix imports from '@calcom/ui/componentName' to '@calcom/ui/components/componentName' format
- Preserve direct exports like '@calcom/ui/classNames' and '@calcom/ui/styles'
- Update 200+ files across apps/web, packages/features, packages/app-store, and packages/ui
- Fix localStorage usage to use safe webstorage import
- Remove forbidden 'as any' type casting in favor of proper TypeScript types
- All changes verified to pass TypeScript compilation

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-26 13:47:06 -03:00
Rajiv Sahal d49843de0b fix: update e2e tests for event types (#23322)
* add data test id for testing

* update tests for creating event type

* add test for creating team event types

* helper function for tests

* update tests

* update tests

* fixup: fix correct data test ids for tabs
2025-08-26 13:02:22 -03:00
Peer Richelsen 42efd95476 chore: fixed typo in recording link (#23363)
* Update common.json

* Update apps/web/public/static/locales/en/common.json
2025-08-26 14:46:37 +00:00
Anik Dhabal Babu 2ec68ef1aa fix: team metadata restriction for stripe (#23327) 2025-08-26 14:24:11 +00:00
6e09489685 perf: implement quick app store loading optimizations (#22450)
* perf: implement quick app store loading optimizations

- Add conditional app store imports in videoClient and handlePayment
- Implement lazy calendar manager pattern in CalendarManager
- Enhance createCachedImport with better concurrency handling
- Create calendar-only registry for common calendar operations
- Add performance instrumentation for debugging

These optimizations reduce initial app store loading time by avoiding
module-level imports and creating smaller, focused registries for
calendar operations.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert getCalendar to use appStore for proper test mocking

- Reverted getCalendar.ts to use main appStore instead of calendarStore
- This ensures test mocking system works properly with existing appStoreMock
- Fixes unit test failures where Google Calendar references were getting null values
- All collective scheduling tests now pass

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* perf: implement CalendarServiceMap for optimized calendar loading

- Add CalendarServiceMap generation following CrmServiceMap pattern
- Update getCalendar.ts to use generated calendar service map
- Remove manual calendar-registry.ts in favor of auto-generated approach
- Reduces calendar initialization from loading 48+ apps to ~10 calendar apps

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: update test mocks for CalendarServiceMap compatibility

- Add missing SelectedCalendar fields (createdAt, updatedAt, lastErrorAt, watchAttempts, etc.)
- Fix CredentialPayload type errors by adding user.email and delegationCredentialId
- Mock CalendarServiceMap to use vi.importActual for real calendar services
- Ensure calendar service tests work with new lazy loading approach

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: resolve CalendarServiceMap test compatibility issues

- Update getCalendarsEvents.test.ts mocks to work with CalendarServiceMap dynamic imports
- Add missing SelectedCalendar fields (createdAt, updatedAt, lastErrorAt, watchAttempts, etc.)
- Fix CredentialPayload type errors by adding user.email and delegationCredentialId
- Use type assertion in getCalendar.ts to resolve credential type conflicts
- Ensure calendar service tests work with new lazy loading approach

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: update test mocking for CalendarServiceMap compatibility

- Add comprehensive vi.mock for calendar.services.generated in delegation-credential tests
- Mock GoogleCalendarService and Office365CalendarService with proper return values
- Update all test files to use await with mockCalendarToHaveNoBusySlots
- Ensure calendar events return expected meetingId, meetingPassword, meetingUrl values
- Fix async/await compatibility issues in booking scenario test utilities

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: resolve TypeScript errors in CalendarServiceMap mocking

- Extract CalendarServiceMap promise to variable to fix 'always true' condition
- Ensure vi.mocked is called on Promise type for proper mockResolvedValue access
- Add await keywords to calendar mock calls in test files
- Maintain existing functionality while making code type-safe

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Remove Exchange 2013 and 2016

* Mock Exchange in all tests

* Fix tests

* Remove exchange 2013 and 2016 from app store index

* Fix merge error

* Await when getting calendar service

* Fix selectedSlot test

* Add missing variable

* Update openapi.json

* Updated CalendarService imports

* try again

* WIP migrate calendar apps to ES6

* Revert "WIP migrate calendar apps to ES6"

This reverts commit 15bf2c83305e82050779d0bca3380fa9573db1e0.

* Revert changes back to e23991024a5a455c14aa05f100a5b56288db343a

This reverts all calendar service changes that were causing circular dependency issues during builds and E2E tests.

* Remove circular dependency for location constants

* Update yarn.lock with removed package

* Add empty map when running E2E

* Type fies

* Fix merge conflict

* Remove logging statements

* Throw error and reset state if failing to load app

* Revert "Remove Exchange 2013 and 2016"

This reverts commit fedaf6346bd9e4c63337276d5a6f9e8c3943056c.

* Re-introduce exchange{2013,2016}

Revert the removal in app-store/index.ts also.

* Trying to fix tests

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-26 14:35:35 +01:00
Hemant M MehtaandEunjae Lee af07c411ee fix: auto-open filter dropdown after adding a new filter (#23330)
* fix: auto-open-filter-dropdown

closes #23326

* coderabbit suggestion

Signed-off-by: Hemant M Mehta <hemant29mehta@gmail.com>

* coderabbit suggestion

Signed-off-by: Hemant M Mehta <hemant29mehta@gmail.com>

* removing clickable event

Signed-off-by: Hemant M Mehta <hemant29mehta@gmail.com>

* updated filters

Signed-off-by: Hemant M Mehta <hemant29mehta@gmail.com>

* fix e2e tests

---------

Signed-off-by: Hemant M Mehta <hemant29mehta@gmail.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-08-26 13:22:43 +00:00
a22ca4758c test: Performance tests suite (#21226)
* fix: add org email into ics

* perf test

* update

* fix: address bot comments on performance tests

- Add validation to randomSleep function to ensure min <= max
- Replace new Date().getTime() with Date.now() for better performance
- Move hardcoded test credentials to environment variables
- Add HTTP timeout to requests in helpers.js
- Improve DOM element checking to be less brittle
- Fix spike test sleep duration from 0.01s to 0.1s
- Make GitHub workflow BASE_URL configurable with inputs
- Fix README filename reference from booking_flow.js to booking.js
- Add documentation for new environment variables

Co-Authored-By: anik@cal.com <adhabal2002@gmail.com>

* Add script and small sec check

* chore: fix script path

* chore: fix script path

* chore: change default url

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-26 09:16:19 -03:00
Anik Dhabal Babu bb3ba88f18 fix: Skip trial button visibility (#23034)
* fix: flaky e2e

* fix: skip trial button visibility

* fix type error

* fix test

* update

* Update skipTeamTrials.test.ts

* fix test
2025-08-26 17:25:37 +05:30
Udit Takkar ce646c590f chore: update daily dependencies (#23272) 2025-08-26 08:55:18 -03:00
Morgan ba8cca55e8 refactor: convert lucky user to service class (#23329)
* refactor: lucky user to service class with DI

* fix: unit test filterHostsByLeadThreshold.test.ts

* fixup! fix: unit test filterHostsByLeadThreshold.test.ts

* fix: get lucky user test missing mocks

* fix: get lucky user hosts not within interval

* chore: bump platform library

* chore: bump platform library

* chore: add DAILY_API_KEY to turborepo.json db seed

* chore: add DAILY_API_KEY to cache-db action

* fixup! chore: add DAILY_API_KEY to cache-db action

* chore: remove log in fixture

* chore: bump platform library

* chore: refactor test team-emails.e2e

* only failing test

* run all api v2 tests

* chore: bump platform library

* revert change to turbo.json and action in cache-db

* chore: use inPerson location in team-emails.e2e
2025-08-26 08:43:41 -03:00
sean-brydon ae738fae35 revert: "fix: remove fallback permssions on organization Id. (#22769)" (#23013)
* Revert "fix: remove fallback permssions on organization Id. (#22769)"

This reverts commit 30061c0d58.

Removes the manage permission concept and fallback logic to simplify
permission checking back to direct role-based checks.

* remove manage from workflows

* fix type error and remove manage from components
2025-08-26 08:32:49 -03:00
Benny Joo 2922c8fe77 perf: Move runtime redirects to build-time redirects in next.config.js (#23325)
* routing forms

* auth new

* settings route

* apps installed

* next.config.js
2025-08-26 20:20:12 +09:00
emrysal 4599740637 chore: release v5.6.6
Post release / release (push) Canceled after 0s
2025-08-26 11:10:41 +00:00
Benny Joo 8d17ca536e perf: bundle i18n json into code (#23303)
* bundle i18n jsons into code

* refactor

* use absolute url
2025-08-26 12:01:44 +01:00
Rajiv Sahal 3fa7b30485 chore: update playwright config for platform examples (#23352) 2025-08-26 09:55:08 +00:00
Rajiv Sahal d9adb6c1fc feat: atoms e2e tests (#23273)
* e2e tests for availability settings atom

* init tests for booking page

* add data test id for event type card

* update tests for booking page

* add data test ids for playwright tests

* update test coverage for booking page
2025-08-26 07:35:45 +00:00
Anik Dhabal Babu f4944a05c9 fix: recurring event isn't working (#23346) 2025-08-26 12:40:40 +05:30
Sahitya Chandra 6ba49a73df fix: fr and de translation for buttons on the onboarding flow (#23347)
* fix: added french translation for buttons on the onboarding

* added some german also
2025-08-26 01:02:28 +05:30
Anik Dhabal Babu babeb1f31d fix get booking issue (#23344) 2025-08-25 18:42:30 +00:00
Hariom BalharaandDevin AI 13cbb59e5e fix: Custom Event Redirect in Routing Form with Dropdown Based fields (#17870)
Co-authored-by: hariom@cal.com <hariombalhara@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-25 13:46:18 -04:00
Sahitya Chandra 8d2a072645 fix: german translation for button in the onboarding flow (#23336) 2025-08-25 20:33:59 +05:30
Joe Au-YeungandAlex van Andel b2244503b1 fix: Missing jose module in saml-jackson package (#23291)
* Ensure packages are bundled with code

* Update dependencies

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-25 15:36:31 +01:00
Alex van AndelandDevin AI 9d67e1c473 feat: enable Stripe Adaptive Currencies for subscription flows (#23328)
* feat: enable Stripe Adaptive Currencies for subscription flows

- Add STRIPE_ADAPTIVE_CURRENCIES_ENABLED environment variable to control feature
- Remove hardcoded USD currency when Adaptive Currencies is enabled
- Update documentation with comprehensive setup instructions
- Focus on team subscriptions, platform billing, and premium username flows
- Maintain backward compatibility with USD default when disabled

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

* refactor: simplify Stripe Adaptive Currencies implementation by removing currency parameter

Co-Authored-By: alex@cal.com <me@alexvanandel.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-25 14:00:49 +00:00
Benny Joo 489d56dad1 perf: Make /icons page a static page (#23324)
* write _generateMetadataForStaticPage

* refactor /icons/page.tsx
2025-08-25 14:34:46 +02:00
Anik Dhabal Babu 532c2f57a1 fix: show correct input type for location (#23228) 2025-08-25 12:06:33 +00:00
Bandhan MajumderandEunjae Lee efca90507b refactor: improve most booked & least booked (#23192)
* refactor: improve most booked & least booked

* add 2 new charts and trpc handlers

* update arg

* update comment

* completed booking filter

* update

* add change in component level and remove unused translations

* current time and accepted status logic handler

* resize some charts

* restore some i18n

* restore i18n

* restore i18n

---------

Co-authored-by: Eunjae Lee <hey@eunjae.dev>
2025-08-25 11:17:07 +00:00
Hariom BalharaandDevin AI 28ff809739 feat: add startsWith operator to routing forms (#23026)
* feat: add startsWith operator to routing forms

- Implement startsWith operator in BasicConfig.ts for RAQB
- Add custom JsonLogic operation for startsWith evaluation
- Add startsWith support to jsonLogicToPrisma for reporting
- Supports both form field response routing and attribute routing
- Uses case-insensitive matching via normalize function

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* test: add comprehensive unit tests for startsWith operator

- Add tests for operator availability in FormFieldsBaseConfig and AttributesBaseConfig
- Add tests for JsonLogic configuration validation
- Add tests for text widget operator integration
- All 15 tests pass including new startsWith operator tests

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* test: move startsWith operator tests to jsonLogic.test.ts

- Add comprehensive startsWith tests to packages/lib/raqb/jsonLogic.test.ts
- Remove startsWith tests from packages/app-store/routing-forms/__tests__/config.test.ts
- Tests verify case-insensitive matching, edge cases, and falsy value handling
- All tests pass: jsonLogic.test.ts (18 passed) and config.test.ts (12 passed)

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: remove unused startsWith operator from jsonLogicToPrisma.ts

- Remove startsWith entry from OPERATOR_MAP in jsonLogicToPrisma.ts
- File is currently unused so this cleanup keeps PR focused
- Core startsWith functionality remains intact in jsonLogic.ts and BasicConfig.ts

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* test: add comprehensive edge case tests for startsWith operator

- Add test cases for non-string first argument (haystack) handling
- Cover null, undefined, numbers, booleans, arrays, and objects
- Update startsWith implementation to safely handle non-string inputs
- Ensure operation returns false without throwing runtime errors
- All tests now pass: 19 passed | 1 skipped

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* refactor: improve startsWith operation with type guards and String.prototype.startsWith

- Change argument types from string to unknown for better type safety
- Add type guards to return false when either operand is not a string
- Use String.prototype.startsWith instead of indexOf for cleaner implementation
- Keep existing empty-second check and normalize function calls
- All tests continue to pass: 19 passed | 1 skipped

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-25 15:57:34 +05:30
Benny Joo 8e73421094 perf: do not use next/dist/server image optimizer (#23207)
* write image utils

* refactor

* better comment
2025-08-25 15:13:44 +05:30
5bab2af500 chore: some minor QA fixes (#22727)
Post release / release (push) Canceled after 0s
* Various fixes

* Remove tooltip

* another string change

* Fix width of custom event name modal to prevent overflow

* Booking questions fixes

* minor string fix

* checkbox alignment

* padding in reassign dialog

* fix: update FormBuilder tests for checkbox UI changes

- Updated test utilities to work with CheckboxField instead of BooleanToggleGroupField
- Fixed badge expectations to check for 'optional' instead of 'required'
- Updated dialog interaction methods to use checkbox.checked instead of button text
- All 13 FormBuilder tests now pass successfully

Fixes failing tests in PR #22727 caused by UI changes from toggle buttons to checkboxes for required field selection.

Co-Authored-By: amit@cal.com <samit91848@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: amit@cal.com <samit91848@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-25 18:11:18 +09:00
Carina WollendorferandCarinaWolli 9b7c6585d8 fix: add variables dropdown UI (#23214)
* Ui fixes for variables dropdown

* remove other translation keys

* fix test

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-25 16:42:27 +09:00
Sahitya ChandraandSomay Chauhan 58930c4c5e fix: correct conversion of username parameter (#23203)
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-08-25 12:32:16 +05:30
Anirban SinghaandAnik Dhabal Babu 9412226a76 fix: Trim input before submission(availability) (#23317)
* fix: Trim input before submission(availability)

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

* refactor: Persist trimmed value in form

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>

* Update NewScheduleButton.tsx

---------

Signed-off-by: Anirban Singha <anirbansingha20@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-25 06:08:10 +00:00
Benny Joo df12581879 perf: reduce build time and deployment size by removing unused routes (#23319) 2025-08-25 04:56:55 +00:00
Alex van Andel 0ec1638ca7 chore: Clean up some code, like console.log, tsx -> ts (#23321) 2025-08-25 01:30:45 +00:00
emrysal 2516e18ebb chore: release v5.6.5 2025-08-24 23:34:40 +00:00
Volnei Munhoz d2f8076c70 perf: Improve memory cache on build (#23286)
* Improve memory cache on build

* Improve memory cache on build
2025-08-22 19:57:25 +00:00
Keith WilliamsandDevin AI cbfb11015e feat: add E2E test to prevent duplicate schedule API calls (#23232)
* feat: add E2E test to prevent duplicate schedule API calls

- Tests individual user events (/{user}/{eventTypeSlug})
- Tests team events (/team/{teamSlug}/{eventTypeSlug})
- Tests organization team events (/org/{orgSlug}/{teamSlug}/{eventTypeSlug})
- Ensures /api/trpc/getSchedule and /v2/slots/available are called only once
- Prevents regression of double API calls in Booker component
- Uses flexible assertions to handle environment issues while detecting duplicates

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* refactor: rename E2E test file to booking-duplicate-api-calls.e2e.ts

Addresses GitHub comment from keithwillcode to follow naming convention

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: correct tRPC API route pattern to api/trpc/slots/getSchedule

Addresses GitHub comment from keithwillcode - the route pattern should be
api/trpc/slots/getSchedule instead of api/trpc/viewer.slots.getSchedule
in all page.route() interceptors for the getSchedule endpoint.

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* fix: revert to correct tRPC API route pattern api/trpc/viewer.slots.getSchedule

The previous change to api/trpc/slots/getSchedule was incorrect. The tRPC router
structure shows that getSchedule is under viewer.slots, which translates to the
HTTP endpoint api/trpc/viewer.slots.getSchedule as used by trpc.viewer.slots.getSchedule.useQuery().

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-22 18:25:40 +00:00
2d90d11e9e feat: Add custom label for organizer default app and link meeting (#23114)
* Create `CalVideoSettings` type

* Create `LocationOptionContainer`

* Extract cal video settings to `CalVideoSettings`

* Refactor `<Locations />` component to use `<CalVideoSettings />`

* Unify naming

* Move location folder back under components

* Type fixes

* Extract types

* Add custom label prop to default app types

* Extract `LocationInput` component

* Create `DefaultLocationSettings` component

* Move `locations` folder

* Add custom label field for link and organizer default locations

* Type fixes

* Save the custom label in db

* Include location custom label when querying db

* Display custom label on booking page

* Add translations

* Type fixes

* Change `customLabel` to `supportsCustomLabel`

* Remove unused code

* Show the customLabel on booking page

* Fix hover tooltip text as well

* Fix displaying exact value on booking page

* Add missing translation

* Remove default label of "Value"

* Replace nullish collescing with OR

* Replace nullish collescing with OR

---------

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2025-08-22 16:34:14 +00:00
Lauris Skraucis 7ae0d6b7cc refactor: run atoms e2e when e2e label added (#23281)
* refactor: run atoms e2e when e2e label added

* refactor: rename atoms-e2e to e2e-atoms
2025-08-22 14:32:19 +00:00
Lauris Skraucis e0dc2ba03a feat: authentication secured event types (#23217)
* feat: EventType bookingRequiresAuthentication column

* feat: toggle EventType bookingRequiresAuthentication via api

* feat: check bookingRequiresAuthentication when booking

* docs: v2 swagger

* fix: ts error

* refactor: use Forbidden exception instead of Unauthorized

* refactor: use findFirst instead of unique

* fix: only count accepted memberships

* fix: orgs schedules docs

* regenerate docs

* chore: update platform libraries

* fix: unit test

* fix: e2e test
2025-08-22 15:22:50 +01:00
github-actions[bot] aea6e105e6 chore: version packages (#23248)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-22 14:17:18 +00:00
a082cf73a0 fix: Manual RR reassign location change - Cal Video/Zoom/GoogleMeet to Zoom/Google Meet/Cal Video (#22840)
* wip

* fix tests

* fix webex OAUTH_URL for local testing as done in other apps

* fix type errors

* Improve check

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-22 17:52:22 +05:30
Lauris Skraucis 61268f36b9 chore: add debug logs (#23278) 2025-08-22 12:20:39 +00:00
99cd0c5c41 fix: Use Memberships with OWNER role for platform owner lookup (#22475)
* fix platform owner identification to use Memberships with OWNER role

* replaced the hardcoded OWNER string with the enum value

* replaced enum with hard coded OWNER value for safty

* added enum usage

* added admin lookout if owner validation fails

* chore

* chore

* fixed tests

* chore

* chore

* chore

* chore

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-08-22 14:14:28 +05:30
Eunjae LeeandDevin AI 2266301469 feat: implement system filter segments for data tables (#22939)
* feat: implement default filter segments for data tables

- Add defaultSegmentId column to UserFilterSegmentPreference table
- Support mixed segment ID types (number for user segments, string for default segments)
- Add DefaultFilterSegment and CombinedFilterSegment types
- Update useSegments hook to handle default segments with date range recalculation
- Modify FilterSegmentSelect to group and display default segments separately
- Add default segments to bookings view (My Bookings, Upcoming Bookings)
- Prevent editing/deleting of default segments in SaveFilterSegmentButton
- Update DataTableProvider to support defaultSegments prop
- Update parsers and repository to handle mixed segment ID types

Implements frontend-only default segments as specified in the requirements.

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

* chore: update ESLint dependencies to resolve configuration issues

- Update eslint-config-next and @typescript-eslint packages to latest versions
- Fix dependency compatibility issues that were blocking pre-commit hooks

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

* revert eslint change

* some changes

* refactor: implement discriminated union for segment types

- Update SegmentIdentifier to use discriminated union with 'custom' vs 'default' types
- Refactor setSegmentId to accept object parameters: { id: string; type: 'default' } | { id: number; type: 'custom' }
- Update type definitions with DefaultFilterSegment, CustomFilterSegment, and CombinedFilterSegment
- Modify useSegments hook to handle new segment type structure
- Update FilterSegmentSelect component to work with discriminated unions
- Refactor database preference handling to store segment type alongside ID
- Add proper type safety throughout the data flow
- Remove description property from DefaultFilterSegment type

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

* fix: update unit test to expect discriminated union format for preferredSegmentId

The test was expecting preferredSegmentId to be a number, but after the discriminated union refactor it now returns { id: number, type: 'custom' } for custom segments. Updated the assertion to use toEqual() for deep object comparison.

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

* fix: correct SegmentIdentifier type to exclude undefined

- Remove null from SegmentIdentifier type definition since null/undefined indicate absence of identifier
- Update DataTableProvider and useSegments to handle SegmentIdentifier | null properly
- Fix type safety while maintaining discriminated union functionality
- Ensure setAndPersistSegmentId accepts null values for clearing segments

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

* remove unused segment

* a little update

* feat: add default- prefix to default segment IDs for clearer type identification

- Update 'my_bookings' to 'default-my_bookings'
- Update 'upcoming-bookings' to 'default-upcoming-bookings'
- Makes it easier to verify segment type by looking at the ID string
- Maintains all existing discriminated union functionality

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

* refactor: rename segment types from default/custom to system/user for better semantics

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

* fix: update unit test to expect 'user' type instead of 'custom' in discriminated union

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

* fix: complete type renaming from default/custom to system/user across all component files

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

* refactor: update segment ID prefix from default- to system- to match type naming

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

* remove unused segment

* some fixes

* revert schema change

* rename defaultSegmentId to systemSegmentId

* renaming

* fix save button

* remove as any

* clean up prisma migrations

* type fixes

* many fixes

* remove icon property

* fix infinite rendering

* fix race condition

* re-visiting useSegments implementation WIP

* extract useElementByClassName

* add e2e tests

* fix a bug that the created segment was not selected automatically

* add useSegments to /insights and fix e2e test

* fix type error

* fix type error

* apply feedback

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-22 10:18:18 +03:00
Bandhan MajumderandKartik Saini 30b0012e6a fix: truncated droplist in reroute booking (#23263)
Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-22 04:32:21 +00:00
emrysal 77d1958c42 chore: release v5.6.4
Post release / release (push) Canceled after 0s
2025-08-22 01:49:37 +00:00
88f347f18c feat: improve unkey error handling with heavy logging for unknown errors (#23223)
* feat: improve unkey error handling with heavy logging for unknown errors

- Add onError handler to all Ratelimit instances to catch unknown service errors
- Log comprehensive error details including stack traces and identifiers
- Return fallback response allowing requests through instead of rejecting promises
- Preserve existing rate limit functionality for legitimate rate limit cases
- Maintain consistency with existing timeout fallback behavior

Co-Authored-By: keith@cal.com <keithwillcode@gmail.com>

* Update rateLimit.ts

* chore: Bump @unkey/ratelimit version

* Add required props to rateLimiter onError response

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-21 23:37:01 +01:00
Somay ChauhanandDevin AI 09cf8885cf feat: add send & verify code flow to booker atom when email verification is turned on (#23074)
* feat: extract tRPC verification logic to platform libraries and create v2 atoms endpoints

- Extract verifyCodeUnAuthenticated, verifyCodeAuthenticated, and sendVerifyEmailCode logic from tRPC handlers into reusable platform library functions
- Create VerificationAtomsService and AtomsVerificationController following atoms pattern
- Add v2 endpoints: /atoms/verification/email/send-code, /atoms/verification/email/verify-code, /atoms/verification/email/verify-code-authenticated
- Update useVerifyEmail and useVerifyCode hooks to use new v2 endpoints instead of verified-resources endpoints
- Export verification functions from @calcom/platform-libraries following getPublicEvent pattern
- Integrate platform-specific verification hooks into BookerPlatformWrapper

Co-Authored-By: somay@cal.com <somaychauhan98@gmail.com>

* feat: update platform hooks to use v2 verification endpoints

- Update useVerifyEmail hook to use correct response type for v2 endpoint
- Update useVerifyCode hook to use v2 verification endpoint
- All verification functions now properly exported from platform libraries
- Type checking passes with no errors

Co-Authored-By: somay@cal.com <somaychauhan98@gmail.com>

* fix: update v2 API to use workspace version of platform-libraries

- Change package.json dependency from pinned version to workspace version
- Add proper error handling to verification service with NestJS HTTP exceptions
- Convert generic Error objects to BadRequestException and UnauthorizedException
- Ensure verification endpoints return proper HTTP status codes instead of 500 errors

Co-Authored-By: somay@cal.com <somaychauhan98@gmail.com>

* chore: update auto-generated files after package.json changes

- Update OpenAPI documentation for v2 verification endpoints
- Update yarn.lock after changing platform-libraries dependency

Co-Authored-By: somay@cal.com <somaychauhan98@gmail.com>

* refactor: extract verification logic from tRPC handlers into exportable functions

- Refactor sendVerifyEmailCode.handler.ts to extract core logic into sendVerifyEmailCode function
- Refactor verifyCodeUnAuthenticated.handler.ts to extract core logic into verifyCodeUnAuthenticated function
- Refactor organizations/verifyCode.handler.ts to extract core logic into verifyCodeAuthenticated function
- Update platform/libraries/index.ts to import from refactored handler files instead of standalone verification.ts
- Remove standalone verification.ts file as requested by user
- Keep original tRPC handlers as wrappers that call the extracted functions
- Maintain backward compatibility for existing tRPC endpoints

Co-Authored-By: somay@cal.com <somaychauhan98@gmail.com>

* extract logic into seperate functions

* Update index.ts

* refactor: remove unused type imports from verification-atom service

* feat: create v2 atoms input/output types for verification endpoints

- Add SendVerificationEmailInput and VerifyEmailCodeInput with validation decorators
- Add SendVerificationEmailOutput and VerifyEmailCodeOutput following v2 atoms patterns
- Update atoms verification controller to use custom types instead of platform types
- Auto-update openapi.json with new type definitions

Co-Authored-By: somay@cal.com <somaychauhan98@gmail.com>

* fix: add missing type imports to verification service

- Import ZVerifyCodeInputSchema from @calcom/prisma/zod-utils
- Import VerifyCodeAuthenticatedInput from organizations handler
- Import TSendVerifyEmailCodeSchema from sendVerifyEmailCode schema
- Resolves CI build failure in API v2 tests

Co-Authored-By: somay@cal.com <somaychauhan98@gmail.com>

* feat: add email verification check endpoint and refactor verification flow

* added Throttle

* added chagelog

* chore: update platform libraries to 0.0.317

* refactor: remove unused context and update email verification query key dependencies

* chore: bump @calcom/platform-libraries from 0.0.318 to 0.0.319

* Update verifyCode.handler.ts

* chore: bump @calcom/platform-libraries from 0.0.319 to 0.0.320

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-21 15:06:42 +00:00
Romit f3d4eab951 fix: POST v2/bookings - recurrenceCount (with eventType reoccuring enabled) causes lengthInMinutes to be ignored (#23023)
* fix

* added tests

* chore
2025-08-21 16:54:33 +02:00
Anik Dhabal Babu 866b6cc233 fix: attendess are not showing (#23255) 2025-08-21 11:32:42 -03:00
沙什瓦特andDevanshu Sharma cafecad8ae feat: multi Select for "Event Type" in Insights (#23173)
* Multi select event type in insights

* Fixing multi select query generation

* Added null check for eventType filters

* Updated test to support multiselect

* Type fix

---------

Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-21 13:30:21 +00:00
Benny Joo 0ea673e507 feat: update global feature flag to team feature flag for team booking page cache (#23154)
* add team id fetcher based on slugs

* export getOrgContext

* update RSC

* refactor

* further refactor
2025-08-21 21:34:45 +09:00
Eunjae Leeandcoderabbitai[bot] cf367c9340 feat: add attendee filters to /insights/routing (+ use RoutingFormResponseDenormalized) (#21297)
* fix: denormalize RoutingFormResponse with InsightsRoutingService

* fix tests

* fix type error

* update service usage

* fix attendee filters

* display attendee phone number

* use useInsightsRoutingParameters()

* remove unnecessary type check

* clean up

* fix type error

* fix wrong import

* fix integration tests

* Update packages/lib/server/service/__tests__/InsightsRoutingService.integration-test.ts

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

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-21 14:09:22 +02:00
Alex van Andel d72e15649f chore: Remove maintenance mode middleware (#23251) 2025-08-21 11:12:54 +00:00
Somay Chauhan 89a3b77a1d feat: add hideEventMetadata prop (#23211)
* feat: add hideEventMetadata prop

* added changelog

* udpate

* undo extra changes
2025-08-21 09:00:51 +00:00
Somay Chauhan 38f6f71ae0 feat: add organizer.id to the BOOKING_CREATED webhook for requires confirmation event bookings (#23245) 2025-08-21 08:58:51 +00:00
Rajiv Sahal 40caa82e55 fix: onBookerStateChange prop for Booker atom (#23213)
* fix onBookerStateChange not working

* add changesets

* update changesets message
2025-08-21 08:51:48 +00:00
Joe Au-Yeung b9aee3b18c fix: Add guests via URL param even if field is hidden (#23229)
* Add guests via URL param even if field is hidden

* Clean up
2025-08-21 02:27:52 +00:00
Volnei Munhoz 3f8d2f2b38 chore: add coderabbit config file (#23200)
Post release / release (push) Canceled after 0s
* chore: add coderabbit config file

* remove wrong config

* add coderabbit instructions

* remove unnecessary paths

* add path instructions

* Fix duplicated instructions on yaml file

* Fix coderabbit.yaml syntax
2025-08-20 22:55:27 +00:00
emrysal c3731d9f17 chore: release v5.6.3 2025-08-20 22:49:28 +00:00
Vitor Monteiro 6e4b2d8886 fix: addOnLeading text field caret cropping (#23225) 2025-08-20 22:32:19 +00:00
cf13c06c57 chore: Upgrade boxyhq/saml-jackson to 1.52.2 (#23231)
* Upgrade jackson to 1.52.2

* Update apps/web/package.json

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

* Remove unused option prop

---------

Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-08-20 23:13:36 +01:00
Anik Dhabal Babu e309e72be3 fix: duplicate event created on attendee calendar (#23216) 2025-08-20 15:53:15 +00:00
b4d804de7c chore: hide cal branding for platform bookings (#22979)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
2025-08-20 12:27:09 -03:00
Volnei Munhoz f501ef3269 refactor: remove highperf endpoints (#23171)
* refactor: remove highperf endpoints

* fix trpc function name

* chore: remove isTeamEvent logic

* chore: remove isTeamEvent at all

* revert wrong change

* Revert "chore: remove isTeamEvent at all"

This reverts commit 6dbc8c31f16c40a7654a05240661d7892aa48bb8.

* revert wrong change
2025-08-20 15:12:31 +00:00
Volnei Munhoz 79f1074f93 chore: Remove Vercel functions memory config (#23125)
* chore:Remove Vercel memory config

* chore: remove empty vercel.json configs
2025-08-20 14:51:45 +00:00
Vansh GilhotraandKartik Saini 48aa6bf489 fix: Add missing CSS variable for switch component accent color in getting-started flow (#23129)
- Fix switch component thumb appearing all white when ON
- Add --cal-brand-accent CSS variable definitions for light and dark modes
- Ensures proper contrast: white accent on dark bg, black accent on light bg
- Resolves UI issue on /getting-started/connected-calendar and /getting-started/setup-availability pages

Fixes switch components using bg-brand-accent class that maps to var(--cal-brand-accent,white)
When CSS variable was undefined, it defaulted to white causing invisible thumbs.

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-20 18:29:03 +05:30
Hariom Balhara 8ed2aacd9e Add organization rewrite logs (#23210) 2025-08-20 11:51:55 +00:00
Hariom BalharaandDevin AI dfc5d914fc chore: Routing Forms - remove unused report.handler.ts and jsonLogicToPrisma functionality (#23032)
* feat: remove unused report.handler.ts and jsonLogicToPrisma functionality

- Remove unused packages/app-store/routing-forms/trpc/report.handler.ts
- Remove packages/app-store/routing-forms/jsonLogicToPrisma.ts implementation
- Remove packages/app-store/routing-forms/__tests__/jsonLogicToPrisma.test.ts test file

These files were no longer being used in the routing-forms package.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* feat: clean up remaining references to removed report functionality

- Remove unused report.schema.ts file
- Remove buildResponsesForReporting test file
- Remove report endpoint from router
- Update comment referencing jsonLogicToPrisma

All references to the removed jsonLogicToPrisma and report handler functionality have been cleaned up.

Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com>

* Update packages/app-store/routing-forms/lib/getQueryBuilderConfig.ts

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-08-20 15:17:35 +05:30
Tim NeutkensandKeith Williams 0a11c45065 perf: Only run Sentry in production (#23167)
* Only run Sentry in production

* Only run Sentry in production for instrumentation-client

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-20 07:27:51 +00:00
Tim Neutkens 4ae5de5a3a Only apply autoprefixer in production (#23168) 2025-08-20 07:26:32 +00:00
564 changed files with 19914 additions and 9215 deletions
+187
View File
@@ -0,0 +1,187 @@
language: en-US
tone_instructions: ''
early_access: false
enable_free_tier: true
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: false
high_level_summary_placeholder: '@coderabbitai summary'
high_level_summary_in_walkthrough: false
auto_title_placeholder: '@coderabbitai'
auto_title_instructions: ''
review_status: true
commit_status: true
fail_commit_status: false
collapse_walkthrough: false
changed_files_summary: false
sequence_diagrams: false
estimate_code_review_effort: false
assess_linked_issues: true
related_issues: false
related_prs: true
suggested_labels: false
auto_apply_labels: false
suggested_reviewers: false
auto_assign_reviewers: false
poem: false
labeling_instructions: []
path_filters:
- '!**/node_modules/**'
- '!**/dist/**'
- '!**/build/**'
- '!**/.next/**'
path_instructions:
- path: "**/*.{ts,tsx,js,jsx}"
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
abort_on_close: true
disable_cache: false
auto_review:
enabled: true
auto_incremental_review: true
ignore_title_keywords: []
labels: []
drafts: false
base_branches: []
finishing_touches:
docstrings:
enabled: true
unit_tests:
enabled: true
pre_merge_checks:
docstrings:
mode: warning
threshold: 80
title:
mode: warning
requirements: ''
description:
mode: warning
issue_assessment:
mode: warning
tools:
ast-grep:
rule_dirs: []
util_dirs: []
essential_rules: true
packages: []
shellcheck:
enabled: true
ruff:
enabled: true
markdownlint:
enabled: true
github-checks:
enabled: true
timeout_ms: 180000
languagetool:
enabled: true
enabled_rules: []
disabled_rules: []
enabled_categories: []
disabled_categories: []
enabled_only: false
level: default
biome:
enabled: true
hadolint:
enabled: true
swiftlint:
enabled: true
phpstan:
enabled: true
level: default
phpmd:
enabled: true
phpcs:
enabled: true
golangci-lint:
enabled: true
yamllint:
enabled: true
gitleaks:
enabled: true
checkov:
enabled: true
detekt:
enabled: true
eslint:
enabled: true
flake8:
enabled: true
rubocop:
enabled: true
buf:
enabled: true
regal:
enabled: true
actionlint:
enabled: true
pmd:
enabled: true
cppcheck:
enabled: true
semgrep:
enabled: true
circleci:
enabled: true
clippy:
enabled: true
sqlfluff:
enabled: true
prismaLint:
enabled: true
pylint:
enabled: true
oxc:
enabled: true
shopifyThemeCheck:
enabled: true
luacheck:
enabled: true
brakeman:
enabled: true
dotenvLint:
enabled: true
htmlhint:
enabled: true
checkmake:
enabled: true
osvScanner:
enabled: true
chat:
art: true
auto_reply: true
integrations:
jira:
usage: auto
linear:
usage: auto
knowledge_base:
opt_out: false
web_search:
enabled: true
code_guidelines:
enabled: true
filePatterns: []
learnings:
scope: auto
issues:
scope: auto
jira:
usage: auto
project_keys: []
linear:
usage: auto
team_keys: []
pull_requests:
scope: auto
mcp:
usage: auto
disabled_servers: []
code_generation:
docstrings:
language: en-US
path_instructions: []
unit_tests:
path_instructions: []
+14 -1
View File
@@ -383,10 +383,23 @@ TASKER_ENABLE_EMAILS=0
# Ratelimiting via unkey
UNKEY_ROOT_KEY=
# Used for Cal.ai Enterprise Voice AI Agents
# Used for Cal.ai Voice AI Agents
# https://retellai.com
RETELL_AI_KEY=
# Used for buying phone number for cal ai voice agent
STRIPE_PHONE_NUMBER_MONTHLY_PRICE_ID=
CAL_AI_CALL_RATE_PER_MINUTE=0.29
STRIPE_WEBHOOK_SECRET_BILLING=
# Price for buying a phone number for cal.ai voice agent (Default is 5)
NEXT_PUBLIC_CAL_AI_PHONE_NUMBER_MONTHLY_PRICE=
# Used for the huddle01 integration
HUDDLE01_API_TOKEN=
+6 -1
View File
@@ -2,7 +2,12 @@
/apps/api/**/* @calcom/Platform @calcom/Foundation
/apps/ui-playground/**/* @calcom/Consumer @calcom/Foundation
/apps/web/**/layout.tsx @calcom/Consumer @calcom/Foundation
/apps/web/lib/**/* @calcom/Foundation
/apps/web/lib/csp.ts @calcom/Foundation
/apps/web/lib/buildNonce.ts @calcom/Foundation
/apps/web/lib/daily-webhook/**/* @calcom/Foundation
/apps/web/lib/core/**/* @calcom/Foundation
/apps/web/lib/booking.ts @calcom/Foundation
/apps/web/lib/handleOrgRedirect.ts @calcom/Foundation
/apps/web/middleware.ts @calcom/Foundation
/deploy/**/* @calcom/Foundation
/infra/**/* @calcom/Foundation
+7 -1
View File
@@ -74,8 +74,14 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
e2e-atoms:
name: Tests
needs: [lint, build, build-atoms, build-api-v2]
uses: ./.github/workflows/e2e-atoms.yml
secrets: inherit
required:
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
needs: [lint, type-check, unit-test, integration-test, build, build-api-v1, build-api-v2, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
steps:
@@ -1,15 +1,7 @@
name: Atoms E2E Tests
on:
pull_request:
branches:
- main
paths:
- 'packages/platform/atoms/**'
- 'packages/platform/examples/base/**'
- 'apps/api/v2/**'
- '.github/workflows/atoms-e2e.yml'
workflow_call:
permissions:
actions: write
contents: read
@@ -25,7 +17,7 @@ env:
DATABASE_URL: ${{ secrets.CI_DATABASE_URL }}
jobs:
atoms-e2e:
e2e-atoms:
timeout-minutes: 15
name: Atoms E2E Tests
runs-on: buildjet-4vcpu-ubuntu-2204
@@ -45,13 +37,13 @@ jobs:
uses: actions/upload-artifact@v4
if: always()
with:
name: atoms-e2e-test-results
name: e2e-atoms-test-results
path: test-results
retention-days: 7
- name: Upload Playwright Report
uses: actions/upload-artifact@v4
if: always()
with:
name: atoms-e2e-playwright-report
name: e2e-atoms-playwright-report
path: playwright-report
retention-days: 7
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
with:
repo-token: ${{ secrets.EQUITY_BEE_TEAM_LABELER_ACTION_TOKEN }}
organization-name: calcom
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, teams, webhooks, workflows, zapier"
ignore-labels: "admin, app-store, ai, authentication, automated-testing, billing, bookings, caldav, calendar-apps, ci, console, crm-apps, dba, devops, docs, documentation, emails, embeds, event-types, i18n, impersonation, manual-testing, ui, performance, ops-stack, organizations, public-api, routing-forms, seats, self-hosted, teams, webhooks, workflows, zapier"
apply-labels-from-issue:
runs-on: ubuntu-latest
+65
View File
@@ -0,0 +1,65 @@
name: Performance Tests
on:
release:
types: [created]
workflow_dispatch:
inputs:
base_url:
description: 'Base URL for performance tests'
required: false
default: 'https://cal.dev'
jobs:
performance-tests:
runs-on: ubuntu-latest
env:
BASE_URL: ${{ github.event.inputs.base_url || 'https://cal.dev' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup K6
uses: grafana/setup-k6-action@v1
with:
k6-version: '1.0.0'
- name: Run Smoke Tests
uses: grafana/run-k6-action@v1
env:
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }}
with:
path: tests/performance/smoke/*.js
cloud-run-locally: true
fail-fast: true
- name: Run Load Tests
uses: grafana/run-k6-action@v1
env:
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }}
with:
path: tests/performance/load/*.js
cloud-run-locally: true
fail-fast: true
- name: Run Stress Tests
uses: grafana/run-k6-action@v1
env:
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }}
with:
path: tests/performance/stress/*.js
cloud-run-locally: true
fail-fast: true
- name: Run Spike Tests
uses: grafana/run-k6-action@v1
env:
K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }}
K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }}
with:
path: tests/performance/spike/*.js
cloud-run-locally: true
fail-fast: true
+9 -1
View File
@@ -195,6 +195,13 @@ jobs:
uses: ./.github/workflows/e2e-embed-react.yml
secrets: inherit
e2e-atoms:
name: Tests
needs: [changes, check-label, build, build-atoms, build-api-v2]
if: ${{ needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
uses: ./.github/workflows/e2e-atoms.yml
secrets: inherit
analyze:
name: Analyze Build
needs: [build]
@@ -204,7 +211,7 @@ jobs:
merge-reports:
name: Merge reports
if: ${{ !cancelled() && needs.check-label.outputs.run-e2e == 'true' && needs.changes.outputs.has-files-requiring-all-checks == 'true' }}
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store]
needs: [changes, check-label, e2e, e2e-embed, e2e-embed-react, e2e-app-store, e2e-atoms]
uses: ./.github/workflows/merge-reports.yml
secrets: inherit
@@ -249,6 +256,7 @@ jobs:
e2e-embed,
e2e-embed-react,
e2e-app-store,
e2e-atoms,
]
if: always()
runs-on: buildjet-2vcpu-ubuntu-2204
+2
View File
@@ -97,3 +97,5 @@ apps/auth
!.yarn/sdks
!.yarn/versions
i18n.cache
.claude
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
-7
View File
@@ -1,7 +0,0 @@
{
"functions": {
"pages/api/slots/*.ts": {
"memory": 512
}
}
}
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.318",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.335",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
+10
View File
@@ -9,6 +9,7 @@ import { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -33,6 +34,7 @@ describe("AppController", () => {
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeyString: string;
@@ -108,6 +110,14 @@ describe("AppController", () => {
organization: { connect: { id: organization.id } },
});
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
app = moduleRef.createNestApplication();
await app.init();
});
@@ -22,6 +22,7 @@ import { BookingSeatModule } from "@/modules/booking-seat/booking-seat.module";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { KyselyModule } from "@/modules/kysely/kysely.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
@@ -53,6 +54,7 @@ import { Module } from "@nestjs/common";
StripeModule,
TeamsModule,
TeamsEventTypesModule,
MembershipsModule,
],
providers: [
TokensRepository,
@@ -13,10 +13,14 @@ import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.ser
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
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 { Roles } from "@/modules/auth/decorators/roles/roles.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 { UsersService } from "@/modules/users/services/users.service";
import { UserWithProfile } from "@/modules/users/users.repository";
@@ -96,6 +100,7 @@ export class BookingsController_2024_08_13 {
) {}
@Post("/")
@UseGuards(OptionalApiAuthGuard)
@ApiOperation({
summary: "Create a booking",
description: `
@@ -138,9 +143,10 @@ export class BookingsController_2024_08_13 {
async createBooking(
@Body(new CreateBookingInputPipe())
body: CreateBookingInput,
@Req() request: Request
@Req() request: Request,
@GetOptionalUser() user: AuthOptionalUser
): Promise<CreateBookingOutput_2024_08_13> {
const booking = await this.bookingsService.createBooking(request, body);
const booking = await this.bookingsService.createBooking(request, body, user);
if (Array.isArray(booking)) {
await this.bookingsService.billBookings(booking);
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ import { AppModule } from "@/app.module";
import { HttpExceptionFilter } from "@/filters/http-exception.filter";
import { PrismaExceptionFilter } from "@/filters/prisma-exception.filter";
import { Locales } from "@/lib/enums/locales";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import {
CreateManagedUserData,
CreateManagedUserOutput,
@@ -80,7 +81,7 @@ describe("Managed user bookings 2024-08-13", () => {
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [PrismaExceptionFilter, HttpExceptionFilter],
imports: [AppModule, UsersModule],
imports: [AppModule, UsersModule, MembershipsModule],
}).compile();
app = moduleRef.createNestApplication();
@@ -772,6 +773,77 @@ describe("Managed user bookings 2024-08-13", () => {
});
});
describe("event type booking requires authentication", () => {
let eventTypeRequiringAuthenticationId: number;
let body: CreateBookingInput_2024_08_13;
beforeAll(async () => {
const eventTypeRequiringAuthentication = await eventTypesRepositoryFixture.create(
{
title: `event-type-requiring-authentication-${randomString()}`,
slug: `event-type-requiring-authentication-${randomString()}`,
length: 60,
requiresConfirmation: true,
bookingRequiresAuthentication: true,
},
secondManagedUser.user.id
);
eventTypeRequiringAuthenticationId = eventTypeRequiringAuthentication.id;
body = {
start: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)).toISOString(),
eventTypeId: eventTypeRequiringAuthenticationId,
attendee: {
email: "external@example.com",
name: "External Attendee",
timeZone: "Europe/Rome",
},
};
});
it("can't be booked without credentials", async () => {
await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(401);
});
it("can't be booked with managed user credentials who is not admin and not event type owner", async () => {
await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${firstManagedUser.accessToken}`)
.expect(403);
});
it("can be booked with managed user credentials who is event type owner", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${secondManagedUser.accessToken}`)
.expect(201);
const bookingId = response.body.data.id;
await bookingsRepositoryFixture.deleteById(bookingId);
});
it("can be booked with managed user credentials who is admin", async () => {
const response = await request(app.getHttpServer())
.post(`/v2/bookings`)
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
.expect(201);
const bookingId = response.body.data.id;
await bookingsRepositoryFixture.deleteById(bookingId);
});
});
afterAll(async () => {
await userRepositoryFixture.delete(firstManagedUser.user.id);
await userRepositoryFixture.delete(secondManagedUser.user.id);
@@ -18,7 +18,6 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
@@ -27,7 +26,6 @@ import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@cal
import {
CancelBookingInput_2024_08_13,
CancelSeatedBookingInput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
@@ -35,11 +33,9 @@ import {
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
import { Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
describe("Seated bookings", () => {
let app: INestApplication;
let organization: Team;
@@ -48,7 +44,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
@@ -58,7 +53,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let apiKeyString: string;
let seatedEventTypeId: number;
const maxRecurrenceCount = 3;
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
@@ -83,7 +77,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
@@ -190,6 +183,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -264,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid: createdSeatedBooking.seatUid,
bookingFieldsResponses: {
guests: [],
name: createdSeatedBooking.attendees[0].name,
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
},
@@ -278,6 +273,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -517,6 +513,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -21,7 +21,12 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
@@ -44,6 +49,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let normalEventType: EventType;
const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let variableLengthRecurringEventType: EventType;
const VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG = `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -105,6 +112,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
user.id
);
variableLengthRecurringEventType = await eventTypesRepositoryFixture.create(
{
title: `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`,
slug: VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG,
length: 15,
metadata: { multipleDuration: [15, 30, 60] },
recurringEvent: { freq: 2, count: 3, interval: 1 }, // Weekly, 3 times, every 1 week
},
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -246,6 +264,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("create recurring bookings", () => {
it("should create recurring bookings with custom lengthInMinutes", async () => {
const lengthInMinutes = 30;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 1, 14, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes,
recurrenceCount: 2,
attendee: {
name: "Mr Recurring",
email: "mr_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
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: { status: string; data: RecurringBookingOutput_2024_08_13[] } = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data).toHaveLength(2);
const firstBooking = responseBody.data[0];
const secondBooking = responseBody.data[1];
expect(firstBooking.duration).toEqual(lengthInMinutes);
expect(secondBooking.duration).toEqual(lengthInMinutes);
});
});
it("should reject recurring booking with invalid lengthInMinutes", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 3, 1, 10, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes: 45, // Not in allowed [15, 30, 60]
recurrenceCount: 2,
attendee: {
name: "Mr Invalid",
email: "mr_invalid@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -6,9 +6,12 @@ import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/servi
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
import { getPagination } from "@/lib/pagination/pagination";
import { AuthOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { BillingService } from "@/modules/billing/services/billing.service";
import { BookingSeatRepository } from "@/modules/booking-seat/booking-seat.repository";
import { KyselyReadService } from "@/modules/kysely/kysely-read.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { MembershipsService } from "@/modules/memberships/services/memberships.service";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthClientUsersService } from "@/modules/oauth-clients/services/oauth-clients-users.service";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
@@ -18,7 +21,14 @@ import { TeamsEventTypesRepository } from "@/modules/teams/event-types/teams-eve
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
import { UsersService } from "@/modules/users/services/users.service";
import { UsersRepository, UserWithProfile } from "@/modules/users/users.repository";
import { ConflictException, Injectable, Logger, NotFoundException } from "@nestjs/common";
import {
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
UnauthorizedException,
} from "@nestjs/common";
import { BadRequestException } from "@nestjs/common";
import { Request } from "express";
import { DateTime } from "luxon";
@@ -95,10 +105,12 @@ export class BookingsService_2024_08_13 {
private readonly organizationsRepository: OrganizationsRepository,
private readonly teamsRepository: TeamsRepository,
private readonly teamsEventTypesRepository: TeamsEventTypesRepository,
private readonly membershipsRepository: MembershipsRepository,
private readonly membershipsService: MembershipsService,
private readonly errorsBookingsService: ErrorsBookingsService_2024_08_13
) {}
async createBooking(request: Request, body: CreateBookingInput) {
async createBooking(request: Request, body: CreateBookingInput, authUser: AuthOptionalUser) {
let bookingTeamEventType = false;
try {
const eventType = await this.getBookedEventType(body);
@@ -108,6 +120,7 @@ export class BookingsService_2024_08_13 {
if (!eventType) {
this.errorsBookingsService.handleEventTypeToBeBookedNotFound(body);
}
await this.checkBookingRequiresAuthenticationSetting(eventType, authUser);
if (eventType.schedulingType === "MANAGED") {
throw new BadRequestException(
@@ -142,6 +155,56 @@ export class BookingsService_2024_08_13 {
}
}
async checkBookingRequiresAuthenticationSetting(
eventType: EventTypeWithOwnerAndTeam,
authUser: AuthOptionalUser
) {
if (!eventType.bookingRequiresAuthentication) return true;
if (!authUser) {
throw new UnauthorizedException(
"checkBookingRequiresAuthentication - request must be authenticated by passing credentials belonging to event type owner, host or team or org admin or owner."
);
}
const authUserId = authUser.id;
const authUserRole = authUser.role;
const eventTypeId = eventType.id;
const teamId = eventType.teamId;
const bookingUserId = eventType.owner?.id || null;
if (authUserRole === "ADMIN") return;
if (bookingUserId === authUserId) return;
if (eventTypeId) {
const [isUserHost, isUserAssigned] = await Promise.all([
this.eventTypesRepository.isUserHostOfEventType(authUserId, eventTypeId),
this.eventTypesRepository.isUserAssignedToEventType(authUserId, eventTypeId),
]);
if (isUserHost || isUserAssigned) return;
}
if (teamId) {
const membership = await this.membershipsRepository.getUserAdminOrOwnerTeamMembership(
authUserId,
teamId
);
if (membership) return;
}
if (
bookingUserId &&
(await this.membershipsService.isUserOrgAdminOrOwnerOfAnotherUser(authUserId, bookingUserId))
) {
return;
}
throw new ForbiddenException(
"checkBookingRequiresAuthentication - user is not authorized to access this event type. User has to be either event type owner, host, team admin or owner or org admin or owner."
);
}
async getBookedEventType(body: CreateBookingInput) {
if (body.eventTypeId) {
return await this.eventTypesRepository.getEventTypeByIdWithOwnerAndTeam(body.eventTypeId);
@@ -420,6 +420,9 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
}
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
const repeatsEvery = occurrence.interval;
@@ -456,7 +459,7 @@ export class InputBookingsService_2024_08_13 {
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
for (let i = 0; i < repeatsTimes; i++) {
const endTime = startTime.plus({ minutes: eventType.length });
const endTime = startTime.plus({ minutes: lengthInMinutes });
events.push({
start: startTime.toISO(),
@@ -1,6 +1,5 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
@@ -498,6 +498,7 @@ describe("Event types Endpoints", () => {
lightThemeHex: "#fafafa",
},
customName: `{Event type title} between {Organiser} and {Scheduler}`,
bookingRequiresAuthentication: true,
};
return request(app.getHttpServer())
@@ -570,6 +571,7 @@ describe("Event types Endpoints", () => {
];
expect(createdEventType.bookingFields).toEqual(expectedBookingFields);
expect(createdEventType.bookingRequiresAuthentication).toEqual(true);
eventType = responseBody.data;
});
});
@@ -1026,6 +1028,7 @@ describe("Event types Endpoints", () => {
lightThemeHex: "#fafafa",
},
customName: `{Event type title} betweennnnnnnnnnn {Organiser} and {Scheduler}`,
bookingRequiresAuthentication: false,
};
return request(app.getHttpServer())
@@ -1120,6 +1123,8 @@ describe("Event types Endpoints", () => {
eventType.color = updatedEventType.color;
eventType.bookingFields = updatedEventType.bookingFields;
eventType.calVideoSettings = updatedEventType.calVideoSettings;
expect(updatedEventType.bookingRequiresAuthentication).toEqual(false);
});
});
@@ -2028,7 +2033,6 @@ describe("Event types Endpoints", () => {
const username = name;
let user: User;
let legacyEventTypeId1: number;
let legacyEventTypeId2: number;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -132,4 +132,26 @@ export class EventTypesRepository_2024_06_14 {
async deleteEventType(eventTypeId: number) {
return this.dbWrite.prisma.eventType.delete({ where: { id: eventTypeId } });
}
async isUserHostOfEventType(userId: number, eventTypeId: number) {
const eventType = await this.dbRead.prisma.eventType.findFirst({
where: {
id: eventTypeId,
hosts: { some: { userId: userId } },
},
select: { id: true },
});
return !!eventType;
}
async isUserAssignedToEventType(userId: number, eventTypeId: number) {
const eventType = await this.dbRead.prisma.eventType.findFirst({
where: {
id: eventTypeId,
users: { some: { id: userId } },
},
select: { id: true },
});
return !!eventType;
}
}
@@ -90,6 +90,7 @@ type Input = Pick<
| "hideOrganizerEmail"
| "calVideoSettings"
| "hidden"
| "bookingRequiresAuthentication"
>;
@Injectable()
@@ -129,6 +130,7 @@ export class OutputEventTypesService_2024_06_14 {
hideOrganizerEmail,
calVideoSettings,
hidden,
bookingRequiresAuthentication,
} = databaseEventType;
const locations = this.transformLocations(databaseEventType.locations);
@@ -206,6 +208,7 @@ export class OutputEventTypesService_2024_06_14 {
hideOrganizerEmail,
calVideoSettings,
hidden,
bookingRequiresAuthentication,
};
}
@@ -520,6 +520,8 @@ export const systemBeforeFieldName: NameSystemField = {
name: "fullName",
type: "text",
required: true,
label: "",
placeholder: "",
},
],
},
@@ -292,8 +292,8 @@ describe("transformBookingFieldsInternalToApi", () => {
isDefault: true,
required: true,
disableOnPrefill: false,
label: undefined,
placeholder: undefined,
label: "",
placeholder: "",
},
];
@@ -2,13 +2,7 @@ import { extractUserContext } from "@/lib/extract-user-context";
import { filterReqHeaders } from "@/lib/filterReqHeaders";
import type { ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { Catch, HttpStatus, Logger } from "@nestjs/common";
import {
PrismaClientInitializationError,
PrismaClientKnownRequestError,
PrismaClientRustPanicError,
PrismaClientUnknownRequestError,
PrismaClientValidationError,
} from "@prisma/client/runtime/library";
import { Prisma } from "@prisma/client";
import { Request } from "express";
import {
@@ -21,18 +15,18 @@ import {
import { Response } from "@calcom/platform-types";
type PrismaError =
| PrismaClientInitializationError
| PrismaClientKnownRequestError
| PrismaClientRustPanicError
| PrismaClientUnknownRequestError
| PrismaClientValidationError;
| Prisma.PrismaClientInitializationError
| Prisma.PrismaClientKnownRequestError
| Prisma.PrismaClientRustPanicError
| Prisma.PrismaClientUnknownRequestError
| Prisma.PrismaClientValidationError;
@Catch(
PrismaClientInitializationError,
PrismaClientKnownRequestError,
PrismaClientRustPanicError,
PrismaClientUnknownRequestError,
PrismaClientValidationError
Prisma.PrismaClientInitializationError,
Prisma.PrismaClientKnownRequestError,
Prisma.PrismaClientRustPanicError,
Prisma.PrismaClientUnknownRequestError,
Prisma.PrismaClientValidationError
)
export class PrismaExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger("PrismaExceptionFilter");
@@ -58,7 +52,7 @@ export class PrismaExceptionFilter implements ExceptionFilter {
let message = "There was an error, please try again later.";
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
let errorCode = INTERNAL_SERVER_ERROR;
if (error instanceof PrismaClientKnownRequestError) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
switch (error.code) {
case "P2002": // Unique constraint failed
errorCode = CONFLICT;
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaAttributeRepository as PrismaAttributeRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaAttributeRepository extends PrismaAttributeRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaHostRepository as PrismaHostRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaHostRepository extends PrismaHostRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -0,0 +1,27 @@
import { PrismaAttributeRepository } from "@/lib/repositories/prisma-attribute.repository";
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaHostRepository } from "@/lib/repositories/prisma-host.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { Injectable } from "@nestjs/common";
import { LuckyUserService as BaseLuckyUserService } from "@calcom/platform-libraries/bookings";
@Injectable()
export class LuckyUserService extends BaseLuckyUserService {
constructor(
bookingRepository: PrismaBookingRepository,
hostRepository: PrismaHostRepository,
oooRepository: PrismaOOORepository,
userRepository: PrismaUserRepository,
attributeRepository: PrismaAttributeRepository
) {
super({
bookingRepository: bookingRepository,
hostRepository: hostRepository,
oooRepository: oooRepository,
userRepository: userRepository,
attributeRepository: attributeRepository,
});
}
}
@@ -4,10 +4,12 @@ import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/ato
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
import { AtomsVerificationController } from "@/modules/atoms/controllers/atoms.verification.controller";
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
@@ -32,6 +34,7 @@ import { Module } from "@nestjs/common";
AtomsRepository,
UsersService,
SchedulesAtomsService,
VerificationAtomsService,
RedisService,
],
exports: [EventTypesAtomService],
@@ -40,6 +43,7 @@ import { Module } from "@nestjs/common";
AtomsEventTypesController,
AtomsConferencingAppsController,
AtomsSchedulesController,
AtomsVerificationController,
],
})
export class AtomsModule {}
@@ -0,0 +1,108 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
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";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
Body,
UseGuards,
Version,
VERSION_NEUTRAL,
HttpCode,
HttpStatus,
Get,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - verification endpoints for atoms")
@DocsExcludeController(true)
export class AtomsVerificationController {
constructor(private readonly verificationService: VerificationAtomsService) {}
@Post("/verification/email/send-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "atoms_verification_email_send_code" })
async sendEmailVerificationCode(
@Body() body: SendVerificationEmailInput
): Promise<SendVerificationEmailOutput> {
const result = await this.verificationService.sendEmailVerificationCode({
email: body.email,
username: body.username,
language: body.language,
isVerifyingEmail: body.isVerifyingEmail,
});
return {
data: { sent: result.ok && !result.skipped },
status: SUCCESS_STATUS,
};
}
@Get("/verification/email/check")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async checkEmailVerificationRequired(
@Query() query: CheckEmailVerificationRequiredParams
): Promise<ApiResponse<boolean>> {
const required = await this.verificationService.checkEmailVerificationRequired({
email: query.email,
userSessionEmail: query.userSessionEmail,
});
return {
data: required,
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async verifyEmailCode(@Body() body: VerifyEmailCodeInput): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeUnAuthenticated({
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code-authenticated")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async verifyEmailCodeAuthenticated(
@Body() body: VerifyEmailCodeInput,
@GetUser() user: UserWithProfile
): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeAuthenticated(user, {
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString } from "class-validator";
export class CheckEmailVerificationRequiredParams {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "user@example.com" })
@IsOptional()
@IsString()
userSessionEmail?: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString, IsBoolean } from "class-validator";
export class SendVerificationEmailInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "johndoe" })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ example: "en" })
@IsOptional()
@IsString()
language?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isVerifyingEmail?: boolean;
}
@@ -0,0 +1,12 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsString } from "class-validator";
export class VerifyEmailCodeInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiProperty({ example: "123456" })
@IsString()
code!: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class SendVerificationEmailData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly sent!: boolean;
}
export class SendVerificationEmailOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => SendVerificationEmailData)
@Expose()
@ApiProperty({ type: SendVerificationEmailData })
readonly data!: SendVerificationEmailData;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class VerifyEmailCodeData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly verified!: boolean;
}
export class VerifyEmailCodeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => VerifyEmailCodeData)
@Expose()
@ApiProperty({ type: VerifyEmailCodeData })
readonly data!: VerifyEmailCodeData;
}
@@ -0,0 +1,64 @@
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
import {
verifyCodeUnAuthenticated,
verifyCodeAuthenticated,
sendEmailVerificationByCode,
checkEmailVerificationRequired,
} from "@calcom/platform-libraries";
@Injectable()
export class VerificationAtomsService {
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
return await checkEmailVerificationRequired(input);
}
async verifyEmailCodeUnAuthenticated(input: VerifyEmailCodeInput) {
try {
return await verifyCodeUnAuthenticated(input);
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email and code are required");
}
}
throw new BadRequestException("Verification failed");
}
}
async verifyEmailCodeAuthenticated(user: UserWithProfile, input: VerifyEmailCodeInput) {
try {
return await verifyCodeAuthenticated({
user,
email: input.email,
code: input.code,
});
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email, code, and user ID are required");
}
}
throw new UnauthorizedException("Verification failed");
}
}
async sendEmailVerificationCode(input: SendVerificationEmailInput) {
return await sendEmailVerificationByCode({
email: input.email,
username: input.username,
language: input.language,
isVerifyingEmail: input.isVerifyingEmail,
});
}
}
@@ -6,7 +6,6 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -22,7 +21,6 @@ import { PassportModule } from "@nestjs/passport";
MembershipsModule,
TokensModule,
DeploymentsModule,
ProfilesModule,
],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard],
@@ -2,6 +2,8 @@ import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.st
import { ExecutionContext } from "@nestjs/common";
import { createParamDecorator } from "@nestjs/common";
export type AuthOptionalUser = ApiAuthGuardUser | null;
export const GetOptionalUser = createParamDecorator<
keyof ApiAuthGuardUser | (keyof ApiAuthGuardUser)[],
ExecutionContext
@@ -4,6 +4,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { JwtService } from "@/modules/jwt/jwt.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
@@ -20,6 +21,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { createRequest } from "node-mocks-http";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
@@ -45,6 +47,7 @@ describe("ApiAuthStrategy", () => {
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
@@ -65,6 +68,7 @@ describe("ApiAuthStrategy", () => {
}),
ProfilesModule,
TokensModule,
MembershipsModule,
],
providers: [
MockedRedisService,
@@ -91,6 +95,7 @@ describe("ApiAuthStrategy", () => {
teamRepositoryFixture = new TeamRepositoryFixture(module);
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
membershipRepositoryFixture = new MembershipRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
organizationTwo = await teamRepositoryFixture.create({
name: `api-auth-organization-2-${randomString()}`,
@@ -121,6 +126,20 @@ describe("ApiAuthStrategy", () => {
organization: { connect: { id: organizationTwo.id } },
});
await membershipRepositoryFixture.create({
user: { connect: { id: validOAuthUser.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
await membershipRepositoryFixture.create({
user: { connect: { id: validOAuthUser.id } },
team: { connect: { id: organizationTwo.id } },
role: "OWNER",
accepted: true,
});
const data = {
logo: "logo-url",
name: "name",
@@ -4,9 +4,9 @@ import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service";
import { UsersService } from "@/modules/users/services/users.service";
@@ -45,8 +45,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
private readonly userRepository: UsersRepository,
private readonly apiKeyRepository: ApiKeysRepository,
private readonly oauthRepository: OAuthClientRepository,
private readonly profilesRepository: ProfilesRepository,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly membershipsRepository: MembershipsRepository
) {
super();
}
@@ -172,7 +172,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
}
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
const platformCreatorId =
(await this.membershipsRepository.findPlatformOwnerUserId(client.organizationId)) ||
(await this.membershipsRepository.findPlatformAdminUserId(client.organizationId));
if (!platformCreatorId) {
throw new UnauthorizedException(
@@ -89,7 +89,6 @@ export class BillingService implements OnModuleDestroy {
teamId: teamId.toString(),
plan: plan.toString(),
},
currency: "usd",
subscription_data: {
metadata: {
teamId: teamId.toString(),
@@ -117,7 +116,6 @@ export class BillingService implements OnModuleDestroy {
teamId: teamId.toString(),
plan: plan.toString(),
},
currency: "usd",
});
if (!url) throw new InternalServerErrorException("Failed to create Stripe session.");
@@ -403,7 +401,6 @@ export class BillingService implements OnModuleDestroy {
fromReschedule?: string | null;
}
) {
if (this.configService.get("e2e")) {
return true;
}
@@ -465,7 +462,6 @@ export class BillingService implements OnModuleDestroy {
async onModuleDestroy() {
try {
await this.billingQueue.close();
} catch (err) {
this.logger.error(err);
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import { MembershipRole } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
@Injectable()
export class MembershipsRepository {
@@ -20,6 +20,36 @@ export class MembershipsRepository {
return membership;
}
async findPlatformOwnerUserId(organizationId: number): Promise<number | undefined> {
const owner = await this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
role: "OWNER",
accepted: true,
},
select: {
userId: true,
},
});
return owner?.userId ?? undefined;
}
async findPlatformAdminUserId(organizationId: number): Promise<number | undefined> {
const admin = await this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
role: "ADMIN",
accepted: true,
},
select: {
userId: true,
},
});
return admin?.userId ?? undefined;
}
async findMembershipByTeamId(teamId: number, userId: number) {
const membership = await this.dbRead.prisma.membership.findUnique({
where: {
@@ -60,6 +90,26 @@ export class MembershipsRepository {
return !!adminMembership;
}
async getOrgIdsWhereUserIsAdminOrOwner(userId: number) {
const userOrgAdminMemberships = await this.dbRead.prisma.membership.findMany({
where: {
userId: userId,
accepted: true,
role: {
in: ["OWNER", "ADMIN"],
},
team: {
parentId: null,
},
},
select: {
teamId: true,
},
});
return userOrgAdminMemberships.map((m) => m.teamId);
}
async createMembership(teamId: number, userId: number, role: MembershipRole, accepted: boolean) {
const membership = await this.dbRead.prisma.membership.create({
data: {
@@ -73,4 +123,46 @@ export class MembershipsRepository {
return membership;
}
async getUserAdminOrOwnerTeamMembership(userId: number, teamId: number) {
return this.dbRead.prisma.membership.findFirst({
where: {
userId: userId,
teamId: teamId,
accepted: true,
role: {
in: ["OWNER", "ADMIN"],
},
},
});
}
async getUserMembershipInOneOfOrgs(userId: number, orgIds: number[]) {
return this.dbRead.prisma.membership.findFirst({
where: {
userId: userId,
accepted: true,
teamId: {
in: orgIds,
},
team: {
parentId: null,
},
},
});
}
async getUserMembershipInOneOfOrgsTeams(userId: number, orgIds: number[]) {
return this.dbRead.prisma.membership.findFirst({
where: {
userId: userId,
accepted: true,
team: {
parentId: {
in: orgIds,
},
},
},
});
}
}
@@ -21,4 +21,28 @@ export class MembershipsService {
"teamId"
);
}
async isUserOrgAdminOrOwnerOfAnotherUser(userId: number, anotherUserId: number) {
const orgIdsWhereUserIsAdminOrOwner = await this.membershipsRepository.getOrgIdsWhereUserIsAdminOrOwner(
userId
);
if (orgIdsWhereUserIsAdminOrOwner.length === 0) {
return false;
}
const anotherUserOrgMembership = await this.membershipsRepository.getUserMembershipInOneOfOrgs(
anotherUserId,
orgIdsWhereUserIsAdminOrOwner
);
if (anotherUserOrgMembership) return true;
const anotherUserOrgTeamMembership = await this.membershipsRepository.getUserMembershipInOneOfOrgsTeams(
anotherUserId,
orgIdsWhereUserIsAdminOrOwner
);
return !!anotherUserOrgTeamMembership;
}
}
@@ -15,6 +15,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -63,6 +64,7 @@ describe("OAuthFlow Endpoints", () => {
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let user: User;
let organization: Team;
@@ -89,6 +91,7 @@ describe("OAuthFlow Endpoints", () => {
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await usersRepositoryFixtures.create({
email: userEmail,
@@ -104,6 +107,13 @@ describe("OAuthFlow Endpoints", () => {
movedFromUser: { connect: { id: user.id } },
organization: { connect: { id: organization.id } },
});
await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
oAuthClient = await createOAuthClient(organization.id);
});
@@ -39,6 +39,7 @@ import {
HttpStatus,
NotFoundException,
Query,
Logger,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
@@ -66,6 +67,8 @@ export type EventTypeHandlerResponse = {
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsEventTypesController {
private readonly logger = new Logger("OrganizationsEventTypesController");
constructor(
private readonly organizationsEventTypesService: OrganizationsEventTypesService,
private readonly inputService: InputOrganizationsEventTypesService,
@@ -88,6 +91,10 @@ export class OrganizationsEventTypesController {
teamId,
bodyEventType
);
this.logger.debug(
"nl debug - create org team event type - transformedBody",
JSON.stringify(transformedBody, null, 2)
);
const eventType = await this.organizationsEventTypesService.createOrganizationTeamEventType(
user,
@@ -725,6 +725,8 @@ describe("Organizations Event Types Endpoints", () => {
isDefault: true,
type: "name",
slug: "name",
label: "",
placeholder: "",
required: true,
disableOnPrefill: false,
},
@@ -821,6 +823,8 @@ describe("Organizations Event Types Endpoints", () => {
isDefault: true,
type: "name",
slug: "name",
label: "",
placeholder: "",
required: true,
disableOnPrefill: false,
},
@@ -47,6 +47,10 @@ export class OrganizationsEventTypesService {
prisma: this.dbWrite.prisma,
},
});
this.logger.debug(
"nl debug - create org team event type - eventTypeCreated",
JSON.stringify(eventTypeCreated, null, 2)
);
return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user, true);
}
@@ -75,6 +75,7 @@ type Input = Pick<
| "team"
| "calVideoSettings"
| "hidden"
| "bookingRequiresAuthentication"
>;
@Injectable()
@@ -43,6 +43,8 @@ export class OrganizationsTeamsSchedulesController {
@DocsTags("Orgs / Teams / Schedules")
@ApiOperation({ summary: "Get all team member schedules" })
async getTeamSchedules(
// note(Lauirs): putting orgId so swagger is generacted correctly
@Param("orgId", ParseIntPipe) _orgId: number,
@Param("teamId", ParseIntPipe) teamId: number,
@Query() queryParams: SkipTakePagination
): Promise<GetSchedulesOutput_2024_06_11> {
@@ -65,6 +67,9 @@ export class OrganizationsTeamsSchedulesController {
summary: "Get schedules of a team member",
})
async getUserSchedules(
// note(Lauirs): putting orgId and teamId so swagger is generacted correctly
@Param("orgId", ParseIntPipe) _orgId: number,
@Param("teamId", ParseIntPipe) _teamId: number,
@Param("userId", ParseIntPipe) userId: number
): Promise<GetSchedulesOutput_2024_06_11> {
const schedules = await this.schedulesService.getUserSchedules(userId);
@@ -1,6 +1,9 @@
import { VERSION_2024_09_04 } from "@/lib/api-versions";
import { OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER, OPTIONAL_X_CAL_CLIENT_ID_HEADER } from "@/lib/docs/headers";
import { GetOptionalUser } from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import {
AuthOptionalUser,
GetOptionalUser,
} from "@/modules/auth/decorators/get-optional-user/get-optional-user.decorator";
import { OptionalApiAuthGuard } from "@/modules/auth/guards/optional-api-auth/optional-api-auth.guard";
import { GetReservedSlotOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-reserved-slot.output";
import { GetSlotsOutput_2024_09_04 } from "@/modules/slots/slots-2024-09-04/outputs/get-slots.output";
@@ -26,7 +29,6 @@ import {
ApiResponse as DocsResponse,
ApiQuery,
} from "@nestjs/swagger";
import { User } from "@prisma/client";
import { plainToClass } from "class-transformer";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
@@ -261,7 +263,7 @@ export class SlotsController_2024_09_04 {
@ApiHeader(OPTIONAL_API_KEY_OR_ACCESS_TOKEN_HEADER)
async reserveSlot(
@Body() body: ReserveSlotInput_2024_09_04,
@GetOptionalUser() user: User
@GetOptionalUser() user: AuthOptionalUser
): Promise<ReserveSlotOutputResponse_2024_09_04> {
const reservedSlot = await this.slotsService.reserveSlot(body, user?.id);
@@ -133,6 +133,7 @@ export class TeamsEventTypesService {
});
const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId);
this.logger.debug("nl debug - update team event type - eventType", JSON.stringify(eventType, null, 2));
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
+257
View File
@@ -5547,6 +5547,97 @@
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/schedules": {
"get": {
"operationId": "OrganizationsTeamsSchedulesController_getTeamSchedules",
"summary": "Get all team member schedules",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"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"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11"
}
}
}
}
},
"tags": [
"Orgs / Teams / Schedules"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/stripe/connect": {
"get": {
"operationId": "OrganizationsStripeController_getTeamStripeConnectUrl",
@@ -5720,6 +5811,22 @@
"type": "string"
}
},
{
"name": "orgId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "userId",
"required": true,
@@ -13215,6 +13322,71 @@
]
}
},
"/v2/teams/{teamId}/schedules": {
"get": {
"operationId": "TeamsSchedulesController_getTeamSchedules",
"summary": "Get all team member schedules",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "teamId",
"required": true,
"in": "path",
"schema": {
"type": "number"
}
},
{
"name": "take",
"required": false,
"in": "query",
"description": "Maximum number of items to return",
"example": 25,
"schema": {
"minimum": 1,
"maximum": 250,
"default": 250,
"type": "number"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"description": "Number of items to skip",
"example": 0,
"schema": {
"minimum": 0,
"default": 0,
"type": "number"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/GetSchedulesOutput_2024_06_11"
}
}
}
}
},
"tags": [
"Teams / Schedules"
]
}
},
"/v2/teams/{teamId}/verified-resources/emails/verification-code/request": {
"post": {
"operationId": "TeamsVerifiedResourcesController_requestEmailVerificationCode",
@@ -16504,6 +16676,14 @@
}
]
},
"hidden": {
"type": "boolean"
},
"bookingRequiresAuthentication": {
"type": "boolean",
"default": false,
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
},
"locations": {
"type": "array",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
@@ -18166,6 +18346,13 @@
}
]
},
"hidden": {
"type": "boolean"
},
"bookingRequiresAuthentication": {
"type": "boolean",
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
},
"ownerId": {
"type": "number",
"example": 10
@@ -18195,6 +18382,8 @@
"successRedirectUrl",
"isInstantEvent",
"scheduleId",
"hidden",
"bookingRequiresAuthentication",
"ownerId",
"users"
]
@@ -18515,6 +18704,14 @@
}
]
},
"hidden": {
"type": "boolean"
},
"bookingRequiresAuthentication": {
"type": "boolean",
"default": false,
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
},
"locations": {
"type": "array",
"description": "Locations where the event will take place. If not provided, cal video link will be used as the location.",
@@ -20343,6 +20540,14 @@
}
]
},
"hidden": {
"type": "boolean"
},
"bookingRequiresAuthentication": {
"type": "boolean",
"default": false,
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
},
"schedulingType": {
"type": "string",
"enum": [
@@ -20777,6 +20982,13 @@
}
]
},
"hidden": {
"type": "boolean"
},
"bookingRequiresAuthentication": {
"type": "boolean",
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
},
"teamId": {
"type": "number"
},
@@ -21223,6 +21435,14 @@
}
]
},
"hidden": {
"type": "boolean"
},
"bookingRequiresAuthentication": {
"type": "boolean",
"default": false,
"description": "Boolean to require authentication for booking this event type via api. If true, only authenticated users can book this event type."
},
"hosts": {
"type": "array",
"items": {
@@ -22589,6 +22809,11 @@
},
"unit": {
"type": "object",
"enum": [
"hour",
"minute",
"day"
],
"description": "Unit for the offset time",
"example": "hour"
}
@@ -22612,6 +22837,9 @@
"type": {
"type": "string",
"default": "beforeEvent",
"enum": [
"beforeEvent"
],
"description": "Trigger type for the workflow",
"example": "beforeEvent"
}
@@ -22635,6 +22863,9 @@
"type": {
"type": "string",
"default": "afterEvent",
"enum": [
"afterEvent"
],
"description": "Trigger type for the workflow",
"example": "afterEvent"
}
@@ -22650,6 +22881,9 @@
"type": {
"type": "string",
"default": "eventCancelled",
"enum": [
"eventCancelled"
],
"description": "Trigger type for the workflow"
}
},
@@ -22663,6 +22897,9 @@
"type": {
"type": "string",
"default": "newEvent",
"enum": [
"newEvent"
],
"description": "Trigger type for the workflow"
}
},
@@ -22676,6 +22913,9 @@
"type": {
"type": "string",
"default": "rescheduleEvent",
"enum": [
"rescheduleEvent"
],
"description": "Trigger type for the workflow"
}
},
@@ -22697,6 +22937,9 @@
"type": {
"type": "string",
"default": "afterGuestsCalVideoNoShow",
"enum": [
"afterGuestsCalVideoNoShow"
],
"description": "Trigger type for the workflow",
"example": "afterGuestsCalVideoNoShow"
}
@@ -22720,6 +22963,9 @@
"type": {
"type": "string",
"default": "afterHostsCalVideoNoShow",
"enum": [
"afterHostsCalVideoNoShow"
],
"description": "Trigger type for the workflow",
"example": "afterHostsCalVideoNoShow"
}
@@ -23314,6 +23560,17 @@
"properties": {
"type": {
"type": "object",
"enum": [
[
"beforeEvent",
"eventCancelled",
"newEvent",
"afterEvent",
"rescheduleEvent",
"afterHostsCalVideoNoShow",
"afterGuestsCalVideoNoShow"
]
],
"description": "Trigger type for the workflow"
}
},
@@ -15,7 +15,6 @@ export class ApiKeysRepositoryFixture {
async createApiKey(userId: number, expiresAt: Date | null, teamId?: number) {
const keyString = randomBytes(16).toString("hex");
const hashedKey = createHash("sha256").update(keyString).digest("hex");
console.log("createApiKey Fixutre", { hashedKey, keyString });
const apiKey = await this.prismaWriteClient.apiKey.create({
data: {
userId,
@@ -1,6 +1,6 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps, SearchParams } from "app/_types";
import type { PageProps, Params, SearchParams } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
@@ -15,20 +15,34 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import LegacyPage from "~/team/type-view";
import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
import CachedTeamBooker, {
generateMetadata as generateCachedMetadata,
getOrgContext,
} from "./pageWithCachedData";
import { getTeamId } from "./queries";
async function isCachedTeamBookingEnabled(params: Params, searchParams: SearchParams): Promise<boolean> {
if (searchParams.experimentalTeamBookingPageCache !== "true") return false;
const { teamSlug, currentOrgDomain, isValidOrgDomain } = await getOrgContext(params);
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
const teamId = await getTeamId(teamSlug, orgSlug);
if (!teamId) return false;
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository(prisma);
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
const isTeamFeatureEnabled = await featuresRepository.checkIfTeamHasFeature(
teamId,
"team-booking-page-cache"
);
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true";
return isTeamFeatureEnabled;
}
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await generateCachedMetadata({ params, searchParams });
}
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx);
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
@@ -68,7 +82,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await CachedTeamBooker({ params, searchParams });
}
@@ -41,7 +41,7 @@ const _getTeamMetadataForBooking = (teamData: NonNullable<TeamData>, eventTypeId
};
};
async function _getOrgContext(params: Params) {
export async function getOrgContext(params: Params) {
const result = paramsSchema.safeParse({
slug: params?.slug,
type: params?.type,
@@ -74,7 +74,7 @@ const _getMultipleDurationValue = (
};
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
if (!teamData) return {}; // should never happen
@@ -123,7 +123,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
};
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
// Handle org redirects
@@ -13,6 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums";
@@ -158,3 +159,14 @@ export async function getCRMData(
crmRecordId,
};
}
export async function getTeamId(teamSlug: string, orgSlug: string | null): Promise<number | null> {
const teamRepo = new TeamRepository(prisma);
const team = await teamRepo.findFirstBySlugAndParentSlug({
slug: teamSlug,
parentSlug: orgSlug,
select: { id: true },
});
return team?.id ?? null;
}
@@ -1,9 +1,14 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsList from "~/bookings/views/bookings-listing-view";
@@ -26,10 +31,11 @@ const Page = async ({ params }: PageProps) => {
redirect("/bookings/upcoming");
}
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<BookingsList status={parsed.data.status} />
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
</ShellMainAppDir>
);
};
@@ -1,32 +0,0 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { APP_NAME, WEBAPP_URL } from "@calcom/lib/constants";
import { Button } from "@calcom/ui/components/button";
export const generateMetadata = () =>
_generateMetadata(
(t) => `${t("access_denied")} | ${APP_NAME}`,
() => "",
undefined,
undefined,
"/403"
);
async function Error403() {
const t = await getTranslate();
return (
<div className="bg-subtle flex h-screen">
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
<h1 className="font-cal text-emphasis text-6xl">403</h1>
<h2 className="text-emphasis mt-6 text-2xl font-medium">{t("dont_have_access_this_page")}</h2>
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">
{t("you_need_admin_or_owner_privileges_to_access")}
</p>
<Button href={WEBAPP_URL}>{t("go_back_home")}</Button>
</div>
</div>
);
}
export default Error403;
@@ -1,22 +0,0 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { showToast } from "@calcom/ui/components/toast";
export default function CopyButton({ error }: { error: string }) {
const { t } = useLocale();
return (
<Button
color="secondary"
className="mt-2 border-0 font-sans font-normal hover:bg-gray-300"
StartIcon="copy"
onClick={() => {
navigator.clipboard.writeText(error);
showToast("Link copied!", "success");
}}>
{t("copy")}
</Button>
);
}
@@ -1,47 +0,0 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { APP_NAME } from "@calcom/lib/constants";
import { Button } from "@calcom/ui/components/button";
import CopyButton from "./copy-button";
export const generateMetadata = () =>
_generateMetadata(
(t) => `${t("something_unexpected_occurred")} | ${APP_NAME}`,
() => "",
undefined,
undefined,
"/500"
);
async function Error500({ searchParams }: { searchParams: { error?: string } }) {
const t = await getTranslate();
return (
<div className="bg-subtle flex h-screen">
<div className="rtl: bg-default m-auto rounded-md p-10 text-right ltr:text-left">
<h1 className="font-cal text-emphasis text-6xl">500</h1>
<h2 className="text-emphasis mt-6 text-2xl font-medium">{t("500_error_message")}</h2>
<p className="text-default mb-6 mt-4 max-w-2xl text-sm">{t("something_went_wrong_on_our_end")}</p>
{searchParams?.error && (
<div className="mb-8 flex flex-col">
<p className="text-default mb-4 max-w-2xl text-sm">
{t("please_provide_following_text_to_suppport")}:
</p>
<pre className="bg-emphasis text-emphasis w-full max-w-2xl whitespace-normal break-words rounded-md p-4">
{searchParams.error}
<br />
<CopyButton error={searchParams.error} />
</pre>
</div>
)}
<Button href="mailto:support@cal.com">{t("contact_support")}</Button>
<Button color="secondary" href="javascript:history.back()" className="ml-2">
{t("go_back")}
</Button>
</div>
</div>
);
}
export default Error500;
@@ -1,7 +0,0 @@
import { redirect } from "next/navigation";
const Page = () => {
redirect("/apps/installed/calendar");
};
export default Page;
@@ -1,7 +0,0 @@
import { redirect } from "next/navigation";
const ServerPage = async () => {
redirect("/apps/routing-forms/forms");
};
export default ServerPage;
@@ -1,17 +0,0 @@
import { _generateMetadata } from "app/_utils";
import { redirect } from "next/navigation";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("apps"),
(t) => t("admin_apps_description"),
undefined,
undefined,
"/settings/admin/apps"
);
const Page = () => {
redirect("/settings/admin/apps/calendar");
};
export default Page;
@@ -21,11 +21,13 @@ const Page = async () => {
createRouterCaller(appsRouter),
]);
const connectedCalendars = await calendarsCaller.connectedCalendars();
const installedCalendars = await appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
});
const [connectedCalendars, installedCalendars] = await Promise.all([
calendarsCaller.connectedCalendars(),
appsCaller.integrations({
variant: "calendar",
onlyInstalled: true,
}),
]);
return (
<CalendarListContainer connectedCalendars={connectedCalendars} installedCalendars={installedCalendars} />
);
@@ -21,9 +21,9 @@ export const generateMetadata = async () =>
);
const Page = async () => {
const t = await getTranslate();
const [t, _headers, _cookies] = await Promise.all([getTranslate(), headers(), cookies()]);
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
const session = await getServerSession({ req: buildLegacyRequest(_headers, _cookies) });
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
@@ -6,9 +6,9 @@ import type { Resource } from "@calcom/features/pbac/domain/types/permission-reg
import { PERMISSION_REGISTRY, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import classNames from "@calcom/ui/classNames";
import { Checkbox, Label } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { Checkbox, Label } from "@calcom/ui/form";
import { usePermissions } from "./usePermissions";
@@ -1,7 +1,7 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/button";
import { Button } from "@calcom/ui/components/button";
import { useRoleSheetState } from "../hooks/useRoleQueryStates";
@@ -14,10 +14,10 @@ import {
} from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/button";
import { Button } from "@calcom/ui/components/button";
import { Form, TextField, Checkbox, Label } from "@calcom/ui/components/form";
import { Sheet, SheetContent, SheetFooter, SheetHeader, SheetTitle } from "@calcom/ui/components/sheet";
import { showToast } from "@calcom/ui/components/toast";
import { Form, TextField, Checkbox, Label } from "@calcom/ui/form";
import { revalidateTeamRoles } from "../actions";
import { AdvancedPermissionGroup } from "./AdvancedPermissionGroup";
@@ -2,9 +2,9 @@
import type { Scope } from "@calcom/features/pbac/domain/types/permission-registry";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/badge";
import { Button } from "@calcom/ui/button";
import classNames from "@calcom/ui/classNames";
import { Badge } from "@calcom/ui/components/badge";
import { Button } from "@calcom/ui/components/button";
import {
Dropdown,
DropdownMenuContent,
@@ -44,17 +44,5 @@ describe("usePermissions", () => {
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
it("should return 'all' for resource with manage permission", () => {
const permissions = ["eventType.manage"];
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
it("should return 'all' for resource with manage permission even if other permissions are missing", () => {
const permissions = ["eventType.manage", "eventType.read"]; // Has manage and read, but missing create, update, delete
expect(getResourcePermissionLevel("eventType", permissions)).toBe("all");
});
});
});
@@ -51,20 +51,14 @@ export function usePermissions(): UsePermissionsReturn {
return "all";
}
// Check if user has manage permission for this resource
const hasManagePermission = permissions.includes(`${resource}.manage`);
if (hasManagePermission) {
return "all";
}
// Filter out internal keys like _resource and manage when checking for individual permissions
const crudPermissions = Object.keys(resourceConfig)
.filter((action) => !action.startsWith("_") && action !== "manage")
// Filter out internal keys like _resource when checking permissions
const allResourcePerms = Object.keys(resourceConfig)
.filter((action) => !action.startsWith("_"))
.map((action) => `${resource}.${action}`);
const hasAllCrudPerms = crudPermissions.every((p) => permissions.includes(p));
const hasAllPerms = allResourcePerms.every((p) => permissions.includes(p));
const hasReadPerm = permissions.includes(`${resource}.${CrudAction.Read}`);
if (hasAllCrudPerms) return "all";
if (hasAllPerms) return "all";
if (hasReadPerm) return "read";
return "none";
};
@@ -40,9 +40,7 @@ const getCachedRoles = unstable_cache(
const Page = async () => {
const orgCaller = await createRouterCaller(viewerOrganizationsRouter);
const [org, teams] = await Promise.all([orgCaller.listCurrent(), orgCaller.getTeams()]);
const attributes = await getCachedAttributes(org.id);
const roles = await getCachedRoles(org.id);
const [attributes, roles] = await Promise.all([getCachedAttributes(org.id), getCachedRoles(org.id)]);
const facetedTeamValues = {
roles,
+42
View File
@@ -80,6 +80,48 @@ export const _generateMetadata = async (
};
};
export const _generateMetadataForStaticPage = (
title: string,
description: string,
hideBranding?: boolean,
origin?: string,
pathname?: string
) => {
const _pathname = pathname ?? "";
const canonical = buildCanonical({ path: _pathname, origin: origin ?? CAL_URL });
const titleSuffix = `| ${APP_NAME}`;
const displayedTitle = title.includes(titleSuffix) || hideBranding ? title : `${title} ${titleSuffix}`;
const metadataBase = new URL(IS_CALCOM ? getCalcomUrl() : WEBAPP_URL);
const metadata = {
title: title.length === 0 ? APP_NAME : displayedTitle,
description,
alternates: { canonical },
openGraph: {
description: truncateOnWord(description, 158),
url: canonical,
type: "website",
siteName: APP_NAME,
title: displayedTitle,
},
metadataBase,
};
const image =
SEO_IMG_OGIMG +
constructGenericImage({
title: metadata.title,
description: metadata.description,
});
return {
...metadata,
openGraph: {
...metadata.openGraph,
images: [image],
},
};
};
export const generateMeetingMetadata = async (
meeting: MeetingImageProps,
getTitle: (t: TFunction<string, undefined>) => string,
@@ -1,7 +1,9 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { parseRequestData } from "app/api/parseRequestData";
import * as jose from "jose";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import * as dummy from "openid-client";
import { uuid } from "short-uuid";
import jackson from "@calcom/features/ee/sso/lib/jackson";
@@ -9,6 +11,10 @@ import type { OAuthTokenReq } from "@calcom/features/ee/sso/lib/jackson";
import logger from "@calcom/lib/logger";
async function handler(req: NextRequest) {
// Need these imports to fix import errors with jackson
// https://github.com/ory/polis/blob/main/pages/api/import-hack.ts
const unused = dummy; // eslint-disable-line @typescript-eslint/no-unused-vars
const unused2 = jose; // eslint-disable-line @typescript-eslint/no-unused-vars
const { oauthController } = await jackson();
const log = logger.getSubLogger({ prefix: ["[SAML token]"] });
@@ -0,0 +1,5 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import handler from "@calcom/features/calAIPhone/phoneNumberSubscriptionWebhook";
export const GET = defaultResponderForAppDir(handler);
+20 -1
View File
@@ -30,6 +30,10 @@ const querySchema = z.object({
const decryptedSchema = z.object({
bookingUid: z.string(),
userId: z.number().int(),
platformClientId: z.string().optional(),
platformRescheduleUrl: z.string().optional(),
platformCancelUrl: z.string().optional(),
platformBookingUrl: z.string().optional(),
});
// Move the sessionGetter function outside the GET function
@@ -64,7 +68,14 @@ async function handler(request: NextRequest) {
symmetricDecrypt(decodeURIComponent(token), process.env.CALENDSO_ENCRYPTION_KEY || "")
);
const { bookingUid, userId } = decryptedSchema.parse(decryptedData);
const {
bookingUid,
userId,
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
} = decryptedSchema.parse(decryptedData);
const booking = await prisma.booking.findUniqueOrThrow({
where: { uid: bookingUid },
@@ -97,6 +108,14 @@ async function handler(request: NextRequest) {
recurringEventId: booking.recurringEventId || undefined,
confirmed: action === DirectAction.ACCEPT,
reason,
platformClientParams: platformClientId
? {
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
platformBookingUrl,
}
: undefined,
});
} catch (e) {
let message = "Error confirming booking";
+10 -8
View File
@@ -193,25 +193,27 @@ async function getHandler(request: NextRequest) {
const response = await fetch(filteredLogo);
const arrayBuffer = await response.arrayBuffer();
let buffer: Buffer = Buffer.from(arrayBuffer);
let contentType = response.headers.get("content-type") || "image/png";
// If we need to resize the team logos (via Next.js' built-in image processing)
// Resize the team logos if needed
if (teamLogos[logoDefinition.source] && logoDefinition.w) {
const { detectContentType, optimizeImage } = await import("next/dist/server/image-optimizer");
buffer = await optimizeImage({
const { resizeImage } = await import("@calcom/lib/server/imageUtils");
const { buffer: outBuffer, contentType: outContentType } = await resizeImage({
buffer,
contentType: (await detectContentType(buffer)) ?? "image/jpeg",
quality: 100,
width: logoDefinition.w,
height: logoDefinition.h, // optional
height: logoDefinition.h,
quality: 100,
contentType,
});
buffer = outBuffer;
contentType = outContentType;
}
// Create a new response with the image buffer
const imageResponse = new NextResponse(buffer as BodyInit);
// Set the appropriate headers
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
imageResponse.headers.set("Content-Type", contentType);
imageResponse.headers.set("Cache-Control", "s-maxage=86400, stale-while-revalidate=60");
return imageResponse;
-13
View File
@@ -1,13 +0,0 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import { NextResponse } from "next/server";
async function getHandler() {
return NextResponse.json({ message: "Please don't" }, { status: 400 });
}
async function postHandler() {
return NextResponse.json({ message: "Please don't" }, { status: 400 });
}
export const GET = defaultResponderForAppDir(getHandler);
export const POST = defaultResponderForAppDir(postHandler);
@@ -12,7 +12,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { HttpError } from "@calcom/lib/http-error";
import prisma from "@calcom/prisma";
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -50,7 +50,7 @@ async function getHandler(req: NextRequest, { params }: { params: Promise<Params
if (!team) {
const prevTeam = await prisma.team.findFirstOrThrow({ where: { id } });
metadata = teamMetadataSchema.safeParse(prevTeam.metadata);
metadata = teamMetadataStrictSchema.safeParse(prevTeam.metadata);
if (!metadata.success) {
throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
}
@@ -80,7 +80,7 @@ async function getHandler(req: NextRequest, { params }: { params: Promise<Params
}
if (!metadata) {
metadata = teamMetadataSchema.safeParse(team.metadata);
metadata = teamMetadataStrictSchema.safeParse(team.metadata);
if (!metadata.success) {
throw new HttpError({ statusCode: 400, message: "Invalid team metadata" });
}
@@ -0,0 +1,686 @@
import type { NextRequest } from "next/server";
import { Retell } from "retell-sdk";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import type { CalAiPhoneNumber, User, Team } from "@calcom/prisma/client";
import { POST } from "../route";
type MockPhoneNumberWithUser = Omit<CalAiPhoneNumber, "user" | "team"> & {
user: Pick<User, "id" | "email" | "name">;
team: null;
};
type MockPhoneNumberWithTeam = Omit<CalAiPhoneNumber, "user" | "team"> & {
user: null;
team: Pick<Team, "id" | "name">;
};
type RetellWebhookBody = {
event: "call_started" | "call_ended" | "call_analyzed";
call: {
call_id: string;
agent_id?: string;
from_number?: string;
to_number?: string;
direction?: "inbound" | "outbound";
call_status?: string;
start_timestamp?: number;
end_timestamp?: number;
disconnection_reason?: string;
metadata?: Record<string, unknown>;
retell_llm_dynamic_variables?: Record<string, unknown>;
transcript?: string;
opt_out_sensitive_data_storage?: boolean;
call_cost?: {
product_costs?: Array<{
product: string;
unitPrice?: number;
cost?: number;
}>;
total_duration_seconds?: number;
total_duration_unit_price?: number;
total_one_time_price?: number;
combined_cost?: number;
};
call_analysis?: {
call_summary?: string;
in_voicemail?: boolean;
user_sentiment?: string;
call_successful?: boolean;
custom_analysis_data?: Record<string, unknown>;
};
};
};
vi.mock("app/api/defaultResponderForAppDir", () => ({
defaultResponderForAppDir:
(
handler: (req: NextRequest, context: { params: Promise<Record<string, string>> }) => Promise<Response>
) =>
async (req: NextRequest, context?: { params: Promise<Record<string, string>> }) =>
await handler(req, context || { params: Promise.resolve({}) }),
}));
vi.mock("retell-sdk", () => ({
Retell: {
verify: vi.fn(),
},
}));
const mockHasAvailableCredits = vi.fn();
const mockChargeCredits = vi.fn();
vi.mock("@calcom/features/ee/billing/credit-service", () => ({
CreditService: vi.fn().mockImplementation(() => ({
hasAvailableCredits: mockHasAvailableCredits,
chargeCredits: mockChargeCredits,
})),
}));
vi.mock("@calcom/lib/server/repository/PrismaPhoneNumberRepository", () => ({
PrismaPhoneNumberRepository: {
findByPhoneNumber: vi.fn(),
},
}));
vi.mock("next/server", () => ({
NextResponse: {
json: vi.fn((data, options) => ({
json: () => Promise.resolve(data),
status: options?.status || 200,
})),
},
}));
const createMockRequest = (body: RetellWebhookBody, signature?: string): NextRequest => {
const request = {
text: vi.fn().mockResolvedValue(JSON.stringify(body)),
headers: {
get: vi.fn((name: string) => (name === "x-retell-signature" ? signature : null)),
},
} as unknown as NextRequest;
return request;
};
const callPOST = (request: NextRequest) => POST(request, { params: Promise.resolve({}) });
describe("Retell AI Webhook Handler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("RETELL_AI_KEY", "test-api-key");
vi.stubEnv("CAL_AI_CALL_RATE_PER_MINUTE", "0.29");
});
it("should return 401 when signature is missing", async () => {
const request = createMockRequest(
{ event: "call_analyzed", call: { call_id: "test-call-id" } },
"some-signature"
);
const response = await callPOST(request);
expect(response.status).toBe(401);
});
it("should return 401 when API key is not configured", async () => {
vi.unstubAllEnvs();
const request = createMockRequest(
{ event: "call_analyzed", call: { call_id: "test-call-id" } },
"some-signature"
);
const response = await callPOST(request);
expect(response.status).toBe(401);
// Restore the stubbed env for subsequent tests
vi.stubEnv("RETELL_AI_KEY", "test-api-key");
});
it("should return 401 when signature is invalid", async () => {
vi.mocked(Retell.verify).mockReturnValue(false);
const request = createMockRequest(
{ event: "call_analyzed", call: { call_id: "test-call-id" } },
"invalid-signature"
);
const response = await callPOST(request);
expect(response.status).toBe(401);
});
it("should handle non-call_analyzed events", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const body: RetellWebhookBody = {
event: "call_started",
call: { call_id: "test-call-id" },
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(data.message).toContain("No handling for call_started");
});
it("should process call_analyzed event with valid phone number and sufficient credits", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 1,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 120,
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(true);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
teamId: undefined,
credits: 58, // 120 seconds = 2 minutes * $0.29 = $0.58 = 58 credits
callDuration: 120,
externalRef: "retell:test-call-id",
})
);
});
it("should handle team phone numbers", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockTeamPhoneNumber: MockPhoneNumberWithTeam = {
id: 2,
phoneNumber: "+1234567890",
userId: null,
teamId: 5,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id-2",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
team: { id: 5, name: "Test Team" },
user: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockTeamPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 50,
total_duration_seconds: 180,
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({
userId: undefined,
teamId: 5,
credits: 87, // 180 seconds = 3 minutes * $0.29 = $0.87 = 87 credits
callDuration: 180,
externalRef: "retell:test-call-id",
})
);
});
it("should handle missing total_duration_seconds", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
// missing total_duration_seconds
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
expect(PrismaPhoneNumberRepository.findByPhoneNumber).not.toHaveBeenCalled();
});
it("should handle phone number not found", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(null);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 90,
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
expect(mockChargeCredits).not.toHaveBeenCalled();
});
it("should handle insufficient credits", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 3,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id-3",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(false);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 150,
},
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
});
it("should handle schema validation errors", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
// Missing required fields like from_number, to_number, etc.
call_id: "test-call-id",
},
};
const request = createMockRequest(body, "valid-signature");
const response = await callPOST(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data.error).toBe("Internal server error");
});
it("should calculate credits correctly based on duration", async () => {
const testCases = [
{ durationSeconds: 60, expectedCredits: 29 }, // 1 minute * $0.29 = $0.29 = 29 credits
{ durationSeconds: 120, expectedCredits: 58 }, // 2 minutes * $0.29 = $0.58 = 58 credits
{ durationSeconds: 150, expectedCredits: 73 }, // 2.5 minutes * $0.29 = $0.725 = 73 credits (rounded up)
{ durationSeconds: 30, expectedCredits: 15 }, // 0.5 minutes * $0.29 = $0.145 = 15 credits (rounded up)
];
for (const { durationSeconds, expectedCredits } of testCases) {
vi.clearAllMocks();
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 4,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id-4",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: `test-call-${durationSeconds}s`,
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100, // This is ignored now
total_duration_seconds: durationSeconds,
},
},
};
const request = createMockRequest(body, "valid-signature");
await callPOST(request);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
teamId: undefined,
credits: expectedCredits,
callDuration: durationSeconds,
externalRef: expect.stringMatching(/^retell:test-call-/),
})
);
}
});
it("should pass callDuration to chargeCredits when provided", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 10,
phoneNumber: "+15550001111",
userId: 42,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "prov-10",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 42, email: "u@example.com", name: "U" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockHasAvailableCredits.mockResolvedValue(true);
mockChargeCredits.mockResolvedValue(undefined);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "call-dur",
from_number: "+15550002222",
to_number: "+15550001111",
direction: "outbound",
call_status: "completed",
start_timestamp: 123,
call_cost: {
combined_cost: 12, // This is ignored now
total_duration_seconds: 125,
},
},
};
const response = await callPOST(createMockRequest(body, "valid-signature"));
expect(response.status).toBe(200);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({ userId: 42, credits: 61, callDuration: 125 }) // 125s = 2.083 minutes * $0.29 = $0.604 = 61 credits (rounded up)
);
});
describe("Idempotency", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("should pass externalRef with correct format to chargeCredits", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 1,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
mockChargeCredits.mockResolvedValue({ userId: 1 });
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "test-idempotency-call",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 60,
},
},
};
const response = await callPOST(createMockRequest(body, "valid-signature"));
expect(response.status).toBe(200);
expect(mockChargeCredits).toHaveBeenCalledWith(
expect.objectContaining({
userId: 1,
teamId: undefined,
credits: 29, // 60 seconds = 1 minute * $0.29 = $0.29 = 29 credits
callDuration: 60,
externalRef: "retell:test-idempotency-call",
})
);
});
it("should handle duplicate webhook calls idempotently", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 1,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "duplicate-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 60,
},
},
};
// First call - should charge normally
mockChargeCredits.mockResolvedValue({ userId: 1 });
const response1 = await callPOST(createMockRequest(body, "valid-signature"));
expect(response1.status).toBe(200);
const data1 = await response1.json();
expect(data1.success).toBe(true);
expect(data1.message).toContain("Successfully charged 29 credits");
// Second call - should return duplicate
mockChargeCredits.mockResolvedValue({ bookingUid: null, duplicate: true });
const response2 = await callPOST(createMockRequest(body, "valid-signature"));
expect(response2.status).toBe(200);
const data2 = await response2.json();
expect(data2.success).toBe(true);
expect(data2.message).toContain("Successfully charged 29 credits");
// Verify chargeCredits was called twice with same externalRef
expect(mockChargeCredits).toHaveBeenCalledTimes(2);
expect(mockChargeCredits).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ externalRef: "retell:duplicate-call-id" })
);
expect(mockChargeCredits).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ externalRef: "retell:duplicate-call-id" })
);
});
it("should handle errors from chargeCredits gracefully", async () => {
vi.mocked(Retell.verify).mockReturnValue(true);
const mockPhoneNumber: MockPhoneNumberWithUser = {
id: 1,
phoneNumber: "+1234567890",
userId: 1,
teamId: null,
provider: "test-provider",
providerPhoneNumberId: "test-provider-id",
createdAt: new Date(),
updatedAt: new Date(),
stripeCustomerId: null,
stripeSubscriptionId: null,
subscriptionStatus: null,
inboundAgentId: null,
outboundAgentId: null,
user: { id: 1, email: "test@example.com", name: "Test User" },
team: null,
};
vi.mocked(PrismaPhoneNumberRepository.findByPhoneNumber).mockResolvedValue(mockPhoneNumber);
// Mock chargeCredits to throw an error
mockChargeCredits.mockRejectedValue(new Error("Credit service error"));
const body: RetellWebhookBody = {
event: "call_analyzed",
call: {
call_id: "error-call-id",
from_number: "+1234567890",
to_number: "+0987654321",
direction: "outbound",
call_status: "completed",
start_timestamp: 1234567890,
call_cost: {
combined_cost: 100,
total_duration_seconds: 60,
},
},
};
const response = await callPOST(createMockRequest(body, "valid-signature"));
// Should still return 200 to prevent retries
expect(response.status).toBe(200);
const data = await response.json();
expect(data.success).toBe(false);
expect(data.message).toContain("Error charging credits for Retell AI call");
});
});
});
@@ -0,0 +1,229 @@
import { defaultResponderForAppDir } from "app/api/defaultResponderForAppDir";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { Retell } from "retell-sdk";
import { z } from "zod";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { PrismaPhoneNumberRepository } from "@calcom/lib/server/repository/PrismaPhoneNumberRepository";
import { CreditUsageType } from "@calcom/prisma/enums";
const log = logger.getSubLogger({ prefix: ["retell-ai-webhook"] });
const RetellWebhookSchema = z.object({
event: z.enum(["call_started", "call_ended", "call_analyzed"]),
call: z
.object({
call_id: z.string(),
agent_id: z.string().optional(),
from_number: z.string(),
to_number: z.string(),
direction: z.enum(["inbound", "outbound"]),
call_status: z.string(),
start_timestamp: z.number(),
end_timestamp: z.number().optional(),
disconnection_reason: z.string().optional(),
metadata: z.record(z.any()).optional(),
retell_llm_dynamic_variables: z.record(z.any()).optional(),
transcript: z.string().optional(),
opt_out_sensitive_data_storage: z.boolean().optional(),
call_cost: z
.object({
product_costs: z
.array(
z.object({
product: z.string(),
unitPrice: z.number().optional(),
cost: z.number().optional(),
})
)
.optional(),
total_duration_seconds: z.number().optional(),
total_duration_unit_price: z.number().optional(),
total_one_time_price: z.number().optional(),
combined_cost: z.number().optional(),
})
.optional(),
call_analysis: z
.object({
call_summary: z.string().optional(),
in_voicemail: z.boolean().optional(),
user_sentiment: z.string().optional(),
call_successful: z.boolean().optional(),
custom_analysis_data: z.record(z.any()).optional(),
})
.optional(),
})
.passthrough(),
});
async function handleCallAnalyzed(callData: any) {
const { from_number, call_id, call_cost } = callData;
if (
!call_cost ||
typeof call_cost.total_duration_seconds !== "number" ||
!Number.isFinite(call_cost.total_duration_seconds) ||
call_cost.total_duration_seconds <= 0
) {
log.error(
`Invalid or missing call_cost.total_duration_seconds for call ${call_id}: ${safeStringify(call_cost)}`
);
return;
}
const phoneNumber = await PrismaPhoneNumberRepository.findByPhoneNumber({ phoneNumber: from_number });
if (!phoneNumber) {
log.error(`No phone number found for ${from_number}, cannot deduct credits`);
return;
}
// Support both personal and team phone numbers
const userId = phoneNumber.userId;
const teamId = phoneNumber.teamId;
if (!userId && !teamId) {
log.error(`Phone number ${from_number} has no associated user or team`);
return;
}
const rawRatePerMinute = process.env.CAL_AI_CALL_RATE_PER_MINUTE ?? "0.29";
const ratePerMinute = Number.parseFloat(rawRatePerMinute);
const safeRatePerMinute = Number.isFinite(ratePerMinute) && ratePerMinute > 0 ? ratePerMinute : 0.29;
const durationInMinutes = call_cost.total_duration_seconds / 60;
const callCost = durationInMinutes * safeRatePerMinute;
// Convert to cents and round up to ensure we don't undercharge
const creditsToDeduct = Math.ceil(callCost * 100);
const creditService = new CreditService();
try {
await creditService.chargeCredits({
userId: userId ?? undefined,
teamId: teamId ?? undefined,
credits: creditsToDeduct,
callDuration: call_cost.total_duration_seconds,
creditFor: CreditUsageType.CAL_AI_PHONE_CALL,
externalRef: `retell:${call_id}`,
});
} catch (e) {
log.error("Error charging credits for Retell AI call", {
error: e,
call_id,
call_cost,
userId,
teamId,
});
return {
success: false,
message: `Error charging credits for Retell AI call: ${
e instanceof Error ? e.message : "Unknown error"
}`,
};
}
return {
success: true,
message: `Successfully charged ${creditsToDeduct} credits (${
call_cost.total_duration_seconds
}s at $${safeRatePerMinute}/min) for ${teamId ? `team:${teamId}` : ""} ${
userId ? `user:${userId}` : ""
}, call ${call_id}`,
};
}
/**
* Retell AI Webhook Handler
*
* Setup Instructions:
* 1. Add this webhook URL to your Retell AI dashboard: https://yourdomain.com/api/webhooks/retell-ai
* 2. Ensure your domain is accessible from the internet (for local development, use ngrok or similar)
* 3. Set the RETELL_AI_KEY environment variable with your Retell API key (must have webhook badge)
*
* This webhook will:
* - Verify webhook signature for security
* - Receive call_analyzed events from Retell AI
* - Charge credits based on the call cost from the user's or team's credit balance
* - Log all transactions for audit purposes
*/
async function handler(request: NextRequest) {
const rawBody = await request.text();
const body = JSON.parse(rawBody);
// Verify webhook signature
const signature = request.headers.get("x-retell-signature");
const apiKey = process.env.RETELL_AI_KEY;
if (!signature || !apiKey) {
log.error("Missing signature or API key for webhook verification");
return NextResponse.json(
{
error: "Unauthorized",
message: "Missing signature or API key",
},
{ status: 401 }
);
}
if (!Retell.verify(rawBody, apiKey, signature)) {
log.error("Invalid webhook signature");
return NextResponse.json(
{
error: "Unauthorized",
message: "Invalid signature",
},
{ status: 401 }
);
}
if (body.event !== "call_analyzed") {
return NextResponse.json(
{
success: true,
message: `No handling for ${body.event} for call ${body.call?.call_id ?? "unknown"}`,
},
{ status: 200 }
);
}
try {
const payload = RetellWebhookSchema.parse(body);
const callData = payload.call;
if (callData.direction === "inbound") {
return NextResponse.json(
{
success: true,
message: `Inbound calls are not charged or supported for now. Ignoring call ${callData.call_id}`,
},
{ status: 200 }
);
}
log.info(`Received Retell AI webhook: ${payload.event} for call ${callData.call_id}`);
const result = await handleCallAnalyzed(callData);
return NextResponse.json(
{
success: result?.success ?? true,
message: result?.message ?? `Processed ${payload.event} for call ${callData.call_id}`,
},
{ status: 200 }
);
} catch (error) {
log.error("Error processing Retell AI webhook:", safeStringify(error));
return NextResponse.json(
{
error: "Internal server error",
message: error instanceof Error ? error.message : "Unknown error",
},
// we need to return 200 to retell ai to avoid retries
{ status: 200 }
);
}
}
export const POST = defaultResponderForAppDir(handler);
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function Page() {
redirect(process.env.NEXT_PUBLIC_WEBAPP_URL || "https://app.cal.com");
}
+14 -13
View File
@@ -1,4 +1,5 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { _generateMetadataForStaticPage } from "app/_utils";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import localFont from "next/font/local";
@@ -8,6 +9,15 @@ import type { IconName } from "@calcom/ui/components/icon";
import { lucideIconList } from "../../../../packages/ui/components/icon/icon-list.mjs";
import { IconGrid } from "./IconGrid";
export const dynamic = "force-static";
export const metadata: Metadata = _generateMetadataForStaticPage(
"Icons Showcase",
"",
undefined,
undefined,
"/icons"
);
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
const calFont = localFont({
src: "../../fonts/CalSans-SemiBold.woff2",
@@ -16,25 +26,16 @@ const calFont = localFont({
display: "swap",
weight: "600",
});
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => t("icon_showcase"),
() => "",
undefined,
undefined,
"/icons"
);
};
export default async function IconsPage() {
export default function IconsPage() {
const icons = Array.from(lucideIconList).sort() as IconName[];
const t = await getTranslate();
return (
<div className={`${interFont.variable} ${calFont.variable}`}>
<div className="bg-subtle flex h-screen">
<IconSprites />
<div className="bg-default m-auto min-w-full rounded-md p-10 text-right ltr:text-left">
<h1 className="text-emphasis font-cal text-2xl font-medium">{t("icons_showcase")}</h1>
<h1 className="text-emphasis font-cal text-2xl font-medium">Icons Showcase</h1>
<IconGrid title="Regular Icons" icons={icons} />
<IconGrid
title="Filled Icons"
+4 -1
View File
@@ -2,6 +2,7 @@
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";
@@ -17,11 +18,13 @@ 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 ? <PlainChat nonce={nonce} /> : null}
{!isBookingPage && !isOnboardingPage ? <PlainChat nonce={nonce} /> : null}
{!isEmbed && !isBookingPage && <NotificationSoundHandler />}
{/* @ts-expect-error FIXME remove this comment when upgrading typescript to v5 */}
<CacheProvider>
-7
View File
@@ -1,7 +0,0 @@
import { redirect } from "next/navigation";
const Page = () => {
redirect("/apps/routing-forms/forms");
};
export default Page;
@@ -218,7 +218,7 @@ export const ReassignDialog = ({
placeholder={t("search")}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className="scroll-bar flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
<div className="scroll-bar mt-2 flex h-[150px] flex-col gap-0.5 overflow-y-scroll rounded-md border p-1">
{teamMemberOptions.map((member) => (
<label
key={member.value}
+1 -8
View File
@@ -539,18 +539,11 @@ const NewRoutingManager = ({
url: chosenEventUrls.eventBookingAbsoluteUrl,
});
console.log("SETTING NEW VALUE", {
type: "reschedule_to_different_event_new_tab",
reschedulerWindow,
newBooking: null,
});
reroutingState.setValue({
type: "reschedule_to_different_event_new_tab",
reschedulerWindow,
newBooking: null,
});
console.log("VALUE NOW", JSON.stringify(reroutingState));
}
function reroutingPreview() {
@@ -910,7 +903,7 @@ export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: Rerout
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow preventCloseOnOutsideClick>
<DialogContent preventCloseOnOutsideClick>
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
<RerouteDialogContentAndFooter
booking={teamEventTypeBooking}
@@ -84,6 +84,7 @@ const AppConnectionItem = (props: IAppConnectionItem) => {
{t("this_app_requires_connected_account", {
appName: title,
dependencyName: dependency.name,
interpolation: { escapeValue: false },
})}
</span>
@@ -92,7 +92,7 @@ const ConnectedCalendars = (props: IConnectCalendarsProps) => {
loading={isPageLoading}
onClick={() => nextStep()}
disabled={disabledNextButton}>
{firstCalendar ? `${t("continue")}` : `${t("next_step_text")}`}
{firstCalendar ? `${t("connect_your_video")}` : `${t("connect_calendar_first")}`}
</Button>
</>
);
@@ -1,4 +1,3 @@
import { type TFunction } from "i18next";
import { useEffect, useState } from "react";
import type { Dispatch, SetStateAction } from "react";
@@ -20,11 +19,9 @@ interface ConnectedAppStepProps {
}
const ConnectedVideoStepInner = ({
t,
setAnyInstalledVideoApps,
user,
}: {
t: TFunction;
setAnyInstalledVideoApps: Dispatch<SetStateAction<boolean>>;
user: RouterOutputs["viewer"]["me"]["get"];
}) => {
@@ -94,7 +91,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
const [hasAnyInstalledVideoApps, setAnyInstalledVideoApps] = useState(false);
return (
<>
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} t={t} user={user} />
<ConnectedVideoStepInner setAnyInstalledVideoApps={setAnyInstalledVideoApps} user={user} />
<Button
EndIcon="arrow-right"
data-testid="save-video-button"
@@ -105,7 +102,7 @@ const ConnectedVideoStep = (props: ConnectedAppStepProps) => {
disabled={!hasAnyInstalledVideoApps}
loading={isPageLoading}
onClick={() => nextStep()}>
{t("next_step_text")}
{t("set_availability")}
</Button>
</>
);
@@ -81,7 +81,7 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
className="mt-2 w-full justify-center p-2 text-sm sm:mt-8"
loading={availabilityForm.formState.isSubmitting}
disabled={availabilityForm.formState.isSubmitting}>
{t("next_step_text")}
{t("complete_profile")}
</Button>
</div>
</Form>

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