Compare commits

..
Author SHA1 Message Date
github-actions[bot] 2965450929 chore: version packages (#22890)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-08-05 06:15:25 +00:00
ddoemonnandKartik Saini db9b9c45dc fix(event-types): remove gap in URL input for event creation (#22123)
* fix(event-types): remove gap in URL input for event creation

* fix: event setup tab spacing

* fix: space in profile view

---------

Co-authored-by: Kartik Saini <41051387+kart1ka@users.noreply.github.com>
2025-08-05 03:51:30 +00:00
emrysal 37f500526c chore: release v5.5.11 2025-08-05 00:46:56 +00:00
Eunjae Lee 006aecb066 fix: add backfill migration for routing response denormalized tables (#21474)
* fix: add backfill migration for routing response denormalized tables

* update script

* implement select type

* improve number handling

* clean up queries

* rename

* handle zero record case

* revert

* rename

* fix division problem

* log clean up

* force adding new rows after removing existing rows

* backfill only missing or wrong data

* remove sleep
2025-08-05 01:40:22 +01:00
Amit Sharma 0829929e98 perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC (#21781)
* perf: use repository for webhooks list query & caching in /settings/developer/webhooks/... RSC

* fix type check

* invalidate cache on webhook list item actions

* fix review comment

* fix merge conflicts
2025-08-05 01:25:31 +01:00
a61148cb51 test: add no show webhooks integration tests (#21697)
* test: add no show webhooks integration tests

* fix

* chore

* chore

* chore

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
Co-authored-by: Volnei Munhoz <volnei.munhoz@gmail.com>
2025-08-05 01:22:44 +01:00
Eunjae Lee 593c48c8b4 refactor: replace tremor with recharts (#22791)
* refactor: replace tremor with recharts

* update tooltip

* clean up types

* replace tremor with recharts

* replace BarList with recharts

* replace ProgressBar

* remove tremor from the repository

* clean up

* fix UserStatsTable

* fix type error

* add explicit return type
2025-08-05 01:21:23 +01:00
Lauris Skraucis fb364971fa fix: atoms fonts for non next apps (#22886)
* fix: atoms fonts for non next apps

* chore: add changeset
2025-08-04 16:51:05 +00:00
Eunjae LeeandAlex van Andel a2205d497d feat: add booking status filter to /insights (#22842)
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-04 13:48:17 +00:00
Eunjae LeeandDevanshu Sharma cf4e7a5d2c refactor: add useInsightsRoutingParameters() for InsightsRoutingService (#22792)
Co-authored-by: Devanshu Sharma <devanshusharma658@gmail.com>
2025-08-04 14:51:23 +02:00
Anik Dhabal Babu 0e2ee3adb2 chore: add logging for office365 calendar (#22867) 2025-08-04 12:26:57 +00:00
Anik Dhabal Babu dbe8a85cf5 fix: enable auto reording for organizer (#22882) 2025-08-04 12:23:56 +00:00
Anik Dhabal Babu 84210dc1b4 fix: build docs workflow failing in ci (#22883)
* fix: flaky e2e

* fix: build docs failure
2025-08-04 17:36:35 +05:30
Benny Joo 4061bfb3fe fix: refactor i18n loadTranslations and set timeout to 3s (#22878)
* refactor

* use abort controller

* address comment

* fix tests

* fix time

* fix tests

* fix platform library build
2025-08-04 10:30:18 +01:00
github-actions[bot]andLauris Skraucis 18059e50fa chore: version packages (#22813)
* chore: version packages

* Update CHANGELOG.md

* Update package.json

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Lauris Skraucis <lauris.skraucis@gmail.com>
2025-08-04 10:41:55 +02:00
Carina WollendorferandCarinaWolli 852eb346ed fix: minor UI improvements for end of month design (#22837)
* minor UI improvements

* revert classname changes

* remove not needed code

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-08-04 03:02:42 +00:00
b7e11dc112 chore: Add logging to handleNewBooking (#22868)
* Add logging

* Apply suggestion from @coderabbitai[bot]

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

* Update logger config, fix Create booking log

* Added oooExcludedDateRanges + type error fix

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2025-08-02 17:15:30 +01:00
Syed Ali Shahbaz 35a373e70a docs: Create add-new-booking-charts.mdx (#22865)
* Create add-charts.mdx

* Update mint.json

* Rename add-charts.mdx to add-new-booking-charts.mdx

* Update mint.json
2025-08-01 17:20:27 +00:00
Alex van Andel 02c0b721b9 Revert "fix: refactor i18n loadTranslations and set timeout to 3s (#22633)"
This reverts commit 6ff20b98f4.
2025-08-01 16:42:31 +01:00
Ashish manhas 29349845f8 fix:safe HTML rendering for radio label (#22645) 2025-08-01 15:56:05 +01:00
Eunjae Lee 28cb2cff64 chore: upgrade TypeScript to v5.9 (#22861) 2025-08-01 15:16:11 +01:00
Anik Dhabal Babu 9cb2eb3b8c fix: store attendee phone number on instant booking (#22823)
* fix: flaky e2e

* fix: not storing attendee phone number
2025-08-01 13:34:57 +00:00
Benny Joo 6ff20b98f4 fix: refactor i18n loadTranslations and set timeout to 3s (#22633)
* refactor

* use abort controller

* address comment

* fix tests

* fix time

* fix tests
2025-08-01 22:30:28 +09:00
Hariom Balhara aa618dbaa2 fix: Missing bookingId in BOOKING_CANCELLED webhook payload (#22713)
* send bookingId in BOOKING_CANCELLED through requestReschedule

* fix: make customInputs nullable in BookingWebhookFactory

* fix: make customInputs nullable in BookingWebhookFactory
2025-08-01 06:04:53 +00:00
emrysal 6e3de84067 chore: release v5.5.10
Post release / release (push) Canceled after 0s
2025-08-01 01:08:17 +00:00
Alex van Andel c6b19200a9 chore: Add node_modules/dist to ignore + convert config to .ts (#22852) 2025-08-01 00:02:46 +00:00
Joe Au-Yeung 91932212e3 fix: Cal Video sidebar not showing meeting time in timezone (#22821)
* Pass params as object to `formatToLocalizedTime`

* Refactor videos-single-view

* Refactor bookings-single-view
2025-07-31 16:34:02 -04:00
f81bb92beb fix: increment iCalSequence when changing booking location (#22847)
* fix: increment iCalSequence when changing booking location

- Add iCalSequence parameter to buildCalEventFromBooking function
- Update editLocation handler to increment sequence by 1 in CalendarEvent
- Update database with incremented iCalSequence when location changes
- Update buildCalEventFromBooking test to include new fields
- Ensures ICS files properly update existing calendar events instead of creating duplicates

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

* `BookingRepository.updateLocationById` to accept responses and iCalSequence

* `editLocation.handler` use `BookingRepository`

* Update iCalSequence

* Use iCalSequence from booking param

* Add iCalUID to evt object

* Clean up

* Fix test

* Fix test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-07-31 20:58:48 +01:00
1d1a242a72 refactor: convert getShouldServeCache to CacheService with dependency injection (#22814)
* refactor: convert getShouldServeCache to CacheService with dependency injection

- Create CacheService class following AvailableSlotsService DI pattern
- Add FeaturesRepository and CacheService to DI tokens and modules
- Create cache container with proper dependency injection setup
- Update handleNewBooking.ts and slots/util.ts to use new service
- Maintain backward compatibility with error-throwing wrapper function
- Follow established service patterns for clean architecture

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

* feat: inject CacheService into AvailableSlotsService via dependency injection

- Add cacheService to IAvailableSlotsService interface
- Update available-slots container to load cache modules
- Update available-slots module to inject CacheService dependency
- Replace direct getShouldServeCache call with injected service method
- Add CacheService import to util.ts for proper typing

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

* chore: DI api v2 cache service

* refactor: convert FeaturesRepository to use factory pattern in DI

- Change from constructor injection to factory pattern to avoid PRISMA_CLIENT binding issues in tests
- FeaturesRepository now uses default prisma instance instead of DI injection
- Resolves test failures while maintaining DI container compatibility
- Tests reduced from 123+ failures to only 5 unrelated failures

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

* revert: use direct FeaturesRepository instantiation in most usage points

- Revert getFeaturesRepository() calls back to new FeaturesRepository()
- Tests require direct instantiation for mocking compatibility
- Keep DI container for specific use cases that need dependency injection
- Resolves test failures while maintaining both DI and direct usage patterns

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

* fix: resolve FeaturesRepository DI container issues

- Update cache module to use factory pattern with proper ICacheService interface
- Remove featuresModule loading from cache and available-slots containers
- Use direct FeaturesRepository instantiation via getFeaturesRepository()
- Resolves 'No binding found for key: Symbol(FeaturesRepository)' errors
- Reduces test failures from 125 to 7 (remaining failures appear unrelated)

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

* fix: update all FeaturesRepository instantiations to include prisma parameter

- Add prisma parameter to all new FeaturesRepository() calls across the codebase
- Update API v2 services to match main repo interfaces
- Fix PrismaFeaturesRepository to implement IFeaturesRepository directly
- Update CacheService in API v2 to expose required dependencies and getShouldServeCache
- Implement CheckBookingLimitsService in API v2 with proper interface
- Resolves type assignment errors between API v2 and main repo implementations

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

* fix: add prisma parameter to remaining FeaturesRepository instantiations in apps/web/lib

- Update getServerSideProps files to pass prisma parameter to FeaturesRepository
- Ensures all FeaturesRepository instantiations follow the new constructor pattern
- Completes the refactoring to use direct instantiation with prisma parameter

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

* refactor clean and fix devin issues

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* chore: bump platform libs

* fix: missing di

* fix workflow test

* fix workflow test

* fix integration test

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-31 20:39:27 +03:00
Syed Ali Shahbaz ad4af64036 fix: return rescheduledByEmail in V2 bookings (#22804)
* Update output.service.ts

* Update output.service.ts

* Update output.service.ts

* Update user-bookings.e2e-spec.ts

* fix type err

* Update failing test
2025-07-31 16:27:08 +01:00
+5 2cc7827b0e test: add comprehensive unit tests for handleInstantMeeting (#22820)
* fix: resolve username constraint violation in removeMember

- Update username to - format when removing users from organizations
- Fix unique constraint violation on (username, organizationId) when organizationId is null
- Add test case to verify username format change and successful removal
- Fix skipped test by adding proper imports and removing describe.skip

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

* test: update removeMember test to verify constraint violation scenario

- Create two users with same username (one with null orgId, one with orgId)
- Verify removing org user updates username without unique constraint error
- Test ensures username gets updated to - format

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

* test: add comprehensive unit tests for handleInstantMeeting

- Revert previous removeMember changes
- Add full test coverage for instant meeting functionality
- Test team validation, booking creation, token generation
- Test webhook triggers and browser notifications
- Mock external dependencies for isolated unit tests
- Translation issue to be addressed in future iteration

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

* update

* Update handleInstantMeeting.test.ts

* fix: redir parameter for connect atoms (#22815)

* encode redirect url to make sure it has all parameters

* add changesets

* feat: Support an array response for a field when used as `Value of Field` (#22740)

* Passing tests and fixed

* self review addressed adn more tests

* fix: flaky e2e (#22819)

* fix: merge working hours when adjacent (#21912)

* fix: Adjacency issue when working hours connect over multiple days

* Add tests to validate the new merging of day end logic

* Update to correct annotation.

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Implement subsequent date ranges for date overrides also

* The map needs to be updated on successful resolve.

* test: add failing test for overlapping ranges with same end time

Demonstrates bug where overlapping working hour ranges (6:00-10:00 and 8:00-10:00)
lose the earlier portion (6:00-8:00), showing only 8:00 and 9:00 slots
instead of all 4 slots (6:00, 7:00, 8:00, 9:00).

Related to Carina's comment on PR #21912.

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

* fix: properly merge overlapping ranges with same end time

Fixes bug where overlapping working hour ranges with the same end time
(e.g., 6:00-10:00 and 8:00-10:00) would lose the earlier portion of the
first range. The merging logic now correctly preserves the earliest
start time when ranges overlap and share the same end time.

This ensures all expected time slots are available (6:00, 7:00, 8:00, 9:00)
instead of losing the earlier slots (6:00, 7:00).

Resolves the issue identified in Carina's comment on PR #21912.

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

* perf: optimize overlapping range detection from O(n²) to O(n)

Replaces Object.keys().find() with Map-based lookup for ranges with same end time.
This optimization handles 2000+ date ranges efficiently, reducing complexity from
4M operations to linear time while maintaining the same merging behavior.

Performance improvement for high-volume event types with many availability ranges.

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

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: Improving Booking Visibility at Month-End (#22770)

* remove first weeks and add last

* fix added last week

* prefetch availability of next month

* don't switch month

* On hover show month

* only show new UI in monthly view

* show month tooldtip only when needed

* show month on first day of month

* remove isFirstDayOfNextMonth

* fix prefetching next month

* fix datePicker tests

* preventMonthSwitching in monthly view

* add tests

* code clean up

* code clean up

* code clena up for ooo days

* push first day of month

* remove bg color for the month badge

* fix text colour

* remove not needed

* use object param

* revert: use object param

* use object param

* fix DatePicker tests

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>

* chore: Refactor logs (#22824)

* Refactor logs

* Add specific info to log

* fix: Errors in org onboarding in some edge cases (#22711)

* Automatically enable migration of a team that conflicts with Orgs slug

* Allow changing the orgs slug and name if user goes back and changes it

* avoid crashing on some scenarios

* Revert "Allow changing the orgs slug and name if user goes back and changes it"

This reverts commit f8872b0116868d59757ef911c4940df79f93c6e0.

* fix: handle unpublished teams gracefully in org migration

- Change 'No oldSlug for team' from error to warning for unpublished teams
- Keep 'No slug for team' as error since org onboarding ensures teams have names
- Add test for migrating unpublished teams without oldSlug
- Add clarifying comments about when each condition can occur

* feat: enable PBAC checking on organization settings page (#22467)

* refactor: convert checkBookingLimits to class service with dependency injection (#22768)

* refactor: convert checkBookingLimits to class service with dependency injection

- Create CheckBookingLimitsService class following AvailableSlotsService pattern
- Add countBookingsByEventTypeAndDateRange method to BookingRepository
- Move direct prisma calls from service to repository layer
- Implement dependency injection with proper DI tokens and modules
- Update all usage points to use the new service through DI
- Maintain backward compatibility with error-throwing wrapper functions
- Update tests to use the new service pattern
- Resolve TODO comment in AvailableSlotsService for DI integration

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

* chore: DI CheckBookingLimitsService in v2 slots service

* chore: bump libraries

* chore: create getCheckBookingLimitsService

* refactor: convert checkBookingAndDurationLimits to service class with DI

- Create CheckBookingAndDurationLimitsService class following DI pattern
- Add DI tokens and module for the new service
- Update booking-limits container to provide the new service
- Refactor handleNewBooking.ts to use service through DI
- Maintain backward compatibility with deprecated function export
- Preserve all existing functionality while improving code organization

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

* chore: CheckBookingAndDurationLimitsService

* chore: bump platform libs

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>

* fix: Return empty available days if error querying calendar (#22828)

* Return a busy block placeholder if calendar throws an error

* Refactor `getCalendarsEvents` to return an object with a success prop

* Throw error in `getBusyTimes` if failed to fetch calendar availability

* Return empty available days if error getting busy times

* yeet.

* Type fix

* Fix type error in getLuckyUsers

* Type fixes

* Type fix

* Type fix

* Fix test

* Fix test mocks

* Refactor calendars.service to use new calendarBusyTimesQuery

---------

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

* chore: release v5.5.9

* include mobile layout (#22836)

Co-authored-by: CarinaWolli <wollencarina@gmail.com>

* test: fix handleInstantMeeting test with setupAndTeardown and proper mocking

- Add setupAndTeardown() for proper test environment setup
- Use mockNoTranslations() to fix translation function mocking
- Simplify NextApiRequest mock to resolve TypeScript errors
- Both test cases now pass successfully

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

* fix typo

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Rajiv Sahal <sahalrajiv-extc@atharvacoe.ac.in>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com>
Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-07-31 10:46:53 +00:00
ef6618743d feat: add optional startTime prop to BookerPlatformWrapper (#22731)
* feat: add optional startTime prop to BookerPlatformWrapper

- Add startTime prop to BookerPlatformWrapperAtomProps interface
- Allow users to override calculated startTime from useTimesForSchedule
- Maintain backward compatibility when prop is not provided
- Add JSDoc documentation for proper usage

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

* feat: add custom start time support to Booker component with date object input

* feat: update documentation and added changelog

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: somay@cal.com <somay@cal.com>
Co-authored-by: Somay Chauhan <somaychauhan98@gmail.com>
2025-07-31 10:25:11 +00:00
Carina WollendorferandCarinaWolli 3601a9adf1 include mobile layout (#22836)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2025-07-31 09:27:56 +00:00
181 changed files with 3189 additions and 1589 deletions
-6
View File
@@ -1,6 +0,0 @@
---
"@calcom/atoms": patch
---
This change fixes an issue in the CalendarSettings atom where the redirect urls were getting only one search param
-5
View File
@@ -1,5 +0,0 @@
---
"@calcom/atoms": patch
---
This change fixes date overrides breaking for availability settings atom
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+2
View File
@@ -0,0 +1,2 @@
undecided:
- "@calcom/prisma"
+1 -1
View File
@@ -38,7 +38,7 @@
"next-axiom": "^0.17.0",
"next-swagger-doc": "^0.3.6",
"next-validations": "^0.2.0",
"typescript": "^5.7.2",
"typescript": "^5.9.0-beta",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.22.4"
-18
View File
@@ -1,18 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1"
},
"testEnvironment": "node",
"testRegex": ".e2e-spec.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
"setupFilesAfterEnv": ["<rootDir>/test/jest.setup-e2e.ts"],
"reporters": ["default", "jest-summarizing-reporter"],
"workerIdleMemoryLimit": "512MB",
"maxWorkers": 8
}
+25
View File
@@ -0,0 +1,25 @@
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
moduleFileExtensions: ["js", "json", "ts"],
rootDir: ".",
moduleNameMapper: {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1",
},
testEnvironment: "node",
testRegex: ".e2e-spec.ts$",
transform: {
"^.+\\.ts$": "ts-jest",
},
setupFiles: ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
setupFilesAfterEnv: ["<rootDir>/test/jest.setup-e2e.ts"],
reporters: ["default", "jest-summarizing-reporter"],
workerIdleMemoryLimit: "512MB",
maxWorkers: 8,
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
transformIgnorePatterns: ["/dist/", "/node_modules/"],
};
export default config;
-14
View File
@@ -1,14 +0,0 @@
{
"moduleFileExtensions": ["js", "json", "ts"],
"rootDir": ".",
"moduleNameMapper": {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1"
},
"testEnvironment": "node",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
},
"setupFiles": ["<rootDir>/test/setEnvVars.ts"]
}
+21
View File
@@ -0,0 +1,21 @@
import type { Config } from "jest";
const config: Config = {
preset: "ts-jest",
moduleFileExtensions: ["ts", "js", "json"],
rootDir: ".",
moduleNameMapper: {
"@/(.*)": "<rootDir>/src/$1",
"test/(.*)": "<rootDir>/test/$1",
},
testEnvironment: "node",
testRegex: ".*\\.spec\\.ts$",
transform: {
"^.+\\.ts$": "ts-jest",
},
setupFiles: ["<rootDir>/test/setEnvVars.ts"],
testPathIgnorePatterns: ["/dist/", "/node_modules/"],
transformIgnorePatterns: ["/dist/", "/node_modules/"],
};
export default config;
+4 -4
View File
@@ -25,9 +25,9 @@
"test:watch": "yarn dev:build && jest --watch",
"test:cov": "yarn dev:build && jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.json",
"test:e2e": "yarn dev:build && NODE_OPTIONS='--max_old_space_size=8192 --experimental-vm-modules' jest --ci --forceExit --config ./jest-e2e.ts",
"test:e2e:local": "yarn test:e2e --maxWorkers=4",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.json --watch",
"test:e2e:watch": "yarn dev:build && jest --runInBand --detectOpenHandles --forceExit --config ./jest-e2e.ts --watch",
"prisma": "yarn workspace @calcom/prisma prisma",
"generate-schemas": "yarn prisma generate && yarn prisma format",
"copy-swagger-module": "ts-node -r tsconfig-paths/register swagger/copy-swagger-module.ts",
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.271",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.276",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
@@ -108,7 +108,7 @@
"ts-loader": "^9.4.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.0",
"typescript": "^5.7.2"
"typescript": "^5.9.0-beta"
},
"prisma": {
"schema": "../../../packages/prisma/schema.prisma"
@@ -1834,6 +1834,88 @@ describe("Bookings Endpoints 2024-08-13", () => {
}
});
});
it("should return who rescheduled the booking, in the new booking", async () => {
const rescheduledByEmail = `user-bookings-rescheduler-${randomString()}@rescheduler.com`;
// Create the original booking that will be rescheduled
const originalBooking = await bookingsRepositoryFixture.create({
uid: `original-booking-uid-${eventTypeId}`,
title: "original booking title",
startTime: "2050-09-05T10:00:00.000Z",
endTime: "2050-09-05T11:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "CANCELLED",
rescheduledBy: rescheduledByEmail,
metadata: {},
responses: {
name: "original tester",
email: "original@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
// Create the new booking that is the result of the reschedule
const newBooking = await bookingsRepositoryFixture.create({
uid: `new-booking-uid-${eventTypeId}`,
title: "rescheduled booking title",
startTime: "2050-09-05T14:00:00.000Z",
endTime: "2050-09-05T15:00:00.000Z",
eventType: {
connect: {
id: eventTypeId,
},
},
status: "ACCEPTED",
fromReschedule: originalBooking.uid,
metadata: {},
responses: {
name: "new tester",
email: "newtester@example.com",
guests: [],
},
user: {
connect: {
id: user.id,
},
},
});
return request(app.getHttpServer())
.get(`/v2/bookings/${newBooking.uid}`)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(200)
.then(async (response) => {
// Fetch the original booking to get its rescheduledBy value
const originalBookingFromDb = await bookingsRepositoryFixture.getByUid(originalBooking.uid);
const expectedRescheduledBy = originalBookingFromDb?.rescheduledBy;
await bookingsRepositoryFixture.deleteById(originalBooking.id);
await bookingsRepositoryFixture.deleteById(newBooking.id);
const responseBody: GetBookingOutput_2024_08_13 = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(responseDataIsBooking(responseBody.data)).toBe(true);
if (responseDataIsBooking(responseBody.data)) {
const data: BookingOutput_2024_08_13 = responseBody.data;
expect(data.uid).toEqual(newBooking.uid);
expect(rescheduledByEmail).toEqual(expectedRescheduledBy);
} else {
throw new Error(
"Invalid response data - expected booking but received array of possibly recurring bookings"
);
}
});
});
});
describe("book by username and event type slug", () => {
@@ -101,9 +101,13 @@ export class OutputBookingsService_2024_08_13 {
);
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const rescheduledToUid = databaseBooking.rescheduled
? await this.getRescheduledToUid(databaseBooking.uid)
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -116,7 +120,6 @@ export class OutputBookingsService_2024_08_13 {
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -142,6 +145,7 @@ export class OutputBookingsService_2024_08_13 {
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
rescheduledToUid,
rescheduledByEmail,
};
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -151,11 +155,15 @@ export class OutputBookingsService_2024_08_13 {
return bookingTransformed;
}
async getRescheduledToUid(bookingUid: string) {
async getRescheduledToInfo(bookingUid: string): Promise<{ uid?: string; rescheduledBy?: string | null }> {
const rescheduledTo = await this.bookingsRepository.getByFromReschedule(bookingUid);
return rescheduledTo?.uid;
return {
uid: rescheduledTo?.uid,
rescheduledBy: rescheduledTo?.rescheduledBy
};
}
getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) {
if (databaseMetadata === null) return {};
@@ -218,7 +226,6 @@ export class OutputBookingsService_2024_08_13 {
databaseBooking.status === "CANCELLED" ? databaseBooking.cancellationReason : undefined,
cancelledByEmail: databaseBooking.status === "CANCELLED" ? databaseBooking.cancelledBy : undefined,
reschedulingReason: bookingResponses?.rescheduledReason,
rescheduledByEmail: databaseBooking.rescheduledBy || undefined,
rescheduledFromUid: databaseBooking.fromReschedule || undefined,
start: databaseBooking.startTime,
end: databaseBooking.endTime,
@@ -269,9 +276,12 @@ export class OutputBookingsService_2024_08_13 {
const duration = dateEnd.diff(dateStart, "minutes").minutes;
const metadata = safeParse(bookingMetadataSchema, databaseBooking.metadata, defaultBookingMetadata);
const location = metadata?.videoCallUrl || databaseBooking.location;
const rescheduledToUid = databaseBooking.rescheduled
? await this.getRescheduledToUid(databaseBooking.uid)
const rescheduledToInfo = databaseBooking.rescheduled
? await this.getRescheduledToInfo(databaseBooking.uid)
: undefined;
const rescheduledToUid = rescheduledToInfo?.uid;
const rescheduledByEmail = databaseBooking.rescheduled ? rescheduledToInfo?.rescheduledBy : databaseBooking.rescheduledBy;
const booking = {
id: databaseBooking.id,
@@ -299,6 +309,7 @@ export class OutputBookingsService_2024_08_13 {
rating: databaseBooking.rating,
icsUid: databaseBooking.iCalUID,
rescheduledToUid,
rescheduledByEmail,
};
const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
@@ -1,5 +1,6 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
@@ -7,6 +8,7 @@ import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { AvailableSlotsService } from "@/lib/services/available-slots.service";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { Module } from "@nestjs/common";
@@ -22,7 +24,9 @@ import { Module } from "@nestjs/common";
PrismaEventTypeRepository,
PrismaRoutingFormResponseRepository,
PrismaTeamRepository,
PrismaFeaturesRepository,
CheckBookingLimitsService,
CacheService,
AvailableSlotsService,
],
exports: [AvailableSlotsService],
@@ -0,0 +1,12 @@
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { Injectable } from "@nestjs/common";
import { PrismaFeaturesRepository as PrismaFeaturesRepositoryLib } from "@calcom/platform-libraries/repositories";
import { PrismaClient } from "@calcom/prisma";
@Injectable()
export class PrismaFeaturesRepository extends PrismaFeaturesRepositoryLib {
constructor(private readonly dbWrite: PrismaWriteService) {
super(dbWrite.prisma as unknown as PrismaClient);
}
}
@@ -1,11 +1,13 @@
import { PrismaBookingRepository } from "@/lib/repositories/prisma-booking.repository";
import { PrismaEventTypeRepository } from "@/lib/repositories/prisma-event-type.repository";
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { PrismaOOORepository } from "@/lib/repositories/prisma-ooo.repository";
import { PrismaRoutingFormResponseRepository } from "@/lib/repositories/prisma-routing-form-response.repository";
import { PrismaScheduleRepository } from "@/lib/repositories/prisma-schedule.repository";
import { PrismaSelectedSlotRepository } from "@/lib/repositories/prisma-selected-slot.repository";
import { PrismaTeamRepository } from "@/lib/repositories/prisma-team.repository";
import { PrismaUserRepository } from "@/lib/repositories/prisma-user.repository";
import { CacheService } from "@/lib/services/cache.service";
import { CheckBookingLimitsService } from "@/lib/services/check-booking-limits.service";
import { Injectable } from "@nestjs/common";
@@ -21,7 +23,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
bookingRepository: PrismaBookingRepository,
selectedSlotRepository: PrismaSelectedSlotRepository,
eventTypeRepository: PrismaEventTypeRepository,
userRepository: PrismaUserRepository
userRepository: PrismaUserRepository,
featuresRepository: PrismaFeaturesRepository
) {
super({
oooRepo: oooRepoDependency,
@@ -32,7 +35,8 @@ export class AvailableSlotsService extends BaseAvailableSlotsService {
selectedSlotRepo: selectedSlotRepository,
eventTypeRepo: eventTypeRepository,
userRepo: userRepository,
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository),
checkBookingLimitsService: new CheckBookingLimitsService(bookingRepository) as any,
cacheService: new CacheService(featuresRepository),
});
}
}
@@ -0,0 +1,11 @@
import { PrismaFeaturesRepository } from "@/lib/repositories/prisma-features.repository";
import { Injectable } from "@nestjs/common";
import { CacheService as BaseCacheService } from "@calcom/platform-libraries";
@Injectable()
export class CacheService extends BaseCacheService {
constructor(featuresRepository: PrismaFeaturesRepository) {
super({ featuresRepository });
}
}
+1 -1
View File
@@ -34,6 +34,6 @@
"eslint-config-next": "15.1.6",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"typescript": "^5.9.0-beta"
}
}
@@ -7,6 +7,7 @@ import { cookies, headers } from "next/headers";
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { loadTranslations } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import { buildLegacyCtx, decodeParams } from "@lib/buildLegacyCtx";
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
@@ -17,7 +18,7 @@ import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"team-booking-page-cache"
);
@@ -102,7 +102,7 @@ export async function getEnrichedEventType({
}
export async function shouldUseApiV2ForTeamSlots(teamId: number): Promise<boolean> {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(teamId, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && Boolean(process.env.NEXT_PUBLIC_API_V2_URL);
@@ -4,11 +4,12 @@ import { notFound } from "next/navigation";
import { CTA_CONTAINER_CLASS_NAME } from "@calcom/features/data-table/lib/utils";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import Shell from "@calcom/features/shell/Shell";
import { prisma } from "@calcom/prisma";
import UpgradeTipWrapper from "./UpgradeTipWrapper";
export default async function InsightsLayout({ children }: { children: React.ReactNode }) {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const insightsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("insights");
if (!insightsEnabled) {
@@ -1,7 +1,7 @@
"use server";
import { revalidatePath } from "next/cache";
import { revalidateTag } from "next/cache";
export async function revalidateWebhooksList() {
revalidatePath("/settings/developer/webhooks");
export async function revalidateWebhooksListGetByViewer() {
revalidateTag("viewer.webhook.getByViewer");
}
@@ -1,13 +1,13 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import WebhooksView from "@calcom/features/webhooks/pages/webhooks-view";
import { APP_NAME } from "@calcom/lib/constants";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -20,6 +20,17 @@ export const generateMetadata = async () =>
"/settings/developer/webhooks"
);
const getCachedWebhooksList = unstable_cache(
async ({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) => {
return await WebhookRepository.getAllWebhooksByUserId({
userId,
userRole,
});
},
undefined,
{ revalidate: 3600, tags: ["viewer.webhook.getByViewer"] }
);
const WebhooksViewServerWrapper = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
@@ -27,8 +38,9 @@ const WebhooksViewServerWrapper = async () => {
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
const caller = await createRouterCaller(webhookRouter);
const data = await caller.getByViewer();
const userRole = session.user.role !== "INACTIVE_ADMIN" ? session.user.role : undefined;
const data = await getCachedWebhooksList({ userId: session.user.id, userRole });
return <WebhooksView data={data} isAdmin={isAdmin} />;
};
@@ -0,0 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookById(id: string) {
revalidateTag(`viewer.webhook.get:${id}`);
}
@@ -1,5 +1,6 @@
import type { PageProps } from "app/_types";
import { getTranslate, _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { EditWebhookView } from "@calcom/features/webhooks/pages/webhook-edit-view";
@@ -15,12 +16,24 @@ export const generateMetadata = async ({ params }: { params: Promise<{ id: strin
`/settings/developer/webhooks/${(await params).id}`
);
const getCachedWebhook = (id?: string) => {
const fn = unstable_cache(
async () => {
return await WebhookRepository.findByWebhookId(id);
},
undefined,
{ revalidate: 3600, tags: [`viewer.webhook.get:${id}`] }
);
return fn();
};
const Page = async ({ params: _params }: PageProps) => {
const t = await getTranslate();
const params = await _params;
const id = typeof params?.id === "string" ? params.id : undefined;
const webhook = await WebhookRepository.findByWebhookId(id);
const webhook = await getCachedWebhook(id);
return (
<SettingsHeader
@@ -0,0 +1,7 @@
"use server";
import { revalidateTag } from "next/cache";
export async function revalidateWebhookList() {
revalidateTag("viewer.webhook.list");
}
@@ -1,10 +1,16 @@
import { createRouterCaller } from "app/_trpc/context";
import { _generateMetadata } from "app/_utils";
import { unstable_cache } from "next/cache";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { NewWebhookView } from "@calcom/features/webhooks/pages/webhook-new-view";
import { APP_NAME } from "@calcom/lib/constants";
import { WebhookRepository } from "@calcom/lib/server/repository/webhook";
import { appsRouter } from "@calcom/trpc/server/routers/viewer/apps/_router";
import { webhookRouter } from "@calcom/trpc/server/routers/viewer/webhook/_router";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -15,15 +21,25 @@ export const generateMetadata = async () =>
"/settings/developer/webhooks/new"
);
const getCachedWebhooksList = unstable_cache(
async ({ userId }: { userId: number }) => {
return await WebhookRepository.findWebhooksByFilters({ userId });
},
undefined,
{ revalidate: 3600, tags: ["viewer.webhook.list"] }
);
const Page = async () => {
const [appsCaller, webhookCaller] = await Promise.all([
createRouterCaller(appsRouter),
createRouterCaller(webhookRouter),
]);
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
redirect("/auth/login");
}
const appsCaller = await createRouterCaller(appsRouter);
const [installedApps, webhooks] = await Promise.all([
appsCaller.integrations({ variant: "other", onlyInstalled: true }),
webhookCaller.list(),
getCachedWebhooksList({ userId: session.user.id }),
]);
return <NewWebhookView webhooks={webhooks} installedApps={installedApps} />;
@@ -9,6 +9,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { PermissionMapper } from "@calcom/features/pbac/domain/mappers/PermissionMapper";
import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permission-registry";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -17,7 +18,7 @@ import SettingsLayoutAppDirClient from "./SettingsLayoutAppDirClient";
const getTeamFeatures = unstable_cache(
async (teamId: number) => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
return await featuresRepository.getTeamFeatures(teamId);
},
["team-features"],
@@ -11,6 +11,7 @@ import { Resource, CrudAction } from "@calcom/features/pbac/domain/types/permiss
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -29,7 +30,7 @@ const getCachedTeamRoles = unstable_cache(
const getCachedTeamFeature = unstable_cache(
async (teamId: number, feature: keyof AppFlags) => {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
return res;
},
@@ -12,6 +12,7 @@ import { PermissionCheckService } from "@calcom/features/pbac/services/permissio
import { RoleService } from "@calcom/features/pbac/services/role.service";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
import { prisma } from "@calcom/prisma";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
@@ -32,7 +33,7 @@ const getCachedTeamRoles = (teamId: number) =>
const getCachedTeamFeature = (teamId: number, feature: keyof AppFlags) =>
unstable_cache(
async () => {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const res = await featureRepo.checkIfTeamHasFeature(teamId, feature);
return res;
},
+2 -1
View File
@@ -10,6 +10,7 @@ import getIP from "@calcom/lib/getIP";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { checkCfTurnstileToken } from "@calcom/lib/server/checkCfTurnstileToken";
import { prisma } from "@calcom/prisma";
import { signupSchema } from "@calcom/prisma/zod-utils";
async function ensureSignupIsEnabled(body: Record<string, string>) {
@@ -22,7 +23,7 @@ async function ensureSignupIsEnabled(body: Record<string, string>) {
// Still allow signups if there is a team invite
if (token) return;
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const signupDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("disable-signup");
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === "true" || signupDisabled) {
+1 -1
View File
@@ -208,7 +208,7 @@ async function getHandler(request: NextRequest) {
}
// Create a new response with the image buffer
const imageResponse = new NextResponse(buffer);
const imageResponse = new NextResponse(buffer as BodyInit);
// Set the appropriate headers
imageResponse.headers.set("Content-Type", response.headers.get("content-type") || "image/png");
@@ -124,8 +124,9 @@ const UsernameTextfield = (props: ICustomUsernameProps & Partial<React.Component
autoComplete="none"
autoCapitalize="none"
autoCorrect="none"
containerClassName="[&>div]:gap-0"
className={classNames(
"mb-0 mt-0 rounded-md rounded-l-none",
"mb-0 mt-0 rounded-md rounded-l-none pl-0",
markAsError
? "focus:shadow-0 focus:ring-shadow-0 border-red-500 focus:border-red-500 focus:outline-none focus:ring-0"
: ""
@@ -120,7 +120,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
// Check if team has API v2 feature flag enabled (same logic as team pages)
let useApiV2 = false;
if (isTeamEvent && hashedLink.eventType.team?.id) {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(
hashedLink.eventType.team.id,
"use-api-v2-for-team-slots"
@@ -1,9 +1,10 @@
import type { GetServerSidePropsResult } from "next";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
export const getServerSideProps = async (): Promise<GetServerSidePropsResult<{ isOrg: boolean }>> => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const organizations = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizations) {
+1 -1
View File
@@ -24,7 +24,7 @@ const querySchema = z.object({
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const emailVerificationEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
"email-verification"
);
@@ -127,7 +127,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
const organizationSettings = getOrganizationSEOSettings(team);
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
@@ -71,7 +71,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
// Provided by Rewrite from next.config.js
const isOrgProfile = context.query?.isOrgProfile === "1";
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
log.debug("getServerSideProps", {
@@ -117,7 +117,7 @@ const checkIfUserIsHost = async ({
});
// If eventType exists, it means user is either owner, host or user
return !eventType;
return !!eventType;
};
export async function getServerSideProps(context: GetServerSidePropsContext) {
@@ -247,7 +247,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const videoReference = getCalVideoReference(bookingObj.references);
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const displayLogInOverlay = profile?.organizationId
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
: false;
@@ -1229,8 +1229,21 @@ function RecurringBookings({
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
<br />
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
{formatToLocalizedTime(dayjs(dateStr).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
{formatToLocalizedTime({
date: dayjs(dateStr),
locale: language,
timeStyle: undefined,
hour12: !is24h,
timeZone: tz,
})}{" "}
-{" "}
{formatToLocalizedTime({
date: dayjs(dateStr).add(duration, "m"),
locale: language,
timeStyle: undefined,
hour12: !is24h,
timeZone: tz,
})}{" "}
<span className="text-bookinglight">
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
</span>
@@ -1249,14 +1262,19 @@ function RecurringBookings({
<div key={idx} className={classNames("mb-2", isCancelled ? "line-through" : "")}>
{formatToLocalizedDate(dayjs.tz(dateStr, tz), language, "full", tz)}
<br />
{formatToLocalizedTime(dayjs(dateStr), language, undefined, !is24h, tz)} -{" "}
{formatToLocalizedTime(
dayjs(dateStr).add(duration, "m"),
language,
undefined,
!is24h,
tz
)}{" "}
{formatToLocalizedTime({
date: dayjs(dateStr),
locale: language,
hour12: !is24h,
timeZone: tz,
})}{" "}
-{" "}
{formatToLocalizedTime({
date: dayjs(dateStr).add(duration, "m"),
locale: language,
hour12: !is24h,
timeZone: tz,
})}{" "}
<span className="text-bookinglight">
({formatToLocalizedTimezone(dayjs(dateStr), language, tz)})
</span>
@@ -1273,8 +1291,13 @@ function RecurringBookings({
<div className={classNames(isCancelled ? "line-through" : "")}>
{formatToLocalizedDate(date, language, "full", tz)}
<br />
{formatToLocalizedTime(date, language, undefined, !is24h, tz)} -{" "}
{formatToLocalizedTime(dayjs(date).add(duration, "m"), language, undefined, !is24h, tz)}{" "}
{formatToLocalizedTime({ date, locale: language, hour12: !is24h, timeZone: tz })} -{" "}
{formatToLocalizedTime({
date: dayjs(date).add(duration, "m"),
locale: language,
hour12: !is24h,
timeZone: tz,
})}{" "}
<span className="text-bookinglight">({formatToLocalizedTimezone(date, language, tz)})</span>
</div>
);
@@ -22,7 +22,6 @@ import {
RecentFeedbackTable,
TimezoneBadge,
} from "@calcom/features/insights/components/booking";
import "@calcom/features/insights/components/tremor.css";
import { InsightsOrgTeamsProvider } from "@calcom/features/insights/context/InsightsOrgTeamsProvider";
import { Download } from "@calcom/features/insights/filters/Download";
import { OrgTeamsFilter } from "@calcom/features/insights/filters/OrgTeamsFilter";
@@ -1,6 +1,5 @@
"use client";
import { ProgressBar } from "@tremor/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useMemo } from "react";
@@ -16,6 +15,7 @@ import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Select } from "@calcom/ui/components/form";
import { TextField, Label, InputError } from "@calcom/ui/components/form";
import { ProgressBar } from "@calcom/ui/components/progress-bar";
import { showToast } from "@calcom/ui/toast";
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
@@ -288,6 +288,7 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
const endTime = new Date(booking.endTime);
const startTime = new Date(booking.startTime);
const timeZone = booking.user?.timeZone;
useDailyEvent("left-meeting", () => {
if (rediectAttendeeToOnExit) {
@@ -306,11 +307,11 @@ export function VideoMeetingInfo(props: VideoMeetingInfo) {
<h3>{t("what")}:</h3>
<p>{booking.title}</p>
<h3>{t("invitee_timezone")}:</h3>
<p>{booking.user?.timeZone}</p>
<p>{timeZone}</p>
<h3>{t("when")}:</h3>
<p suppressHydrationWarning={true}>
{formatToLocalizedDate(startTime)} <br />
{formatToLocalizedTime(startTime)}
{formatToLocalizedTime({ date: startTime, timeZone })}
</p>
<h3>{t("time_left")}</h3>
<ProgressBar
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.5.9",
"version": "5.5.11",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -76,7 +76,6 @@
"@stripe/stripe-js": "^1.35.0",
"@tanstack/react-query": "^5.17.15",
"@team-plain/typescript-sdk": "^5.9.0",
"@tremor/react": "^2.11.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.21.0",
@@ -195,7 +194,7 @@
"tailwindcss": "^3.3.3",
"tailwindcss-animate": "^1.0.6",
"ts-node": "^10.9.1",
"typescript": "^5.7.2"
"typescript": "^5.9.0-beta"
},
"nextBundleAnalysis": {
"budget": 358400,
+7 -15
View File
@@ -203,32 +203,24 @@ test.describe("Insights", async () => {
await page.keyboard.press("Escape");
// Choose User filter item from dropdown
await addFilter(page, "bookingUserId");
await addFilter(page, "userId");
// Wait for the URL to include bookingUserId
await page.waitForURL((url) => url.toString().includes("bookingUserId"));
// Wait for the URL to include userId
await page.waitForURL((url) => url.toString().includes("userId"));
// Click User filter to see a user list
await openFilter(page, "bookingUserId");
await openFilter(page, "userId");
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(0)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
await page
.locator('[data-testid="select-filter-options-bookingUserId"]')
.getByRole("option")
.nth(1)
.click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
// press escape button to close the filter
await page.keyboard.press("Escape");
await clearFilters(page);
await expect(page.url()).not.toContain("bookingUserId");
await expect(page.url()).not.toContain("userId");
});
test("should test download button", async ({ page, users }) => {
@@ -3402,6 +3402,7 @@
"license_key_saved": "License key saved successfully",
"timezone_mismatch_tooltip": "You are viewing the report based on your profile timezone ({{userTimezone}}), while your browser is set to timezone ({{browserTimezone}})",
"failed_bookings_by_field": "Failed Bookings By Field",
"failed_bookings": "Failed bookings",
"event_type_no_hosts": "No hosts are assigned to event type",
"cache_status": "Cache Status",
"cache_last_updated": "Last updated: {{timestamp}}",
@@ -3429,5 +3430,7 @@
"usage_based_expiration_description": "This link can be used for {{count}} booking",
"usage_based_generic_expiration_description": "This link can be configured to expire after a set number of bookings",
"usage_based_expiration_description_plural": "This link can be used for {{count}} bookings",
"stats": "Stats",
"booking_status": "Booking status",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+1 -5
View File
@@ -2,10 +2,6 @@ const base = require("@calcom/config/tailwind-preset");
/** @type {import('tailwindcss').Config} */
module.exports = {
...base,
content: [
...base.content,
"../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}",
"../../node_modules/@tremor/**/*.{js,ts,jsx,tsx}",
],
content: [...base.content, "../../packages/app-store/routing-forms/**/*.{js,ts,jsx,tsx}"],
plugins: [...base.plugins, require("tailwindcss-animate")],
};
@@ -26,7 +26,12 @@ vi.mock("@calcom/emails/email-manager", () => {
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: (key: string) => key,
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
@@ -0,0 +1,180 @@
---
title: "How to Add a New Booking Chart to Cal.com Insights Page"
---
This guide walks you through creating a new booking chart component for the insights page, covering the entire stack from UI component to backend service.
## Overview
The insights booking system follows this architecture:
```
UI Component → tRPC Handler → Insights Service → Database Query → Response
```
<Steps>
<Step title="Create the UI Component">
Create your chart component in `packages/features/insights/components/booking/`:
```typescript
// packages/features/insights/components/booking/MyNewChart.tsx
import { LineChart, XAxis, YAxis, CartesianGrid, Tooltip, Line, ResponsiveContainer } from "recharts";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
export const MyNewChart = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
const { data, isSuccess, isPending } = trpc.viewer.insights.myNewChartData.useQuery(insightsBookingParams, {
staleTime: 180000, // 3 minutes
refetchOnWindowFocus: false,
trpc: { context: { skipBatch: true } },
});
if (isPending) return <LoadingInsight />;
return (
<ChartCard title={t("my_new_chart_title")}>
{isSuccess && data?.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="value" stroke="#8884d8" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-500">{t("no_data_yet")}</p>
</div>
)}
</ChartCard>
);
};
```
</Step>
<Step title="Add Component to Barrel Export">
Update the booking components index file:
```typescript
// packages/features/insights/components/booking/index.ts
export { AverageEventDurationChart } from "./AverageEventDurationChart";
export { BookingKPICards } from "./BookingKPICards";
// ... existing exports
export { MyNewChart } from "./MyNewChart"; // Add this line
```
</Step>
<Step title="Add Component to Insights View">
Add your component to the main insights page:
```typescript
// apps/web/modules/insights/insights-view.tsx
import {
AverageEventDurationChart,
BookingKPICards, // ... existing imports
MyNewChart, // Add this import
} from "@calcom/features/insights/components/booking";
export default function InsightsPage() {
// ... existing code
return (
<div className="space-y-6">
{/* Existing components */}
<BookingKPICards />
<EventTrendsChart />
{/* Add your new chart */}
<MyNewChart />
{/* Other existing components */}
</div>
);
}
```
</Step>
<Step title="Create tRPC Handler">
Add the tRPC endpoint in the insights router using the `getInsightsBookingService()` DI container function:
```typescript
// packages/features/insights/server/trpc-router.ts
import { bookingRepositoryBaseInputSchema } from "@calcom/features/insights/server/raw-data.schema";
import { userBelongsToTeamProcedure } from "@calcom/trpc/server/procedures/authedProcedure";
import { TRPCError } from "@trpc/server";
export const insightsRouter = router({
// ... existing procedures
myNewChartData: userBelongsToTeamProcedure
.input(bookingRepositoryBaseInputSchema)
.query(async ({ ctx, input }) => {
// `createInsightsBookingService` is defined at the root level in this file
const insightsBookingService = createInsightsBookingService(ctx, input);
try {
return await insightsBookingService.getMyNewChartData();
} catch (e) {
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
}
}),
});
```
</Step>
<Step title="Add Service Method to InsightsBookingBaseService">
Add your new method to the `InsightsBookingBaseService` class:
```typescript
// packages/lib/server/service/InsightsBookingBaseService.ts
export class InsightsBookingBaseService {
// ... existing methods
async getMyNewChartData() {
const baseConditions = await this.getBaseConditions();
// Example: Get booking counts by day using raw SQL for performance
const data = await this.prisma.$queryRaw<
Array<{
date: Date;
bookingsCount: number;
}>
>`
SELECT
DATE("createdAt") as date,
COUNT(*)::int as "bookingsCount"
FROM "BookingTimeStatusDenormalized"
WHERE ${baseConditions}
GROUP BY DATE("createdAt")
ORDER BY date ASC
`;
// Transform the data for the chart
return data.map((item) => ({
date: item.date.toISOString().split("T")[0], // Format as YYYY-MM-DD
value: item.bookingsCount,
}));
}
}
```
</Step>
## Best Practices
1. **Use `getInsightsBookingService()`**: Always use the DI container function for consistent service creation
2. **Raw SQL for Performance**: Use `$queryRaw` for complex aggregations and better performance
3. **Base Conditions**: Always use `await this.getBaseConditions()` for proper filtering and permissions
4. **Error Handling**: Wrap service calls in try-catch blocks with `TRPCError`
5. **Loading States**: Always show loading indicators with `LoadingInsight`
6. **Consistent Styling**: Use `recharts` for new charts.
7. **Date Handling**: Use `getDateRanges()` and `getTimeView()` for time-based charts
+5
View File
@@ -109,6 +109,11 @@
"group": "Embeds",
"icon": "code-simple",
"pages": ["developing/guides/embeds/embed-events"]
},
{
"group": "Insights",
"icon": "chart-bar",
"pages": ["developing/guides/insights/add-new-booking-charts"]
}
]
},
+1
View File
@@ -90,6 +90,7 @@ Below is a list of props that can be passed to the booker atom.
| customClassNames | No | To pass in custom classnames from outside for styling the atom |
| month | No | The exact month for displaying a user's availability; defaults to the current month |
| selectedDate | No | Default selected date for which the slot picker opens |
| startTime | No | Custom start time for the Booker that allows users to decide the first available date. Accepts JavaScript Date object or date string in format `YYYY-MM-DD` (e.g., `"2025-08-20"` or `new Date("2025-08-20")`) |
| hideBranding | No | For hiding any branding on the booker |
| isAway | No | Sets the booker component to the away state |
| allowsDynamicBooking | No | Boolean indicating if the booking is a dynamic booking |
+1 -1
View File
@@ -109,7 +109,7 @@
"prismock": "^1.33.4",
"resize-observer-polyfill": "^1.5.1",
"tsc-absolute": "^1.0.0",
"typescript": "^5.7.2",
"typescript": "5.9.0-beta",
"vitest": "^2.1.1",
"vitest-fetch-mock": "^0.3.0",
"vitest-mock-extended": "^2.0.2"
+1 -1
View File
@@ -31,6 +31,6 @@
"eslint-plugin-react": "^7.30.1",
"eslint-plugin-react-hooks": "^4.6.0",
"ts-node": "^10.9.1",
"typescript": "^5.7.2"
"typescript": "^5.9.0-beta"
}
}
@@ -5,6 +5,7 @@ import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar
import { WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { handleErrorsJson } from "@calcom/lib/errors";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import logger from "@calcom/lib/logger";
import prisma from "@calcom/prisma";
import { Prisma } from "@calcom/prisma/client";
@@ -91,8 +92,21 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
"Content-Type": "application/json",
},
});
logger.info("Office365 Calendar: Received calendar response", {
userId: req.session?.user?.id,
status: calRequest.status,
statusText: calRequest.statusText,
url: calRequest.url,
});
let calBody = await handleErrorsJson<{ value: OfficeCalendar[]; "@odata.nextLink"?: string }>(calRequest);
logger.info("Office365 Calendar: handleErrorsJson completed", {
userId: req.session?.user?.id,
calendarCount: calBody.value.length ?? 0,
});
if (typeof responseBody === "string") {
calBody = JSON.parse(responseBody) as { value: OfficeCalendar[] };
}
+2 -2
View File
@@ -12,7 +12,7 @@
"dependencies": {
"@calcom/eslint-plugin-eslint": "*",
"@todesktop/tailwind-variants": "^1.0.0",
"eslint-config-next": "^13.2.1",
"eslint-config-next": "^15.4.5",
"eslint-config-prettier": "^8.6.0",
"eslint-config-turbo": "^0.0.7",
"eslint-plugin-playwright": "^0.12.0",
@@ -33,6 +33,6 @@
"prettier-plugin-tailwindcss": "^0.2.5",
"tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.3.3",
"typescript": "^5.7.2"
"typescript": "^5.9.0-beta"
}
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { getErrorFromUnknown } from "@calcom/lib/errors";
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
import { serverConfig } from "@calcom/lib/serverConfig";
import { setTestEmail } from "@calcom/lib/testEmails";
import { prisma } from "@calcom/prisma";
import { sanitizeDisplayName } from "../lib/sanitizeDisplayName";
@@ -29,7 +30,7 @@ export default class BaseEmail {
return {};
}
public async sendEmail() {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const emailsDisabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("emails");
/** If email kill switch exists and is active, we prevent emails being sent. */
if (emailsDisabled) {
+1 -1
View File
@@ -54,7 +54,7 @@
"npm-run-all": "^4.1.5",
"postcss": "^8.4.18",
"tailwindcss": "^3.3.3",
"typescript": "^5.7.2",
"typescript": "^5.9.0-beta",
"vite": "^4.1.2",
"vite-plugin-environment": "^1.1.3"
}
+1 -1
View File
@@ -50,7 +50,7 @@
"@vitejs/plugin-react": "^2.2.0",
"eslint": "^8.34.0",
"npm-run-all": "^4.1.5",
"typescript": "^5.7.2",
"typescript": "^5.9.0-beta",
"vite": "^4.5.2"
},
"dependencies": {
+1 -1
View File
@@ -25,7 +25,7 @@
],
"types": "./dist/index.d.ts",
"devDependencies": {
"typescript": "^5.7.2",
"typescript": "^5.9.0-beta",
"vite": "^4.1.2"
},
"dependencies": {
+1 -1
View File
@@ -8,7 +8,7 @@
"@typescript-eslint/parser": "^5.52.0",
"@typescript-eslint/utils": "^5.52.0",
"ts-node-maintained": "^10.9.5",
"typescript": "^5.7.2"
"typescript": "^5.9.0-beta"
},
"devDependencies": {
"@types/eslint": "^8.4.5"
+2 -2
View File
@@ -34,7 +34,7 @@ export const sendEmailVerification = async ({
}: VerifyEmailType) => {
const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common");
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
if (!emailVerification) {
@@ -127,7 +127,7 @@ interface ChangeOfEmail {
export const sendChangeOfEmailVerification = async ({ user, language }: ChangeOfEmail) => {
const token = randomBytes(32).toString("hex");
const translation = await getTranslation(language ?? "en", "common");
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const emailVerification = await featuresRepository.checkIfFeatureIsEnabledGlobally("email-verification");
if (!emailVerification) {
@@ -102,7 +102,7 @@ export const DatePicker = ({
moveToNextMonthOnNoAvailability();
// Determine if this is a compact sidebar view based on layout
const isCompact = layout !== "month_view";
const isCompact = layout !== "month_view" && layout !== "mobile";
const periodData: PeriodData = {
...{
@@ -27,7 +27,6 @@ import getICalUID from "@calcom/emails/lib/getICalUID";
import { CalendarEventBuilder } from "@calcom/features/CalendarEventBuilder";
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
import { isEventTypeLoggingEnabled } from "@calcom/features/bookings/lib/isEventTypeLoggingEnabled";
import { getShouldServeCache } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import {
allowDisablingAttendeeConfirmationEmails,
@@ -52,6 +51,7 @@ import {
getFirstDelegationConferencingCredentialAppLocation,
} from "@calcom/lib/delegationCredential/server";
import { getCheckBookingAndDurationLimitsService } from "@calcom/lib/di/containers/booking-limits";
import { getCacheService } from "@calcom/lib/di/containers/cache";
import { ErrorCode } from "@calcom/lib/errorCodes";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
@@ -387,6 +387,23 @@ export type BookingHandlerInput = {
forcedSlug?: string;
} & PlatformParams;
function formatAvailabilitySnapshot(data: {
dateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
oooExcludedDateRanges: { start: dayjs.Dayjs; end: dayjs.Dayjs }[];
}) {
return {
...data,
dateRanges: data.dateRanges.map(({ start, end }) => ({
start: start.toISOString(),
end: end.toISOString(),
})),
oooExcludedDateRanges: data.oooExcludedDateRanges.map(({ start, end }) => ({
start: start.toISOString(),
end: end.toISOString(),
})),
};
}
async function handler(
input: BookingHandlerInput,
bookingDataSchemaGetter: BookingDataSchemaGetter = getBookingDataSchema
@@ -539,7 +556,8 @@ async function handler(
}
}
const shouldServeCache = await getShouldServeCache(_shouldServeCache, eventType.team?.id);
const cacheService = getCacheService();
const shouldServeCache = await cacheService.getShouldServeCache(_shouldServeCache, eventType.team?.id);
const isTeamEventType =
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
@@ -1396,7 +1414,10 @@ async function handler(
logger.info(`Booking created`, {
bookingUid: booking.uid,
availabilitySnapshot: organizerUserAvailability?.availabilityData,
selectedCalendarIds: organizerUser.allSelectedCalendars?.map((c) => c.id) ?? [],
availabilitySnapshot: organizerUserAvailability?.availabilityData
? formatAvailabilitySnapshot(organizerUserAvailability.availabilityData)
: null,
});
}
@@ -0,0 +1,302 @@
import type { Booking, User, Webhook } from "@prisma/client";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { DailyLocationType } from "@calcom/app-store/locations";
import { getMeetingSessionsFromRoomName } from "@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName";
import { triggerHostNoShow } from "@calcom/features/tasker/tasks/triggerNoShow/triggerHostNoShow";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import { prisma } from "@calcom/prisma";
import { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { scheduleNoShowTriggers } from "./scheduleNoShowTriggers";
vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({
getMeetingSessionsFromRoomName: vi.fn(),
}));
vi.mock("@calcom/features/webhooks/lib/sendPayload", () => ({
sendGenericWebhookPayload: vi.fn().mockResolvedValue({}),
}));
describe("scheduleNoShowTriggers Integration", () => {
let testUser: User;
let hostWebhook: Webhook;
let guestWebhook: Webhook;
const testEventTypeId = 67890;
const testBookingIds = [98765, 98766];
const testBookingId = 123456;
beforeAll(async () => {
testUser = await prisma.user.create({
data: {
email: "test-user-no-show@example.com",
username: "test-user-no-show",
name: "Test User No Show",
},
});
[hostWebhook, guestWebhook] = await Promise.all([
prisma.webhook.create({
data: {
id: "test-host-webhook-id",
userId: testUser.id,
subscriberUrl: "https://example.com/host-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
active: true,
time: 5,
timeUnit: TimeUnit.MINUTE,
},
}),
prisma.webhook.create({
data: {
id: "test-guest-webhook-id",
userId: testUser.id,
subscriberUrl: "https://example.com/guest-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
active: true,
time: 10,
timeUnit: TimeUnit.MINUTE,
},
}),
]);
});
afterAll(async () => {
await prisma.task.deleteMany({
where: {
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
},
});
await prisma.webhook.deleteMany({
where: {
userId: testUser.id,
},
});
await prisma.booking.deleteMany({
where: {
id: testBookingId,
},
});
await prisma.user.deleteMany({
where: {
id: testUser.id,
},
});
await prisma.eventType.deleteMany({
where: {
id: testEventTypeId,
},
});
});
test("scheduling a webhook creates the correct Tasker jobs for no-show webhooks", async () => {
const currentDate = new Date("2023-01-01T10:00:00.000Z");
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
id: testBookingIds[0],
startTime: currentDate,
endTime: new Date("2023-01-01T10:30:00.000Z"),
location: DailyLocationType,
};
await scheduleNoShowTriggers({
booking: bookingData,
organizerUser: { id: testUser.id },
eventTypeId: testEventTypeId,
triggerForUser: true,
});
const createdTasks = await prisma.task.findMany({
where: {
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
},
});
expect(createdTasks.length).toBe(2);
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
expect(hostTask).toBeDefined();
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
expect(guestTask).toBeDefined();
});
test("created task payloads for no-show webhooks are correct", async () => {
const currentDate = new Date("2023-01-01T11:00:00.000Z");
const bookingData: Partial<Booking> & { startTime: Date; id: number; location: string } = {
id: testBookingIds[1],
startTime: currentDate,
endTime: new Date("2023-01-01T11:30:00.000Z"),
location: DailyLocationType,
};
await scheduleNoShowTriggers({
booking: bookingData,
organizerUser: { id: testUser.id },
eventTypeId: testEventTypeId,
triggerForUser: true,
});
const createdTasks = await prisma.task.findMany({
where: {
OR: [{ type: "triggerHostNoShowWebhook" }, { type: "triggerGuestNoShowWebhook" }],
payload: {
contains: bookingData.id.toString(),
},
},
});
const hostTask = createdTasks.find((task) => task.type === "triggerHostNoShowWebhook");
expect(hostTask).toBeDefined();
let hostPayload;
try {
hostPayload = JSON.parse(hostTask?.payload ?? "");
} catch (e) {
console.error("Failed to parse host task payload.");
throw e;
}
expect(hostPayload).toEqual({
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
bookingId: bookingData.id,
webhook: expect.objectContaining({
id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
time: 5,
timeUnit: TimeUnit.MINUTE,
}),
});
const guestTask = createdTasks.find((task) => task.type === "triggerGuestNoShowWebhook");
expect(guestTask).toBeDefined();
let guestPayload;
try {
guestPayload = JSON.parse(guestTask?.payload ?? "");
} catch (e) {
console.error("Failed to parse guest task payload.");
throw e;
}
expect(guestPayload).toEqual({
triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW,
bookingId: bookingData.id,
webhook: expect.objectContaining({
id: guestWebhook.id,
subscriberUrl: "https://example.com/guest-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW],
time: 10,
timeUnit: TimeUnit.MINUTE,
}),
});
});
test("task handler runs properly with the correct payload", async () => {
await prisma.eventType.create({
data: {
id: testEventTypeId,
title: "Test Event Type",
slug: "test-event-type",
length: 30,
userId: testUser.id,
description: "Test description",
},
});
const now = new Date();
await prisma.booking.create({
data: {
id: testBookingId,
uid: "test-uid-no-show",
title: "Test No-Show",
eventTypeId: testEventTypeId,
status: "ACCEPTED",
startTime: now,
endTime: new Date(now.getTime() + 30 * 60 * 1000),
location: DailyLocationType,
userId: testUser.id,
references: {
create: {
type: "daily_video",
uid: "meeting-test-uid",
},
},
},
});
vi.mocked(getMeetingSessionsFromRoomName).mockResolvedValue({
total_count: 1,
data: [
{
id: "meeting-test-id",
room: "meeting-test-uid",
start_time: Math.floor(now.getTime() / 1000),
duration: 30,
max_participants: 1,
participants: [
{
user_id: "999999",
participant_id: "participant-test-id",
user_name: "Guest User",
join_time: Math.floor(now.getTime() / 1000),
duration: 30,
},
],
},
],
});
const taskPayload = JSON.stringify({
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
bookingId: testBookingId,
webhook: {
id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
eventTriggers: [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW],
active: true,
time: 5,
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
appId: null,
},
});
await triggerHostNoShow(taskPayload);
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
expect.objectContaining({
webhook: expect.objectContaining({
id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
}),
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
data: expect.objectContaining({
bookingId: testBookingId,
bookingUid: "test-uid-no-show",
message: expect.stringContaining("Host with email"),
participants: expect.arrayContaining([
expect.objectContaining({
user_id: "999999",
user_name: "Guest User",
}),
]),
}),
})
);
const updatedBooking = await prisma.booking.findUnique({
where: {
id: testBookingId,
},
});
expect(updatedBooking?.noShowHost).toBe(true);
});
});
@@ -3,6 +3,7 @@ import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getCredentialForCalendarCache } from "@calcom/lib/delegationCredential/server";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import type { Calendar } from "@calcom/types/Calendar";
import { CalendarCacheRepository } from "./calendar-cache.repository";
@@ -20,7 +21,7 @@ export class CalendarCache {
return await CalendarCache.init(calendarForCalendarCache);
}
static async init(calendar: Calendar | null): Promise<ICalendarCacheRepository> {
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const isCalendarCacheEnabledGlobally = await featureRepo.checkIfFeatureIsEnabledGlobally(
"calendar-cache"
);
@@ -1,8 +1,15 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import type { IFeaturesRepository } from "@calcom/features/flags/features.repository.interface";
export async function getShouldServeCache(shouldServeCache?: boolean | undefined, teamId?: number) {
if (typeof shouldServeCache === "boolean") return shouldServeCache;
if (!teamId) return undefined;
const featureRepo = new FeaturesRepository();
return await featureRepo.checkIfTeamHasFeature(teamId, "calendar-cache-serve");
export interface ICacheService {
featuresRepository: IFeaturesRepository;
}
export class CacheService {
constructor(private readonly dependencies: ICacheService) {}
async getShouldServeCache(shouldServeCache?: boolean | undefined, teamId?: number) {
if (typeof shouldServeCache === "boolean") return shouldServeCache;
if (!teamId) return undefined;
return await this.dependencies.featuresRepository.checkIfTeamHasFeature(teamId, "calendar-cache-serve");
}
}
+4 -2
View File
@@ -194,8 +194,10 @@ const Days = ({
const getPadding = (day: number) => (browsingDate.set("date", day).day() - weekStart + 7) % 7;
const totalDays = daysInMonth(browsingDate);
const showNextMonthDays = isSecondWeekOver && !isCompact;
// Only apply end-of-month logic for main monthly view (not compact sidebar)
if (isSecondWeekOver && !isCompact) {
if (showNextMonthDays) {
const startDay = 8;
const pad = getPadding(startDay);
days = Array(pad).fill(null);
@@ -337,7 +339,7 @@ const Days = ({
active={isActive(day)}
away={away}
emoji={emoji}
showMonthTooltip={isSecondWeekOver && !isCompact}
showMonthTooltip={showNextMonthDays && !disabled && day.month() !== browsingDate.month()}
isFirstDayOfNextMonth={isFirstDayOfNextMonth}
/>
)}
@@ -25,6 +25,17 @@ vi.mock("@calcom/prisma", () => {
vi.mock("stripe");
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
vi.mock("@calcom/lib/constants", async () => {
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
return {
@@ -43,6 +43,17 @@ vi.mock("@calcom/lib/domainManager/organization", () => ({
createDomain: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
const mockOrganizationOnboarding = {
name: "Test Org",
slug: "test-org",
@@ -6,7 +6,7 @@ import { MembershipRole } from "@calcom/prisma/client";
export const getServerSideProps = async ({ req }: GetServerSidePropsContext) => {
const prisma = await import("@calcom/prisma").then((mod) => mod.default);
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
const organizationsEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally("organizations");
// Check if organizations are enabled
if (!organizationsEnabled) {
@@ -277,7 +277,7 @@ export const scheduleEmailReminder = async (args: scheduleEmailReminderArgs) =>
const isSendgridEnabled = !!(process.env.SENDGRID_API_KEY && process.env.SENDGRID_EMAIL);
const featureRepo = new FeaturesRepository();
const featureRepo = new FeaturesRepository(prisma);
const isWorkflowSmtpEmailsEnabled = teamId
? await featureRepo.checkIfTeamHasFeature(teamId, "workflow-smtp-emails")
@@ -17,6 +17,16 @@ vi.mock("@calcom/features/ee/workflows/lib/reminders/providers/emailProvider", (
sendOrScheduleWorkflowEmails: vi.fn(),
}));
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
describe("reminderScheduler", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -1,5 +1,5 @@
import { createInstance } from "i18next";
import { expect, test, describe } from "vitest";
import { vi, expect, test, describe } from "vitest";
import { getTranslation } from "@calcom/lib/server/i18n";
import { TimeFormat } from "@calcom/lib/timeFormat";
@@ -10,6 +10,17 @@ import { getTemplateBodyForAction } from "../actionHelperFunctions";
import compareReminderBodyToTemplate from "../compareReminderBodyToTemplate";
import plainTextReminderTemplates from "../reminders/templates/plainTextTemplates";
vi.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: async (locale: string, namespace: string) => {
const t = (key: string) => key;
t.locale = locale;
t.namespace = namespace;
return t;
},
};
});
const translation = async () => {
const _i18n = createInstance();
await _i18n.init({
@@ -1103,7 +1103,6 @@ describe("Workflow SMTP Emails Feature Flag", () => {
});
test("should use SMTP when team has workflow-smtp-emails feature", async () => {
const featuresRepository = new FeaturesRepository();
vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(true);
await scheduleEmailReminder({
@@ -1115,7 +1114,6 @@ describe("Workflow SMTP Emails Feature Flag", () => {
});
test("should use SMTP when user has workflow-smtp-emails feature", async () => {
const featuresRepository = new FeaturesRepository();
vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(true);
await scheduleEmailReminder({
@@ -1127,7 +1125,6 @@ describe("Workflow SMTP Emails Feature Flag", () => {
});
test("should use SendGrid when workflow-smtp-emails feature is not enabled for team", async () => {
const featuresRepository = new FeaturesRepository();
vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(false);
await scheduleEmailReminder({
@@ -1140,7 +1137,6 @@ describe("Workflow SMTP Emails Feature Flag", () => {
});
test("should use SendGrid when workflow-smtp-emails feature is not enabled for user", async () => {
const featuresRepository = new FeaturesRepository();
vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(false);
await scheduleEmailReminder({
@@ -1153,9 +1149,9 @@ describe("Workflow SMTP Emails Feature Flag", () => {
});
test("should use SMTP when SendGrid is not configured", async () => {
const featuresRepository = new FeaturesRepository();
vi.spyOn(featuresRepository, "checkIfTeamHasFeature").mockResolvedValue(false);
vi.spyOn(featuresRepository, "checkIfUserHasFeature").mockResolvedValue(false);
vi.spyOn(FeaturesRepository.prototype, "checkIfTeamHasFeature").mockResolvedValue(false);
vi.spyOn(FeaturesRepository.prototype, "checkIfUserHasFeature").mockResolvedValue(false);
delete process.env.SENDGRID_API_KEY;
delete process.env.SENDGRID_EMAIL;
@@ -66,11 +66,13 @@ export default function CreateEventTypeForm({
!isPlatform ? (
<Tooltip content={!isManagedEventType ? pageSlug : t("username_placeholder")}>
<span className="max-w-24 md:max-w-56">
/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
{`/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
</span>
</Tooltip>
) : undefined
}
containerClassName="[&>div]:gap-0"
className="pl-0"
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
@@ -91,11 +93,13 @@ export default function CreateEventTypeForm({
<Tooltip
content={`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}>
<span className="max-w-24 md:max-w-56">
{urlPrefix}/{!isManagedEventType ? pageSlug : t("username_placeholder")}/
{`${urlPrefix}/${!isManagedEventType ? pageSlug : t("username_placeholder")}/`}
</span>
</Tooltip>
) : undefined
}
containerClassName="[&>div]:gap-0"
className="pl-0"
{...register("slug")}
onChange={(e) => {
form.setValue("slug", slugify(e?.target.value), { shouldTouch: true });
@@ -208,9 +208,12 @@ export const EventSetupTab = (
{...(isManagedEventType || isChildrenManagedEventType ? urlLockedProps : {})}
defaultValue={eventType.slug}
data-testid="event-slug"
containerClassName={classNames(customClassNames?.titleSection?.urlInput?.container)}
containerClassName={classNames(
"[&>div]:gap-0",
customClassNames?.titleSection?.urlInput?.container
)}
labelClassName={classNames(customClassNames?.titleSection?.urlInput?.label)}
className={classNames(customClassNames?.titleSection?.urlInput?.input)}
className={classNames("pl-0", customClassNames?.titleSection?.urlInput?.input)}
addOnLeading={
isPlatform ? undefined : (
<>
@@ -1,11 +1,11 @@
import { PrismaClient } from "@prisma/client";
import { describe, expect, beforeAll, afterAll, beforeEach, it } from "vitest";
import prisma from "@calcom/prisma";
import type { AppFlags } from "./config";
import { FeaturesRepository } from "./features.repository";
const prisma = new PrismaClient();
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
// Access private clearCache method through type assertion
const clearCache = () => {
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { captureException } from "@sentry/nextjs";
import db from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
import type { AppFlags, TeamFeatures } from "./config";
import type { IFeaturesRepository } from "./features.repository.interface";
@@ -18,6 +18,8 @@ interface CacheOptions {
export class FeaturesRepository implements IFeaturesRepository {
private static featuresCache: { data: any[]; expiry: number } | null = null;
constructor(private prismaClient: PrismaClient) {}
private clearCache() {
FeaturesRepository.featuresCache = null;
}
@@ -32,7 +34,7 @@ export class FeaturesRepository implements IFeaturesRepository {
return FeaturesRepository.featuresCache.data;
}
const features = await db.feature.findMany({
const features = await this.prismaClient.feature.findMany({
orderBy: { slug: "asc" },
cacheStrategy: { swr: 300, ttl: 300 },
});
@@ -64,7 +66,7 @@ export class FeaturesRepository implements IFeaturesRepository {
* @returns Promise<{ [slug: string]: boolean } | null>
*/
public async getTeamFeatures(teamId: number) {
const result = await db.teamFeatures.findMany({
const result = await this.prismaClient.teamFeatures.findMany({
where: {
teamId,
},
@@ -122,7 +124,7 @@ export class FeaturesRepository implements IFeaturesRepository {
* FIXME refactor when upgrading prismock
* https://github.com/morintd/prismock/issues/592
*/
const userHasFeature = await db.userFeatures.findFirst({
const userHasFeature = await this.prismaClient.userFeatures.findFirst({
where: {
userId,
featureId: slug,
@@ -180,7 +182,7 @@ export class FeaturesRepository implements IFeaturesRepository {
LIMIT 1;
`;
const result = await db.$queryRaw<unknown[]>(query);
const result = await this.prismaClient.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
@@ -199,7 +201,7 @@ export class FeaturesRepository implements IFeaturesRepository {
async checkIfTeamHasFeature(teamId: number, featureId: keyof AppFlags): Promise<boolean> {
try {
// Early return if team has feature directly assigned
const teamHasFeature = await db.teamFeatures.findUnique({
const teamHasFeature = await this.prismaClient.teamFeatures.findUnique({
where: {
teamId_featureId: {
teamId,
@@ -238,7 +240,7 @@ export class FeaturesRepository implements IFeaturesRepository {
LIMIT 1;
`;
const result = await db.$queryRaw<unknown[]>(query);
const result = await this.prismaClient.$queryRaw<unknown[]>(query);
return result.length > 0;
} catch (err) {
captureException(err);
@@ -1,6 +1,7 @@
import { startSpan } from "@sentry/nextjs";
import { FeaturesRepository } from "../features.repository";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
/**
* Use Cases represent individual operations, like "Create Feature" or "Sign In" or "Toggle Feature".
@@ -11,7 +12,7 @@ import { FeaturesRepository } from "../features.repository";
*/
export function checkIfUserHasFeatureUseCase(userId: number, slug: string): Promise<boolean> {
return startSpan({ name: "checkIfUserHasFeature UseCase", op: "function" }, async () => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
return await featuresRepository.checkIfUserHasFeature(userId, slug);
});
@@ -1,12 +1,12 @@
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import prisma from "@calcom/prisma";
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { FeaturesRepository } from "../../features.repository";
/**
* TRPC procedure that returns a map of all feature flags and their enabled status.
* Uses the FeaturesRepository to handle caching and database access.
*/
export const map = publicProcedure.query(async () => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
return featuresRepository.getFeatureFlagMap();
});
+4 -3
View File
@@ -1,15 +1,16 @@
import { z } from "zod";
import type { AppFlags } from "@calcom/features/flags/config";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { prisma } from "@calcom/prisma";
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
import { router } from "@calcom/trpc/server/trpc";
import { FeaturesRepository } from "../features.repository";
import { map } from "./procedures/map";
export const featureFlagRouter = router({
list: publicProcedure.query(async () => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
return featuresRepository.getAllFeatures();
}),
checkTeamFeature: publicProcedure
@@ -20,7 +21,7 @@ export const featureFlagRouter = router({
})
)
.query(async ({ input }) => {
const featuresRepository = new FeaturesRepository();
const featuresRepository = new FeaturesRepository(prisma);
return featuresRepository.checkIfTeamHasFeature(input.teamId, input.feature as keyof AppFlags);
}),
map,
@@ -4,6 +4,7 @@ import { Controller, useFormContext } from "react-hook-form";
import type { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import classNames from "@calcom/ui/classNames";
import { InfoBadge } from "@calcom/ui/components/badge";
import { Label } from "@calcom/ui/components/form";
@@ -19,6 +20,19 @@ import {
} from "./useShouldBeDisabledDueToPrefill";
import { getTranslatedConfig as getTranslatedVariantsConfig } from "./utils/variantsConfig";
// helper to render markdown label safely
const renderLabel = (field: Partial<RhfFormField>) => {
if (field.labelAsSafeHtml) {
return (
<span
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(field.labelAsSafeHtml) }}
/>
);
}
return <span>{field.label}</span>;
};
type RhfForm = {
fields: z.infer<typeof fieldsSchema>;
};
@@ -155,7 +169,7 @@ const WithLabel = ({
field.label && (
<div className="mb-2 flex items-center">
<Label className="!mb-0 flex items-center" htmlFor={htmlFor}>
<span>{field.label}</span>
{renderLabel(field)}
<span className="text-emphasis -mb-1 ml-1 text-sm font-medium leading-none">
{!readOnly && field.required ? "*" : ""}
</span>
@@ -4,4 +4,4 @@ import type { FieldType } from "./schema";
* Once a component supports `labelAsSafeHtml`, add it's field's type here
* A whitelist is needed because unless we explicitly use dangerouslySetInnerHTML, React will escape the HTML
*/
export const fieldsThatSupportLabelAsSafeHtml: FieldType[] = ["boolean"];
export const fieldsThatSupportLabelAsSafeHtml: FieldType[] = ["boolean", "radio"];
@@ -1,18 +0,0 @@
import { Card } from "@tremor/react";
import classNames from "@calcom/ui/classNames";
interface ICardProps {
children: React.ReactNode;
className?: string;
}
export const CardInsights = (props: ICardProps) => {
const { children, className = "", ...rest } = props;
return (
<Card className={classNames(`ring-subtle bg-muted shadow-none `, className)} {...rest}>
{children}
</Card>
);
};
@@ -21,7 +21,7 @@ export function ChartCard({
legendSize,
children,
}: {
title: string;
title: string | React.ReactNode;
subtitle?: string;
cta?: { label: string; onClick: () => void };
legend?: Array<LegendItem>;
@@ -31,7 +31,11 @@ export function ChartCard({
return (
<div className="bg-muted group relative flex w-full flex-col items-center rounded-2xl px-1 pb-1">
<div className="flex h-11 w-full shrink-0 items-center justify-between gap-2 px-4">
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
{typeof title === "string" ? (
<h2 className="text-emphasis mr-4 shrink-0 text-sm font-semibold">{title}</h2>
) : (
title
)}
<div className="no-scrollbar flex items-center gap-2 overflow-x-auto">
{legend && legend.length > 0 && <Legend items={legend} size={legendSize} />}
{cta && (
@@ -80,13 +84,13 @@ function Legend({ items, size = "default" }: { items: LegendItem[]; size?: Legen
{items.map((item, index) => (
<Fragment key={item.label}>
<div
className="relative flex items-center gap-2 rounded-md px-1.5 py-1"
className="relative flex items-center gap-2 rounded-md px-1.5 py-0.5"
style={{ backgroundColor: `${item.color}33` }}>
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: item.color }} />
<Tooltip content={item.label}>
<p
className={classNames(
"text-default truncate text-sm font-medium leading-none",
"text-default truncate py-0.5 text-sm font-medium leading-none",
size === "sm" ? "w-16" : ""
)}>
{item.label}
@@ -1,11 +1,11 @@
"use client";
import { Flex, Text, Metric, BadgeDelta } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import { Icon } from "@calcom/ui/components/icon";
import { Tooltip } from "@calcom/ui/components/tooltip";
import { calculateDeltaType, colors, valueFormatter } from "../lib";
import { calculateDeltaType, valueFormatter } from "../lib";
export const KPICard = ({
title,
@@ -13,7 +13,6 @@ export const KPICard = ({
previousDateRange,
}: {
title: string;
value: number;
previousMetricData: {
count: number;
deltaPrevious: number;
@@ -21,22 +20,54 @@ export const KPICard = ({
previousDateRange: { startDate: string; endDate: string };
}) => {
const { t } = useLocale();
const deltaType = calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count);
const deltaValue = Number(previousMetricData.deltaPrevious).toFixed(0);
const getBadgeVariant = (type: string) => {
switch (type) {
case "increase":
case "moderateIncrease":
return "success" as const;
case "decrease":
case "moderateDecrease":
return "error" as const;
case "unchanged":
default:
return "gray" as const;
}
};
// Get appropriate icon for delta
const getDeltaIcon = (type: string) => {
switch (type) {
case "increase":
case "moderateIncrease":
return "arrow-up" as const;
case "decrease":
case "moderateDecrease":
return "arrow-down" as const;
case "unchanged":
default:
return null;
}
};
const badgeVariant = getBadgeVariant(deltaType);
const deltaIcon = getDeltaIcon(deltaType);
return (
<div>
<Text className="text-default">{title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate">
<Metric className="text-emphasis">{valueFormatter(previousMetricData.count)}</Metric>
</Flex>
<Flex className="mt-4 justify-start space-x-2">
<BadgeDelta
deltaType={calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)}
/>
<Flex className="justify-start space-x-1 truncate">
<Text
color={colors[calculateDeltaType(previousMetricData.deltaPrevious - previousMetricData.count)]}>
{Number(previousMetricData.deltaPrevious).toFixed(0)}%
</Text>
<div className="text-default text-sm">{title}</div>
<div className="flex items-baseline justify-start space-x-3 truncate">
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(previousMetricData.count)}</div>
</div>
<div className="mt-4 flex items-center justify-start space-x-2">
<Badge variant={badgeVariant} className="flex items-center gap-1">
{deltaIcon && <Icon name={deltaIcon} className="h-3 w-3" />}
{deltaValue}%
</Badge>
<div className="flex justify-start space-x-1 truncate">
<Tooltip
content={t("from_to_date_period", {
startDate: previousDateRange.startDate,
@@ -46,8 +77,8 @@ export const KPICard = ({
{t("from_last_period")}
</small>
</Tooltip>
</Flex>
</Flex>
</div>
</div>
</div>
);
};
@@ -1,8 +0,0 @@
import { LineChart as ExternalLineChart } from "@tremor/react";
import type { LineChartProps } from "./tremor.types";
// Honestly this is a mess. Why are all chart libraries in existance horrible to theme
export const LineChart = (props: LineChartProps) => {
return <ExternalLineChart {...props} />;
};
@@ -1,26 +1,25 @@
import classNames from "@calcom/ui/classNames";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { CardInsights } from "./Card";
import { ChartCard } from "./ChartCard";
export const LoadingInsight = () => {
return (
<CardInsights>
<SkeletonText className="w-32" />
<ChartCard title={<SkeletonText className="w-32" />}>
<div className="m-auto flex h-80 flex-col items-center justify-center">
<svg
className={classNames("mx-4 h-8 w-8 animate-spin", "text-black dark:text-white")}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
className="animate-spin">
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
</svg>
</div>
</CardInsights>
</ChartCard>
);
};
@@ -2,25 +2,24 @@
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { User } from "@calcom/prisma/client";
import { Avatar } from "@calcom/ui/components/avatar";
import { ChartCardItem } from "./ChartCard";
export const UserStatsTable = ({
data,
}: {
data:
| {
userId: number | null;
user: Pick<User, "avatarUrl" | "name">;
emailMd5?: string;
count?: number;
averageRating?: number | null;
username?: string;
}[]
| undefined;
}) => {
type UserStatsData = {
userId: number;
user: {
id: number;
username: string | null;
name: string | null;
email: string;
avatarUrl: string;
};
emailMd5: string;
count: number;
}[];
export const UserStatsTable = ({ data }: { data: UserStatsData }) => {
const { t } = useLocale();
// Filter out items without user data
@@ -32,7 +31,7 @@ export const UserStatsTable = ({
filteredData.map((item) => (
<ChartCardItem
key={item.userId || `user-${Math.random()}`}
count={item.averageRating ? item.averageRating.toFixed(1) : item.count}
count={Number.isInteger(item.count) ? item.count : item.count.toFixed(1)}
className="py-3">
<div className="flex items-center">
<Avatar
@@ -1,14 +1,70 @@
"use client";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { valueFormatter } from "../../lib/valueFormatter";
import { ChartCard } from "../ChartCard";
import { LineChart } from "../LineChart";
import { LoadingInsight } from "../LoadingInsights";
const COLOR = {
AVERAGE: "#3b82f6",
};
type AverageEventDurationData = RouterOutputs["viewer"]["insights"]["averageEventDuration"][number];
// Custom duration formatter that shows hours and minutes
const formatDuration = (minutes: number) => {
if (!minutes) return "0m";
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
const remainingMinutesStr = remainingMinutes.toFixed(1);
if (hours > 0 && remainingMinutes > 0) {
return `${hours}h ${remainingMinutesStr}m`;
} else if (hours > 0) {
return `${hours}h`;
} else {
return `${remainingMinutesStr}m`;
}
};
// Custom Tooltip component
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
value: number;
dataKey: string;
name: string;
color: string;
payload: AverageEventDurationData;
}>;
label?: string;
}) => {
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{label}</p>
{payload.map((entry, index: number) => (
<p key={index} style={{ color: entry.color }}>
{entry.name}: {formatDuration(entry.value)}
</p>
))}
</div>
);
};
export const AverageEventDurationChart = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
@@ -36,14 +92,32 @@ export const AverageEventDurationChart = () => {
</div>
)}
{data && data.length > 0 && !isNoData && (
<LineChart
className="mt-4 h-80"
data={data}
index="Date"
categories={["Average"]}
colors={["blue"]}
valueFormatter={valueFormatter}
/>
<div className="mt-4 h-80">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Date" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={formatDuration}
/>
<Tooltip content={<CustomTooltip />} />
<Line
type="linear"
dataKey="Average"
name={t("average_event_duration")}
stroke={COLOR.AVERAGE}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</ChartCard>
);
@@ -84,7 +84,6 @@ export const BookingKPICards = () => {
<StatItem key={item.title} index={index}>
<KPICard
title={item.title}
value={data[item.index].count}
previousMetricData={data[item.index]}
previousDateRange={data.previousRange}
/>
@@ -99,7 +98,6 @@ export const BookingKPICards = () => {
<StatItem key={item.title} index={index}>
<KPICard
title={item.title}
value={data[item.index].count}
previousMetricData={data[item.index]}
previousDateRange={data.previousRange}
/>
@@ -13,15 +13,13 @@ import {
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { LoadingInsight } from "../LoadingInsights";
type BookingsByHourData = {
hour: number;
count: number;
};
type BookingsByHourData = RouterOutputs["viewer"]["insights"]["bookingsByHourStats"][number];
export const BookingsByHourChartContent = ({ data }: { data: BookingsByHourData[] }) => {
const { t } = useLocale();
@@ -1,14 +1,68 @@
"use client";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { valueFormatter } from "../../lib/valueFormatter";
import { ChartCard } from "../ChartCard";
import { LineChart } from "../LineChart";
import { LoadingInsight } from "../LoadingInsights";
const COLOR = {
CREATED: "#a855f7",
COMPLETED: "#22c55e",
RESCHEDULED: "#3b82f6",
CANCELLED: "#ef4444",
NO_SHOW_HOST: "#64748b",
NO_SHOW_GUEST: "#f97316",
};
export const legend = [
{ label: "Created", color: COLOR.CREATED },
{ label: "Completed", color: COLOR.COMPLETED },
{ label: "Rescheduled", color: COLOR.RESCHEDULED },
{ label: "Cancelled", color: COLOR.CANCELLED },
{ label: "No-Show (Host)", color: COLOR.NO_SHOW_HOST },
{ label: "No-Show (Guest)", color: COLOR.NO_SHOW_GUEST },
];
type EventTrendsData = RouterOutputs["viewer"]["insights"]["eventTrends"][number];
// Custom Tooltip component
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
value: number;
dataKey: string;
name: string;
color: string;
payload: EventTrendsData;
}>;
label?: string;
}) => {
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-default text-inverted border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{payload[0].payload.formattedDateFull}</p>
{payload.map((entry, index: number) => (
<p key={index} style={{ color: entry.color }}>
{entry.name}: {valueFormatter ? valueFormatter(entry.value) : entry.value}
</p>
))}
</div>
);
};
export const EventTrendsChart = () => {
const { t } = useLocale();
const insightsBookingParams = useInsightsBookingParameters();
@@ -30,15 +84,36 @@ export const EventTrendsChart = () => {
if (!isSuccess) return null;
return (
<ChartCard title={t("event_trends")}>
<LineChart
className="linechart ml-4 mt-4 h-80 sm:ml-0"
data={eventTrends ?? []}
categories={["Created", "Completed", "Rescheduled", "Cancelled", "No-Show (Host)", "No-Show (Guest)"]}
index="Month"
colors={["purple", "green", "blue", "red", "slate", "orange"]}
valueFormatter={valueFormatter}
/>
<ChartCard title={t("event_trends")} legend={legend}>
<div className="linechart ml-4 mt-4 h-80 sm:ml-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={eventTrends ?? []} margin={{ top: 30, right: 20, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<XAxis dataKey="Month" className="text-xs" axisLine={false} tickLine={false} />
<YAxis
allowDecimals={false}
className="text-xs opacity-50"
axisLine={false}
tickLine={false}
tickFormatter={valueFormatter}
/>
<Tooltip content={<CustomTooltip />} />
{legend.map((item) => (
<Line
key={item.label}
type="linear"
dataKey={item.label}
name={item.label}
stroke={item.color}
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
animationDuration={1000}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</ChartCard>
);
};
@@ -5,8 +5,8 @@ import { trpc } from "@calcom/trpc";
import { useInsightsBookingParameters } from "../../hooks/useInsightsBookingParameters";
import { ChartCard } from "../ChartCard";
import { FeedbackTable } from "../FeedbackTable";
import { LoadingInsight } from "../LoadingInsights";
import { RecentFeedbackTableContent } from "./RecentFeedbackTableContent";
export const RecentFeedbackTable = () => {
const { t } = useLocale();
@@ -26,7 +26,7 @@ export const RecentFeedbackTable = () => {
return (
<ChartCard title={t("recent_ratings")}>
<FeedbackTable data={data} />
<RecentFeedbackTableContent data={data} />
</ChartCard>
);
};
@@ -2,31 +2,20 @@
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { User } from "@calcom/prisma/client";
import type { RouterOutputs } from "@calcom/trpc/react";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { EmptyScreen } from "@calcom/ui/components/empty-screen";
import { Tooltip } from "@calcom/ui/components/tooltip";
export const FeedbackTable = ({
data,
}: {
data:
| {
userId: number | null;
user: Pick<User, "avatarUrl" | "name">;
emailMd5?: string;
username?: string;
rating: number | null;
feedback: string | null;
}[]
| undefined;
}) => {
type FeedbackData = RouterOutputs["viewer"]["insights"]["recentRatings"];
export const RecentFeedbackTableContent = ({ data }: { data: FeedbackData }) => {
const { t } = useLocale();
return (
<div className="overflow-hidden rounded-md">
{data && data?.length > 0 ? (
data?.map((item) => (
{data && data.length > 0 ? (
data.map((item) => (
<div
key={item.userId}
className="border-subtle flex items-center justify-between border-b px-4 py-3 last:border-b-0">
@@ -1,10 +1,8 @@
export { BookedByCell } from "./BookedByCell";
export { BookingAtCell } from "./BookingAtCell";
export { BookingStatusBadge } from "./BookingStatusBadge";
export { CardInsights } from "./Card";
export { CellWithOverflowX } from "./CellWithOverflowX";
export { ChartCard } from "./ChartCard";
export { FeedbackTable } from "./FeedbackTable";
export { KPICard } from "./KPICard";
export { LineChart } from "./LineChart";
export { LoadingInsight } from "./LoadingInsights";
@@ -1,6 +1,16 @@
"use client";
import { useState } from "react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Rectangle,
} from "recharts";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -8,7 +18,39 @@ import { ToggleGroup } from "@calcom/ui/components/form";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { ChartCard } from "../ChartCard";
import { BarList } from "../tremor/BarList";
// Custom Tooltip component
const CustomTooltip = ({
active,
payload,
label,
}: {
active?: boolean;
payload?: Array<{
value: number;
dataKey: string;
name: string;
color: string;
payload: { name: string; value: number };
}>;
label?: string;
}) => {
const { t } = useLocale();
if (!active || !payload?.length) {
return null;
}
return (
<div className="bg-default border-subtle rounded-lg border p-3 shadow-lg">
<p className="text-default font-medium">{label}</p>
{payload.map((entry, index: number) => (
<p key={index}>
{t("failed_bookings")}: {entry.value}
</p>
))}
</div>
);
};
interface FormCardProps {
formName: string;
@@ -16,6 +58,7 @@ interface FormCardProps {
}
function FormCard({ formName, fields }: FormCardProps) {
const { t } = useLocale();
const fieldNames = Object.keys(fields);
const [selectedField, setSelectedField] = useState(fieldNames[0]);
@@ -29,6 +72,17 @@ function FormCard({ formName, fields }: FormCardProps) {
value: option.count,
}));
const maxCount = Math.max(...(selectedFieldData?.map((item) => item.value) || [0]));
const isEmpty = maxCount === 0;
if (isEmpty) {
return (
<div className="text-default flex h-60 text-center">
<p className="m-auto text-sm font-light">{t("insights_no_data_found_for_filter")}</p>
</div>
);
}
return (
<div className="flex flex-col">
<div className="p-4">
@@ -38,13 +92,36 @@ function FormCard({ formName, fields }: FormCardProps) {
className="w-fit"
onValueChange={(value) => value && setSelectedField(value)}
/>
{selectedFieldData && (
<div className="scrollbar-thin mt-4 h-[400px] overflow-y-auto">
<BarList
data={selectedFieldData}
valueFormatter={(value: number) => value.toString()}
className="mt-2"
/>
{selectedFieldData && selectedFieldData.length > 0 ? (
<div className="mt-4 h-80">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={selectedFieldData}
layout="vertical"
margin={{
top: 20,
right: 10,
left: 20,
bottom: 5,
}}
barCategoryGap="10%">
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
<XAxis type="number" className="text-xs" axisLine={false} tickLine={false} />
<YAxis dataKey="name" type="category" className="text-xs" axisLine={false} tickLine={false} />
<Tooltip cursor={false} content={<CustomTooltip />} />
<Bar
dataKey="value"
fill="var(--cal-bg-subtle)"
radius={[0, 2, 2, 0]}
activeBar={<Rectangle fill="var(--cal-bg-info)" />}
/>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="mt-4 p-4 text-center text-gray-500">
No data available for selected field
<div className="mt-2 text-xs">Data: {JSON.stringify(selectedFieldData, null, 2)}</div>
</div>
)}
</div>
@@ -66,8 +143,9 @@ export function FailedBookingsByField() {
// routingFormId is always set, meaning data has only one entry.
const [formName, fields] = Object.entries(data)[0];
return (
<ChartCard title={t("failed_bookings_by_field")} subtitle={formName}>
<ChartCard title={t("failed_bookings_by_field")}>
<FormCard key={formName} formName={formName} fields={fields} />
</ChartCard>
);
@@ -9,6 +9,7 @@ import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useInViewObserver } from "@calcom/lib/hooks/useInViewObserver";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import type { RouterOutputs } from "@calcom/trpc/react";
import classNames from "@calcom/ui/classNames";
import { Avatar } from "@calcom/ui/components/avatar";
import { Badge } from "@calcom/ui/components/badge";
@@ -165,14 +166,7 @@ function FormCard({
);
}
type RoutedToTableRow = {
id: number;
name: string;
avatarUrl: string | null;
stats: { [key: string]: number };
performance: "above_average" | "at_average" | "below_average" | "median" | "no_data";
totalBookings: number;
};
type RoutedToTableRow = RouterOutputs["viewer"]["insights"]["routedToPerPeriod"]["users"]["data"][number];
const getPerformanceBadge = (performance: RoutedToTableRow["performance"], t: TFunction) => {
switch (performance) {
@@ -292,7 +286,7 @@ export function RoutedToPerPeriod() {
.sort((a, b) => a.getTime() - b.getTime());
}, [data?.pages]);
const processedData: RoutedToTableRow[] = useMemo(() => {
const processedData = useMemo(() => {
if (!data?.pages) return [];
// Create a map for quick lookup of stats
@@ -419,7 +413,7 @@ export function RoutedToPerPeriod() {
<HoverCard>
<HoverCardTrigger asChild>
<div className="flex cursor-pointer items-center gap-2">
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name} />
<Avatar size="sm" imageSrc={row.avatarUrl} alt={row.name || ""} />
<div className="flex flex-col gap-1 truncate">
<span>{row.name}</span>
</div>
@@ -21,8 +21,8 @@ import { trpc } from "@calcom/trpc";
import { RoutingFormResponsesDownload } from "../../filters/Download";
import { OrgTeamsFilter } from "../../filters/OrgTeamsFilter";
import { useInsightsColumns } from "../../hooks/useInsightsColumns";
import { useInsightsFacetedUniqueValues } from "../../hooks/useInsightsFacetedUniqueValues";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { useInsightsRoutingFacetedUniqueValues } from "../../hooks/useInsightsRoutingFacetedUniqueValues";
import type { RoutingFormTableRow } from "../../lib/types";
import { RoutingKPICards } from "./RoutingKPICards";
@@ -38,19 +38,21 @@ export function RoutingFormResponsesTable() {
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
useInsightsParameters();
const {
data: headers,
isLoading: isHeadersLoading,
isSuccess: isHeadersSuccess,
} = trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
const { data: headers, isSuccess: isHeadersSuccess } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
userId,
teamId,
isAll,
routingFormId,
});
const getInsightsFacetedUniqueValues = useInsightsRoutingFacetedUniqueValues({
headers,
userId,
teamId,
isAll,
routingFormId,
});
const getInsightsFacetedUniqueValues = useInsightsFacetedUniqueValues({ headers, userId, teamId, isAll });
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
@@ -109,10 +111,6 @@ export function RoutingFormResponsesTable() {
}
}, [table, getInsightsFacetedUniqueValues, routingFormId]);
if (isHeadersLoading && !headers) {
return <DataTableSkeleton columns={4} columnWidths={[200, 200, 250, 250]} />;
}
return (
<>
<div className="flex-1">
@@ -1,7 +1,6 @@
"use client";
import { useColumnFilters } from "@calcom/features/data-table";
import { useInsightsParameters } from "@calcom/features/insights/hooks/useInsightsParameters";
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -11,18 +10,9 @@ import { RoutingFunnelSkeleton } from "./RoutingFunnelSkeleton";
export function RoutingFunnel() {
const { t } = useLocale();
const { scope, selectedTeamId, startDate, endDate } = useInsightsParameters();
const columnFilters = useColumnFilters({
exclude: ["createdAt"],
});
const insightsRoutingParams = useInsightsRoutingParameters();
const { data, isSuccess, isLoading } = trpc.viewer.insights.getRoutingFunnelData.useQuery(
{
scope,
selectedTeamId,
startDate,
endDate,
columnFilters,
},
insightsRoutingParams,
{
staleTime: 30000,
trpc: {
@@ -1,14 +1,13 @@
"use client";
import { Grid } from "@tremor/react";
import { Flex, Text, Metric } from "@tremor/react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import classNames from "@calcom/ui/classNames";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { valueFormatter } from "../../lib";
import { CardInsights } from "../Card";
import { ChartCard } from "../ChartCard";
export const RoutingKPICards = () => {
const { t } = useLocale();
@@ -61,35 +60,46 @@ export const RoutingKPICards = () => {
}
return (
<>
<Grid numColsSm={1} numColsLg={3} className="mt-4 gap-x-4 gap-y-4">
<ChartCard title={t("stats")}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{categories.map((item) => (
<CardInsights key={item.title}>
<Text className="text-default">{item.title}</Text>
<Flex className="items-baseline justify-start space-x-3 truncate">
<Metric className="text-emphasis">{valueFormatter(data[item.index])}</Metric>
</Flex>
</CardInsights>
<div
key={item.title}
className={classNames(
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
)}>
<div className="text-default text-sm">{item.title}</div>
<div className="flex items-baseline justify-start space-x-3 truncate">
<div className="text-emphasis text-2xl font-semibold">{valueFormatter(data[item.index])}</div>
</div>
</div>
))}
</Grid>
</>
</div>
</ChartCard>
);
};
const LoadingKPICards = (props: { categories: { title: string; index: string }[] }) => {
const { t } = useLocale();
const { categories } = props;
return (
<Grid numColsSm={2} numColsLg={4} className="mt-4 gap-x-4 gap-y-4">
{categories.map((item) => (
<CardInsights key={item.title}>
<div className="animate-pulse">
<div className="h-4 w-24 rounded bg-gray-200" />
<div className="mt-4 flex items-baseline space-x-3">
<div className="h-8 w-16 rounded bg-gray-200" />
<ChartCard title={t("stats")}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{categories.map((item) => (
<div
key={item.title}
className={classNames(
"border-muted border-b p-4 last:border-b-0 sm:border-b-0 sm:border-r sm:last:border-r-0"
)}>
<div>
<SkeletonText className="mt-1 h-5 w-32" />
</div>
<div>
<SkeletonText className="h-6 w-12" />
</div>
</div>
</CardInsights>
))}
</Grid>
))}
</div>
</ChartCard>
);
};

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