Compare commits

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

* fix tests

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

* fix type errors

* Improve check

---------

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

* replaced the hardcoded OWNER string with the enum value

* replaced enum with hard coded OWNER value for safty

* added enum usage

* added admin lookout if owner validation fails

* chore

* chore

* fixed tests

* chore

* chore

* chore

* chore

---------

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

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

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

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

* chore: update ESLint dependencies to resolve configuration issues

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

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

* revert eslint change

* some changes

* refactor: implement discriminated union for segment types

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

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

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

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

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

* fix: correct SegmentIdentifier type to exclude undefined

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

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

* remove unused segment

* a little update

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

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

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

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

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

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

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

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

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

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

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

* remove unused segment

* some fixes

* revert schema change

* rename defaultSegmentId to systemSegmentId

* renaming

* fix save button

* remove as any

* clean up prisma migrations

* type fixes

* many fixes

* remove icon property

* fix infinite rendering

* fix race condition

* re-visiting useSegments implementation WIP

* extract useElementByClassName

* add e2e tests

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

* add useSegments to /insights and fix e2e test

* fix type error

* fix type error

* apply feedback

---------

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

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

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

* Update rateLimit.ts

* chore: Bump @unkey/ratelimit version

* Add required props to rateLimiter onError response

---------

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

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

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

* feat: update platform hooks to use v2 verification endpoints

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

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

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

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

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

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

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

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

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

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

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

* extract logic into seperate functions

* Update index.ts

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

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

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

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

* fix: add missing type imports to verification service

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

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

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

* added Throttle

* added chagelog

* chore: update platform libraries to 0.0.317

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

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

* Update verifyCode.handler.ts

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

---------

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

* added tests

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

* Fixing multi select query generation

* Added null check for eventType filters

* Updated test to support multiselect

* Type fix

---------

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

* export getOrgContext

* update RSC

* refactor

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

* fix tests

* fix type error

* update service usage

* fix attendee filters

* display attendee phone number

* use useInsightsRoutingParameters()

* remove unnecessary type check

* clean up

* fix type error

* fix wrong import

* fix integration tests

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

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

---------

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

* added changelog

* udpate

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

* add changesets

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

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

* remove wrong config

* add coderabbit instructions

* remove unnecessary paths

* add path instructions

* Fix duplicated instructions on yaml file

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

* Update apps/web/package.json

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

* Remove unused option prop

---------

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

* fix trpc function name

* chore: remove isTeamEvent logic

* chore: remove isTeamEvent at all

* revert wrong change

* Revert "chore: remove isTeamEvent at all"

This reverts commit 6dbc8c31f16c40a7654a05240661d7892aa48bb8.

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

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

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

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

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

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

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

* feat: clean up remaining references to removed report functionality

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

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

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

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

---------

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

* Only run Sentry in production for instrumentation-client

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2025-08-20 07:27:51 +00:00
Tim Neutkens 4ae5de5a3a Only apply autoprefixer in production (#23168) 2025-08-20 07:26:32 +00:00
131 changed files with 5367 additions and 3448 deletions
+187
View File
@@ -0,0 +1,187 @@
language: en-US
tone_instructions: ''
early_access: false
enable_free_tier: true
reviews:
profile: chill
request_changes_workflow: false
high_level_summary: false
high_level_summary_placeholder: '@coderabbitai summary'
high_level_summary_in_walkthrough: false
auto_title_placeholder: '@coderabbitai'
auto_title_instructions: ''
review_status: true
commit_status: true
fail_commit_status: false
collapse_walkthrough: false
changed_files_summary: false
sequence_diagrams: false
estimate_code_review_effort: false
assess_linked_issues: true
related_issues: false
related_prs: true
suggested_labels: false
auto_apply_labels: false
suggested_reviewers: false
auto_assign_reviewers: false
poem: false
labeling_instructions: []
path_filters:
- '!**/node_modules/**'
- '!**/dist/**'
- '!**/build/**'
- '!**/.next/**'
path_instructions:
- path: "**/*.{ts,tsx,js,jsx}"
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
abort_on_close: true
disable_cache: false
auto_review:
enabled: true
auto_incremental_review: true
ignore_title_keywords: []
labels: []
drafts: false
base_branches: []
finishing_touches:
docstrings:
enabled: true
unit_tests:
enabled: true
pre_merge_checks:
docstrings:
mode: warning
threshold: 80
title:
mode: warning
requirements: ''
description:
mode: warning
issue_assessment:
mode: warning
tools:
ast-grep:
rule_dirs: []
util_dirs: []
essential_rules: true
packages: []
shellcheck:
enabled: true
ruff:
enabled: true
markdownlint:
enabled: true
github-checks:
enabled: true
timeout_ms: 180000
languagetool:
enabled: true
enabled_rules: []
disabled_rules: []
enabled_categories: []
disabled_categories: []
enabled_only: false
level: default
biome:
enabled: true
hadolint:
enabled: true
swiftlint:
enabled: true
phpstan:
enabled: true
level: default
phpmd:
enabled: true
phpcs:
enabled: true
golangci-lint:
enabled: true
yamllint:
enabled: true
gitleaks:
enabled: true
checkov:
enabled: true
detekt:
enabled: true
eslint:
enabled: true
flake8:
enabled: true
rubocop:
enabled: true
buf:
enabled: true
regal:
enabled: true
actionlint:
enabled: true
pmd:
enabled: true
cppcheck:
enabled: true
semgrep:
enabled: true
circleci:
enabled: true
clippy:
enabled: true
sqlfluff:
enabled: true
prismaLint:
enabled: true
pylint:
enabled: true
oxc:
enabled: true
shopifyThemeCheck:
enabled: true
luacheck:
enabled: true
brakeman:
enabled: true
dotenvLint:
enabled: true
htmlhint:
enabled: true
checkmake:
enabled: true
osvScanner:
enabled: true
chat:
art: true
auto_reply: true
integrations:
jira:
usage: auto
linear:
usage: auto
knowledge_base:
opt_out: false
web_search:
enabled: true
code_guidelines:
enabled: true
filePatterns: []
learnings:
scope: auto
issues:
scope: auto
jira:
usage: auto
project_keys: []
linear:
usage: auto
team_keys: []
pull_requests:
scope: auto
mcp:
usage: auto
disabled_servers: []
code_generation:
docstrings:
language: en-US
path_instructions: []
unit_tests:
path_instructions: []
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
-7
View File
@@ -1,7 +0,0 @@
{
"functions": {
"pages/api/slots/*.ts": {
"memory": 512
}
}
}
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0", "@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*", "@calcom/platform-constants": "*",
"@calcom/platform-enums": "*", "@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.318", "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320",
"@calcom/platform-types": "*", "@calcom/platform-types": "*",
"@calcom/platform-utils": "*", "@calcom/platform-utils": "*",
"@calcom/prisma": "*", "@calcom/prisma": "*",
+10
View File
@@ -9,6 +9,7 @@ import { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing"; import { Test } from "@nestjs/testing";
import * as request from "supertest"; import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -33,6 +34,7 @@ describe("AppController", () => {
let organizationsRepositoryFixture: OrganizationRepositoryFixture; let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture; let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture; let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeyString: string; let apiKeyString: string;
@@ -108,6 +110,14 @@ describe("AppController", () => {
organization: { connect: { id: organization.id } }, organization: { connect: { id: organization.id } },
}); });
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
app = moduleRef.createNestApplication(); app = moduleRef.createNestApplication();
await app.init(); await app.init();
}); });
@@ -18,7 +18,6 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture"; import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture"; import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture"; import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString"; import { randomString } from "test/utils/randomString";
@@ -27,7 +26,6 @@ import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@cal
import { import {
CancelBookingInput_2024_08_13, CancelBookingInput_2024_08_13,
CancelSeatedBookingInput_2024_08_13, CancelSeatedBookingInput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13, CreateSeatedBookingOutput_2024_08_13,
GetBookingOutput_2024_08_13, GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13, GetBookingsOutput_2024_08_13,
@@ -35,11 +33,9 @@ import {
RescheduleSeatedBookingInput_2024_08_13, RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types"; } from "@calcom/platform-types";
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types"; import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; import { Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => { describe("Bookings Endpoints 2024-08-13", () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
describe("Seated bookings", () => { describe("Seated bookings", () => {
let app: INestApplication; let app: INestApplication;
let organization: Team; let organization: Team;
@@ -48,7 +44,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let bookingsRepositoryFixture: BookingsRepositoryFixture; let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15; let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture; let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture; let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture; let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
@@ -58,7 +53,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let apiKeyString: string; let apiKeyString: string;
let seatedEventTypeId: number; let seatedEventTypeId: number;
const maxRecurrenceCount = 3;
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`; const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
@@ -83,7 +77,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef); userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef); bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef); eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef); teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15); schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
@@ -190,6 +183,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false, absent: false,
seatUid, seatUid,
bookingFieldsResponses: { bookingFieldsResponses: {
guests: [],
name: body.attendee.name, name: body.attendee.name,
...body.bookingFieldsResponses, ...body.bookingFieldsResponses,
}, },
@@ -264,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false, absent: false,
seatUid: createdSeatedBooking.seatUid, seatUid: createdSeatedBooking.seatUid,
bookingFieldsResponses: { bookingFieldsResponses: {
guests: [],
name: createdSeatedBooking.attendees[0].name, name: createdSeatedBooking.attendees[0].name,
...createdSeatedBooking.attendees[0].bookingFieldsResponses, ...createdSeatedBooking.attendees[0].bookingFieldsResponses,
}, },
@@ -278,6 +273,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false, absent: false,
seatUid, seatUid,
bookingFieldsResponses: { bookingFieldsResponses: {
guests: [],
name: body.attendee.name, name: body.attendee.name,
...body.bookingFieldsResponses, ...body.bookingFieldsResponses,
}, },
@@ -517,6 +513,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false, absent: false,
seatUid, seatUid,
bookingFieldsResponses: { bookingFieldsResponses: {
guests: [],
name: body.attendee.name, name: body.attendee.name,
...body.bookingFieldsResponses, ...body.bookingFieldsResponses,
}, },
@@ -21,7 +21,12 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth"; import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants"; import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types"; import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client"; import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => { describe("Bookings Endpoints 2024-08-13", () => {
@@ -44,6 +49,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`; const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let normalEventType: EventType; let normalEventType: EventType;
const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`; const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let variableLengthRecurringEventType: EventType;
const VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG = `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`;
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await withApiAuth( const moduleRef = await withApiAuth(
@@ -105,6 +112,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
user.id user.id
); );
variableLengthRecurringEventType = await eventTypesRepositoryFixture.create(
{
title: `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`,
slug: VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG,
length: 15,
metadata: { multipleDuration: [15, 30, 60] },
recurringEvent: { freq: 2, count: 3, interval: 1 }, // Weekly, 3 times, every 1 week
},
user.id
);
app = moduleRef.createNestApplication(); app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication); bootstrap(app as NestExpressApplication);
@@ -246,6 +264,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
}); });
}); });
describe("create recurring bookings", () => {
it("should create recurring bookings with custom lengthInMinutes", async () => {
const lengthInMinutes = 30;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 1, 14, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes,
recurrenceCount: 2,
attendee: {
name: "Mr Recurring",
email: "mr_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: { status: string; data: RecurringBookingOutput_2024_08_13[] } = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data).toHaveLength(2);
const firstBooking = responseBody.data[0];
const secondBooking = responseBody.data[1];
expect(firstBooking.duration).toEqual(lengthInMinutes);
expect(secondBooking.duration).toEqual(lengthInMinutes);
});
});
it("should reject recurring booking with invalid lengthInMinutes", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 3, 1, 10, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes: 45, // Not in allowed [15, 30, 60]
recurrenceCount: 2,
attendee: {
name: "Mr Invalid",
email: "mr_invalid@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
});
afterAll(async () => { afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id); await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id); await teamRepositoryFixture.delete(organization.id);
@@ -420,6 +420,9 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`); throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
} }
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
const occurrence = recurringEventSchema.parse(eventType.recurringEvent); const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
const repeatsEvery = occurrence.interval; const repeatsEvery = occurrence.interval;
@@ -456,7 +459,7 @@ export class InputBookingsService_2024_08_13 {
const location = inputLocation ? this.transformLocation(inputLocation) : undefined; const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
for (let i = 0; i < repeatsTimes; i++) { for (let i = 0; i < repeatsTimes; i++) {
const endTime = startTime.plus({ minutes: eventType.length }); const endTime = startTime.plus({ minutes: lengthInMinutes });
events.push({ events.push({
start: startTime.toISO(), start: startTime.toISO(),
@@ -1,6 +1,5 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module"; import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard"; import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module"; import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module"; import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
@@ -4,10 +4,12 @@ import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/ato
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller"; import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller"; import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller"; import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
import { AtomsVerificationController } from "@/modules/atoms/controllers/atoms.verification.controller";
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service"; import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service"; import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service"; import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service"; import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository"; import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository"; import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module"; import { OrganizationsModule } from "@/modules/organizations/organizations.module";
@@ -32,6 +34,7 @@ import { Module } from "@nestjs/common";
AtomsRepository, AtomsRepository,
UsersService, UsersService,
SchedulesAtomsService, SchedulesAtomsService,
VerificationAtomsService,
RedisService, RedisService,
], ],
exports: [EventTypesAtomService], exports: [EventTypesAtomService],
@@ -40,6 +43,7 @@ import { Module } from "@nestjs/common";
AtomsEventTypesController, AtomsEventTypesController,
AtomsConferencingAppsController, AtomsConferencingAppsController,
AtomsSchedulesController, AtomsSchedulesController,
AtomsVerificationController,
], ],
}) })
export class AtomsModule {} export class AtomsModule {}
@@ -0,0 +1,108 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
Body,
UseGuards,
Version,
VERSION_NEUTRAL,
HttpCode,
HttpStatus,
Get,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - verification endpoints for atoms")
@DocsExcludeController(true)
export class AtomsVerificationController {
constructor(private readonly verificationService: VerificationAtomsService) {}
@Post("/verification/email/send-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "atoms_verification_email_send_code" })
async sendEmailVerificationCode(
@Body() body: SendVerificationEmailInput
): Promise<SendVerificationEmailOutput> {
const result = await this.verificationService.sendEmailVerificationCode({
email: body.email,
username: body.username,
language: body.language,
isVerifyingEmail: body.isVerifyingEmail,
});
return {
data: { sent: result.ok && !result.skipped },
status: SUCCESS_STATUS,
};
}
@Get("/verification/email/check")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async checkEmailVerificationRequired(
@Query() query: CheckEmailVerificationRequiredParams
): Promise<ApiResponse<boolean>> {
const required = await this.verificationService.checkEmailVerificationRequired({
email: query.email,
userSessionEmail: query.userSessionEmail,
});
return {
data: required,
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async verifyEmailCode(@Body() body: VerifyEmailCodeInput): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeUnAuthenticated({
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code-authenticated")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async verifyEmailCodeAuthenticated(
@Body() body: VerifyEmailCodeInput,
@GetUser() user: UserWithProfile
): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeAuthenticated(user, {
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString } from "class-validator";
export class CheckEmailVerificationRequiredParams {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "user@example.com" })
@IsOptional()
@IsString()
userSessionEmail?: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString, IsBoolean } from "class-validator";
export class SendVerificationEmailInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "johndoe" })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ example: "en" })
@IsOptional()
@IsString()
language?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isVerifyingEmail?: boolean;
}
@@ -0,0 +1,12 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsString } from "class-validator";
export class VerifyEmailCodeInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiProperty({ example: "123456" })
@IsString()
code!: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class SendVerificationEmailData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly sent!: boolean;
}
export class SendVerificationEmailOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => SendVerificationEmailData)
@Expose()
@ApiProperty({ type: SendVerificationEmailData })
readonly data!: SendVerificationEmailData;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class VerifyEmailCodeData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly verified!: boolean;
}
export class VerifyEmailCodeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => VerifyEmailCodeData)
@Expose()
@ApiProperty({ type: VerifyEmailCodeData })
readonly data!: VerifyEmailCodeData;
}
@@ -0,0 +1,64 @@
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
import {
verifyCodeUnAuthenticated,
verifyCodeAuthenticated,
sendEmailVerificationByCode,
checkEmailVerificationRequired,
} from "@calcom/platform-libraries";
@Injectable()
export class VerificationAtomsService {
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
return await checkEmailVerificationRequired(input);
}
async verifyEmailCodeUnAuthenticated(input: VerifyEmailCodeInput) {
try {
return await verifyCodeUnAuthenticated(input);
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email and code are required");
}
}
throw new BadRequestException("Verification failed");
}
}
async verifyEmailCodeAuthenticated(user: UserWithProfile, input: VerifyEmailCodeInput) {
try {
return await verifyCodeAuthenticated({
user,
email: input.email,
code: input.code,
});
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email, code, and user ID are required");
}
}
throw new UnauthorizedException("Verification failed");
}
}
async sendEmailVerificationCode(input: SendVerificationEmailInput) {
return await sendEmailVerificationByCode({
email: input.email,
username: input.username,
language: input.language,
isVerifyingEmail: input.isVerifyingEmail,
});
}
}
@@ -6,7 +6,6 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
import { DeploymentsModule } from "@/modules/deployments/deployments.module"; import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module"; import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { RedisModule } from "@/modules/redis/redis.module"; import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module"; import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module"; import { UsersModule } from "@/modules/users/users.module";
@@ -22,7 +21,6 @@ import { PassportModule } from "@nestjs/passport";
MembershipsModule, MembershipsModule,
TokensModule, TokensModule,
DeploymentsModule, DeploymentsModule,
ProfilesModule,
], ],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService], providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard], exports: [NextAuthGuard, ApiAuthGuard],
@@ -4,6 +4,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository"; import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service"; import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { JwtService } from "@/modules/jwt/jwt.service"; import { JwtService } from "@/modules/jwt/jwt.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service"; import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
@@ -20,6 +21,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client"; import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { createRequest } from "node-mocks-http"; import { createRequest } from "node-mocks-http";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture"; import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
@@ -45,6 +47,7 @@ describe("ApiAuthStrategy", () => {
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture; let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture; let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`; const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`; const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
@@ -65,6 +68,7 @@ describe("ApiAuthStrategy", () => {
}), }),
ProfilesModule, ProfilesModule,
TokensModule, TokensModule,
MembershipsModule,
], ],
providers: [ providers: [
MockedRedisService, MockedRedisService,
@@ -91,6 +95,7 @@ describe("ApiAuthStrategy", () => {
teamRepositoryFixture = new TeamRepositoryFixture(module); teamRepositoryFixture = new TeamRepositoryFixture(module);
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module); oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
profilesRepositoryFixture = new ProfileRepositoryFixture(module); profilesRepositoryFixture = new ProfileRepositoryFixture(module);
membershipRepositoryFixture = new MembershipRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` }); organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
organizationTwo = await teamRepositoryFixture.create({ organizationTwo = await teamRepositoryFixture.create({
name: `api-auth-organization-2-${randomString()}`, name: `api-auth-organization-2-${randomString()}`,
@@ -121,6 +126,20 @@ describe("ApiAuthStrategy", () => {
organization: { connect: { id: organizationTwo.id } }, organization: { connect: { id: organizationTwo.id } },
}); });
await membershipRepositoryFixture.create({
user: { connect: { id: validOAuthUser.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
await membershipRepositoryFixture.create({
user: { connect: { id: validOAuthUser.id } },
team: { connect: { id: organizationTwo.id } },
role: "OWNER",
accepted: true,
});
const data = { const data = {
logo: "logo-url", logo: "logo-url",
name: "name", name: "name",
@@ -4,9 +4,9 @@ import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types"; import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository"; import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service"; import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service"; import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository"; import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service"; import { TokensService } from "@/modules/tokens/tokens.service";
import { UsersService } from "@/modules/users/services/users.service"; import { UsersService } from "@/modules/users/services/users.service";
@@ -45,8 +45,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
private readonly userRepository: UsersRepository, private readonly userRepository: UsersRepository,
private readonly apiKeyRepository: ApiKeysRepository, private readonly apiKeyRepository: ApiKeysRepository,
private readonly oauthRepository: OAuthClientRepository, private readonly oauthRepository: OAuthClientRepository,
private readonly profilesRepository: ProfilesRepository, private readonly usersService: UsersService,
private readonly usersService: UsersService private readonly membershipsRepository: MembershipsRepository
) { ) {
super(); super();
} }
@@ -172,7 +172,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret"); throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
} }
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId); const platformCreatorId =
(await this.membershipsRepository.findPlatformOwnerUserId(client.organizationId)) ||
(await this.membershipsRepository.findPlatformAdminUserId(client.organizationId));
if (!platformCreatorId) { if (!platformCreatorId) {
throw new UnauthorizedException( throw new UnauthorizedException(
@@ -20,6 +20,36 @@ export class MembershipsRepository {
return membership; return membership;
} }
async findPlatformOwnerUserId(organizationId: number): Promise<number | undefined> {
const owner = await this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
role: "OWNER",
accepted: true,
},
select: {
userId: true,
},
});
return owner?.userId ?? undefined;
}
async findPlatformAdminUserId(organizationId: number): Promise<number | undefined> {
const admin = await this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
role: "ADMIN",
accepted: true,
},
select: {
userId: true,
},
});
return admin?.userId ?? undefined;
}
async findMembershipByTeamId(teamId: number, userId: number) { async findMembershipByTeamId(teamId: number, userId: number) {
const membership = await this.dbRead.prisma.membership.findUnique({ const membership = await this.dbRead.prisma.membership.findUnique({
where: { where: {
@@ -15,6 +15,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test, TestingModule } from "@nestjs/testing"; import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client"; import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest"; import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture"; import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -63,6 +64,7 @@ describe("OAuthFlow Endpoints", () => {
let organizationsRepositoryFixture: OrganizationRepositoryFixture; let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture; let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture; let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let user: User; let user: User;
let organization: Team; let organization: Team;
@@ -89,6 +91,7 @@ describe("OAuthFlow Endpoints", () => {
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef); usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await usersRepositoryFixtures.create({ user = await usersRepositoryFixtures.create({
email: userEmail, email: userEmail,
@@ -104,6 +107,13 @@ describe("OAuthFlow Endpoints", () => {
movedFromUser: { connect: { id: user.id } }, movedFromUser: { connect: { id: user.id } },
organization: { connect: { id: organization.id } }, organization: { connect: { id: organization.id } },
}); });
await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
oAuthClient = await createOAuthClient(organization.id); oAuthClient = await createOAuthClient(organization.id);
}); });
@@ -39,6 +39,7 @@ import {
HttpStatus, HttpStatus,
NotFoundException, NotFoundException,
Query, Query,
Logger,
} from "@nestjs/common"; } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger"; import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
@@ -66,6 +67,8 @@ export type EventTypeHandlerResponse = {
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER) @ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER) @ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsEventTypesController { export class OrganizationsEventTypesController {
private readonly logger = new Logger("OrganizationsEventTypesController");
constructor( constructor(
private readonly organizationsEventTypesService: OrganizationsEventTypesService, private readonly organizationsEventTypesService: OrganizationsEventTypesService,
private readonly inputService: InputOrganizationsEventTypesService, private readonly inputService: InputOrganizationsEventTypesService,
@@ -88,6 +91,10 @@ export class OrganizationsEventTypesController {
teamId, teamId,
bodyEventType bodyEventType
); );
this.logger.debug(
"nl debug - create org team event type - transformedBody",
JSON.stringify(transformedBody, null, 2)
);
const eventType = await this.organizationsEventTypesService.createOrganizationTeamEventType( const eventType = await this.organizationsEventTypesService.createOrganizationTeamEventType(
user, user,
@@ -47,6 +47,10 @@ export class OrganizationsEventTypesService {
prisma: this.dbWrite.prisma, prisma: this.dbWrite.prisma,
}, },
}); });
this.logger.debug(
"nl debug - create org team event type - eventTypeCreated",
JSON.stringify(eventTypeCreated, null, 2)
);
return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user, true); return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user, true);
} }
@@ -133,6 +133,7 @@ export class TeamsEventTypesService {
}); });
const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId); const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId);
this.logger.debug("nl debug - update team event type - eventType", JSON.stringify(eventType, null, 2));
if (!eventType) { if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`); throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
@@ -1,6 +1,6 @@
import { CustomI18nProvider } from "app/CustomI18nProvider"; import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr"; import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps, SearchParams } from "app/_types"; import type { PageProps, Params, SearchParams } from "app/_types";
import { generateMeetingMetadata } from "app/_utils"; import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers"; import { cookies, headers } from "next/headers";
@@ -15,20 +15,34 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import LegacyPage from "~/team/type-view"; import LegacyPage from "~/team/type-view";
import type { PageProps as LegacyPageProps } from "~/team/type-view"; import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData"; import CachedTeamBooker, {
generateMetadata as generateCachedMetadata,
getOrgContext,
} from "./pageWithCachedData";
import { getTeamId } from "./queries";
async function isCachedTeamBookingEnabled(params: Params, searchParams: SearchParams): Promise<boolean> {
if (searchParams.experimentalTeamBookingPageCache !== "true") return false;
const { teamSlug, currentOrgDomain, isValidOrgDomain } = await getOrgContext(params);
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
const teamId = await getTeamId(teamSlug, orgSlug);
if (!teamId) return false;
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository(prisma); const featuresRepository = new FeaturesRepository(prisma);
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally( const isTeamFeatureEnabled = await featuresRepository.checkIfTeamHasFeature(
teamId,
"team-booking-page-cache" "team-booking-page-cache"
); );
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true"; return isTeamFeatureEnabled;
} }
export const generateMetadata = async ({ params, searchParams }: PageProps) => { export const generateMetadata = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) { if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await generateCachedMetadata({ params, searchParams }); return await generateCachedMetadata({ params, searchParams });
} }
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams); const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx); const props = await getData(legacyCtx);
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props; const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
@@ -68,7 +82,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps); const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => { const ServerPage = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) { if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await CachedTeamBooker({ params, searchParams }); return await CachedTeamBooker({ params, searchParams });
} }
@@ -41,7 +41,7 @@ const _getTeamMetadataForBooking = (teamData: NonNullable<TeamData>, eventTypeId
}; };
}; };
async function _getOrgContext(params: Params) { export async function getOrgContext(params: Params) {
const result = paramsSchema.safeParse({ const result = paramsSchema.safeParse({
slug: params?.slug, slug: params?.slug,
type: params?.type, type: params?.type,
@@ -74,7 +74,7 @@ const _getMultipleDurationValue = (
}; };
export const generateMetadata = async ({ params, searchParams }: PageProps) => { export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params); const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain); const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
if (!teamData) return {}; // should never happen if (!teamData) return {}; // should never happen
@@ -123,7 +123,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
}; };
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => { const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params); const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams); const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
// Handle org redirects // Handle org redirects
@@ -13,6 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants"; import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage"; import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { UserRepository } from "@calcom/lib/server/repository/user"; import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums"; import type { SchedulingType } from "@calcom/prisma/enums";
@@ -158,3 +159,14 @@ export async function getCRMData(
crmRecordId, crmRecordId,
}; };
} }
export async function getTeamId(teamSlug: string, orgSlug: string | null): Promise<number | null> {
const teamRepo = new TeamRepository(prisma);
const team = await teamRepo.findFirstBySlugAndParentSlug({
slug: teamSlug,
parentSlug: orgSlug,
select: { id: true },
});
return team?.id ?? null;
}
@@ -1,9 +1,14 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir"; import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types"; import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils"; import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validStatuses } from "~/bookings/lib/validStatuses"; import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsList from "~/bookings/views/bookings-listing-view"; import BookingsList from "~/bookings/views/bookings-listing-view";
@@ -26,10 +31,11 @@ const Page = async ({ params }: PageProps) => {
redirect("/bookings/upcoming"); redirect("/bookings/upcoming");
} }
const t = await getTranslate(); const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
return ( return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}> <ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<BookingsList status={parsed.data.status} /> <BookingsList status={parsed.data.status} userId={session?.user?.id} />
</ShellMainAppDir> </ShellMainAppDir>
); );
}; };
+1 -1
View File
@@ -910,7 +910,7 @@ export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: Rerout
return ( return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}> <Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow preventCloseOnOutsideClick> <DialogContent preventCloseOnOutsideClick>
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} /> <DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
<RerouteDialogContentAndFooter <RerouteDialogContentAndFooter
booking={teamEventTypeBooking} booking={teamEventTypeBooking}
+39 -33
View File
@@ -3,42 +3,48 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/ // https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
Sentry.init({ if (process.env.NODE_ENV === "production") {
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT, Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0, sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control. // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0, tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled. // Define how likely Replay events are sampled.
// This sets the sample rate to be 10%. You may want this to be 100% while // This sets the sample rate to be 10%. You may want this to be 100% while
// in development and sample at a lower rate in production // in development and sample at a lower rate in production
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0, // replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled when an error occurs. // Define how likely Replay events are sampled when an error occurs.
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0, // replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
// Setting this option to true will print useful information to the console while you're setting up Sentry. // Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: !!process.env.SENTRY_DEBUG, debug: !!process.env.SENTRY_DEBUG,
beforeSend(event) { beforeSend(event) {
if ( if (
event.exception?.values?.some( event.exception?.values?.some(
(e) => (e) =>
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4" // Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440 // Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
e.value?.includes("Non-Error promise rejection captured with") || e.value?.includes("Non-Error promise rejection captured with") ||
e.value?.includes("Object Not Found Matching Id") e.value?.includes("Object Not Found Matching Id")
) )
) { ) {
return null; return null;
} }
event.tags = { event.tags = {
...event.tags, ...event.tags,
errorSource: "client", errorSource: "client",
}; };
return event; return event;
}, },
}); });
}
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart; export function onRouterTransitionStart(url: string, navigationType: "push" | "replace" | "traverse") {
if (process.env.NODE_ENV === "production") {
Sentry.captureRouterTransitionStart(url, navigationType);
}
}
+16 -7
View File
@@ -1,13 +1,22 @@
import { type Instrumentation } from 'next'
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
export async function register() { export async function register() {
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") { if (process.env.NODE_ENV === "production") {
await import("./sentry.server.config"); if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
} await import("./sentry.server.config");
}
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") { if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
await import("./sentry.edge.config"); await import("./sentry.edge.config");
}
} }
} }
export const onRequestError = Sentry.captureRequestError; export const onRequestError: Instrumentation.onRequestError = ( err,
request,
context) => {
if (process.env.NODE_ENV === "production") {
Sentry.captureRequestError(err, request, context);
}
};
+2
View File
@@ -17,6 +17,7 @@ export const getEventTypesFromDB = async (id: number) => {
darkBrandColor: true, darkBrandColor: true,
email: true, email: true,
timeZone: true, timeZone: true,
isPlatformManaged: true,
}; };
const eventType = await prisma.eventType.findUnique({ const eventType = await prisma.eventType.findUnique({
where: { where: {
@@ -75,6 +76,7 @@ export const getEventTypesFromDB = async (id: number) => {
hideBranding: true, hideBranding: true,
}, },
}, },
createdByOAuthClientId: true,
}, },
}, },
workflows: { workflows: {
-16
View File
@@ -60,22 +60,6 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req }); const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers); const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
if (!url.pathname.startsWith("/api")) {
//
// NOTE: When tRPC hits an error a 500 is returned, when this is received
// by the application the user is automatically redirected to /auth/login.
//
// - For this reason our matchers are sufficient for an app-wide maintenance page.
//
// Check whether the maintenance page should be shown
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isInMaintenanceMode) {
reqWithEnrichedHeaders.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(reqWithEnrichedHeaders.nextUrl);
}
}
const routingFormRewriteResponse = routingForms.handleRewrite(url); const routingFormRewriteResponse = routingForms.handleRewrite(url);
if (routingFormRewriteResponse) { if (routingFormRewriteResponse) {
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders }); return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table"; import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
import { useSearchParams } from "next/navigation"; import { useSearchParams, usePathname } from "next/navigation";
import { useMemo, useRef } from "react"; import { useMemo, useRef } from "react";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components"; import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
@@ -17,6 +17,7 @@ import {
ZMultiSelectFilterValue, ZMultiSelectFilterValue,
ZDateRangeFilterValue, ZDateRangeFilterValue,
ZTextFilterValue, ZTextFilterValue,
type SystemFilterSegment,
} from "@calcom/features/data-table"; } from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments"; import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -55,11 +56,45 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
type BookingsProps = { type BookingsProps = {
status: (typeof validStatuses)[number]; status: (typeof validStatuses)[number];
userId?: number;
}; };
function useSystemSegments(userId?: number) {
const { t } = useLocale();
const systemSegments: SystemFilterSegment[] = useMemo(() => {
if (!userId) return [];
return [
{
id: "my_bookings",
name: t("my_bookings"),
type: "system",
activeFilters: [
{
f: "userId",
v: {
type: ColumnFilterType.MULTI_SELECT,
data: [userId],
},
},
],
perPage: 10,
},
];
}, [userId, t]);
return systemSegments;
}
export default function Bookings(props: BookingsProps) { export default function Bookings(props: BookingsProps) {
const pathname = usePathname();
const systemSegments = useSystemSegments(props.userId);
return ( return (
<DataTableProvider useSegments={useSegments}> <DataTableProvider
useSegments={useSegments}
systemSegments={systemSegments}
tableIdentifier={pathname || undefined}>
<BookingsContent {...props} /> <BookingsContent {...props} />
</DataTableProvider> </DataTableProvider>
); );
@@ -228,16 +228,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name } ? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
: previousBooking; : previousBooking;
const isPlatformBooking = eventType.users[0]?.isPlatformManaged || eventType.team?.createdByOAuthClientId;
return { return {
props: { props: {
orgSlug: currentOrgDomain, orgSlug: currentOrgDomain,
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username, themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
hideBranding: await shouldHideBrandingForEvent({ hideBranding: isPlatformBooking
eventTypeId: eventType.id, ? true
team: eventType.team, : await shouldHideBrandingForEvent({
owner: eventType.users[0] ?? null, eventTypeId: eventType.id,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null, team: eventType.team,
}), owner: eventType.users[0] ?? null,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
}),
profile, profile,
eventType, eventType,
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId), recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
@@ -138,7 +138,8 @@ const OnboardingPage = (props: PageProps) => {
"dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand:#111827] dark:[--cal-brand:#FFFFFF]", "dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand:#111827] dark:[--cal-brand:#FFFFFF]",
"[--cal-brand-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1]", "[--cal-brand-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1]",
"[--cal-brand-subtle:#9CA3AF]", "[--cal-brand-subtle:#9CA3AF]",
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]" "[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]",
"[--cal-brand-accent:#FFFFFF] dark:[--cal-brand-accent:#000000]"
)} )}
data-testid="onboarding" data-testid="onboarding"
key={pathname}> key={pathname}>
+2 -1
View File
@@ -7,6 +7,7 @@ import {
ColumnFilterType, ColumnFilterType,
type FilterableColumn, type FilterableColumn,
} from "@calcom/features/data-table"; } from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { import {
AverageEventDurationChart, AverageEventDurationChart,
BookingKPICards, BookingKPICards,
@@ -31,7 +32,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
export default function InsightsPage({ timeZone }: { timeZone: string }) { export default function InsightsPage({ timeZone }: { timeZone: string }) {
return ( return (
<DataTableProvider timeZone={timeZone}> <DataTableProvider useSegments={useSegments} timeZone={timeZone}>
<InsightsOrgTeamsProvider> <InsightsOrgTeamsProvider>
<InsightsPageContent /> <InsightsPageContent />
</InsightsOrgTeamsProvider> </InsightsOrgTeamsProvider>
+490 -478
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "5.6.2", "version": "5.6.4",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@@ -29,7 +29,7 @@
"yarn": "3.4.1" "yarn": "3.4.1"
}, },
"dependencies": { "dependencies": {
"@boxyhq/saml-jackson": "1.42.0", "@boxyhq/saml-jackson": "1.52.2",
"@calcom/app-store": "*", "@calcom/app-store": "*",
"@calcom/app-store-cli": "*", "@calcom/app-store-cli": "*",
"@calcom/dayjs": "*", "@calcom/dayjs": "*",
@@ -77,7 +77,6 @@
"@tanstack/react-query": "^5.17.15", "@tanstack/react-query": "^5.17.15",
"@team-plain/typescript-sdk": "^5.9.0", "@team-plain/typescript-sdk": "^5.9.0",
"@types/turndown": "^5.0.1", "@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.35.2", "@upstash/redis": "^1.35.2",
"@vercel/edge-config": "^0.1.1", "@vercel/edge-config": "^0.1.1",
"@vercel/edge-functions-ui": "^0.2.1", "@vercel/edge-functions-ui": "^0.2.1",
@@ -1,6 +0,0 @@
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { highPerfRouter } from "@calcom/trpc/server/routers/viewer/highPerf/_router";
export default wrapApiHandlerWithSentry(createNextApiHandler(highPerfRouter), "/api/trpc/highPerf/[trpc]");
+69
View File
@@ -31,6 +31,7 @@ export async function applySelectFilter(page: Page, columnId: string, value: str
} }
export async function selectOptionValue(page: Page, columnId: string, value: string) { export async function selectOptionValue(page: Page, columnId: string, value: string) {
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).waitFor();
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click(); await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click();
} }
@@ -176,3 +177,71 @@ export async function listSegments(page: Page): Promise<string[]> {
export function locateSelectedSegmentName(page: Page, expectedName: string) { export function locateSelectedSegmentName(page: Page, expectedName: string) {
return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName }); return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName });
} }
/**
* Check if a system segment is visible in the dropdown
*/
export async function expectSystemSegmentVisible(page: Page, segmentName: string) {
await page.getByTestId("filter-segment-select").click();
await expect(
page.locator('[data-testid="filter-segment-select-content"]').getByText("Default")
).toBeVisible();
await expect(
page
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
.filter({ hasText: segmentName })
).toBeVisible();
await page.keyboard.press("Escape");
}
/**
* Check that no segment is currently selected
*/
export async function expectSegmentCleared(page: Page) {
// Check that no segment is selected (button shows default text)
const segmentSelect = page.getByTestId("filter-segment-select");
const buttonText = await segmentSelect.textContent();
expect(buttonText?.trim()).toEqual("Segment");
}
/**
* Check that a specific segment is currently selected
*/
export async function expectSegmentSelected(page: Page, segmentName: string) {
await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible();
}
/**
* Duplicate an existing segment
*/
export async function duplicateSegment(page: Page, originalName: string, newName: string) {
await openSegmentSubmenu(page, originalName);
await page.getByTestId("filter-segment-select-submenu-content").getByText("Duplicate").click();
await page.getByTestId("duplicate-segment-name").fill(newName);
await page.getByRole("button", { name: "Duplicate" }).click();
await expect(page.getByText("Filter segment duplicated")).toBeVisible();
await page.keyboard.press("Escape");
}
/**
* Rename an existing segment
*/
export async function renameSegment(page: Page, originalName: string, newName: string) {
await openSegmentSubmenu(page, originalName);
await page.getByTestId("filter-segment-select-submenu-content").getByText("Rename").click();
await page.getByTestId("rename-segment-name").fill(newName);
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Filter segment updated")).toBeVisible();
await page.keyboard.press("Escape");
}
/**
* Check if a segment group (like "Default" or "Personal") is visible
*/
export async function expectSegmentGroupVisible(page: Page, groupName: string) {
await page.getByTestId("filter-segment-select").click();
await expect(
page.locator('[data-testid="filter-segment-select-content"]').getByText(groupName)
).toBeVisible();
await page.keyboard.press("Escape");
}
+3 -17
View File
@@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
import { randomString } from "@calcom/lib/random"; import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
import { addFilter, openFilter, clearFilters } from "./filter-helpers"; import { clearFilters, applySelectFilter } from "./filter-helpers";
import { test } from "./lib/fixtures"; import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" }); test.describe.configure({ mode: "parallel" });
@@ -202,25 +202,11 @@ test.describe("Insights", async () => {
await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click(); await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
// Choose User filter item from dropdown await applySelectFilter(page, "userId", member.username || "");
await addFilter(page, "userId");
// 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, "userId");
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).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 clearFilters(page);
await expect(page.url()).not.toContain("userId"); await expect(page).not.toHaveURL(/[?&]userId=/);
}); });
test("should test download button", async ({ page, users }) => { test("should test download button", async ({ page, users }) => {
+120
View File
@@ -0,0 +1,120 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { addFilter, selectSegment, expectSegmentCleared, expectSegmentSelected } from "./filter-helpers";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
/**
* Navigate to bookings page with specific status
*/
async function navigateToBookings(page: Page, status = "upcoming") {
// Wait for the bookings API response like existing tests do
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/${status}`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
}
test.describe("System Segments", () => {
test.describe("Core Functionality", () => {
test("My Bookings system segment filters to current user's bookings only", async ({ page, users }) => {
const user1 = await users.create({ username: "user1" });
// Create some bookings for user (this would need booking setup)
// For now, we'll test the filter application
await user1.apiLogin();
await navigateToBookings(page);
await selectSegment(page, "My Bookings");
await expectSegmentSelected(page, "My Bookings");
// Verify the userId filter is applied (check URL or filter indicators)
const url = page.url();
expect(url).toContain("activeFilters");
});
test("System segments show only Duplicate option in submenu", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await navigateToBookings(page);
await page.getByTestId("filter-segment-select").click();
await page
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
.filter({ hasText: "My Bookings" })
.locator('[data-testid="filter-segment-select-submenu-button"]')
.click();
const submenu = page.getByTestId("filter-segment-select-submenu-content");
await expect(submenu.getByText("Duplicate")).toBeVisible();
await expect(submenu.getByText("Rename")).toBeHidden();
await expect(submenu.getByText("Delete")).toBeHidden();
});
});
test.describe("State Persistence", () => {
test("System segment selection persists across page visits", async ({ page, users }) => {
const owner = await users.create(undefined, { hasTeam: true });
await owner.apiLogin();
// Visit bookings page and select system segment
await navigateToBookings(page);
await selectSegment(page, "My Bookings");
await expectSegmentSelected(page, "My Bookings");
// Revisit the page - should preserve selection
await navigateToBookings(page);
await expectSegmentSelected(page, "My Bookings");
});
test("Cleared system segment state persists (no default selection after manual clear)", async ({
page,
users,
}) => {
const owner = await users.create(undefined, { hasTeam: true });
await owner.apiLogin();
await navigateToBookings(page);
// Select system segment
await selectSegment(page, "My Bookings");
await expectSegmentSelected(page, "My Bookings");
// Unselect system segment
await selectSegment(page, "My Bookings");
// Revisit the page - should NOT have any segment selected
await navigateToBookings(page);
await expectSegmentCleared(page);
});
});
test.describe("Auto-clear Behavior", () => {
test("Manual filter clears system segment selection", async ({ page, users }) => {
const user = await users.create(undefined, { hasTeam: true });
await user.apiLogin();
await navigateToBookings(page);
// Select system segment
await selectSegment(page, "My Bookings");
await expectSegmentSelected(page, "My Bookings");
// Apply manual filter - this should clear system segment
await addFilter(page, "eventTypeId");
await page.keyboard.press("Escape");
await expectSegmentCleared(page);
// Verify filter is still applied
await expect(page.getByTestId("filter-popover-trigger-eventTypeId")).toBeVisible();
});
});
});
+7 -2
View File
@@ -1,6 +1,11 @@
module.exports = { const config = {
plugins: { plugins: {
tailwindcss: {}, tailwindcss: {},
autoprefixer: {},
}, },
}; };
if (process.env.NODE_ENV === "production") {
config.plugins.autoprefixer = {};
}
module.exports = config;
@@ -3472,5 +3472,7 @@
"webhook_metadata": "Metadata", "webhook_metadata": "Metadata",
"stats": "Stats", "stats": "Stats",
"booking_status": "Booking status", "booking_status": "Booking status",
"my_bookings": "My Bookings",
"phone": "Phone",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
} }
-12
View File
@@ -41,18 +41,6 @@
}, },
"pages/api/trpc/workflows/[trpc].ts": { "pages/api/trpc/workflows/[trpc].ts": {
"maxDuration": 800 "maxDuration": 800
},
"pages/api/trpc/highPerf/[trpc].ts": {
"memory": 3008
},
"pages/api/trpc/appRoutingForms/[trpc].ts": {
"memory": 2048
},
"pages/router/embed.tsx": {
"memory": 2048
},
"pages/api/book/event.ts": {
"memory": 2048
} }
} }
} }
+1
View File
@@ -129,6 +129,7 @@ Below is a list of props that can be passed to the booker atom.
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. | | roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. | | showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled | | silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
| hideEventMetadata | No | Boolean that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information. Defaults to `false`. |
## Styling ## Styling
-1
View File
@@ -118,7 +118,6 @@
"@daily-co/daily-js": "^0.76.0", "@daily-co/daily-js": "^0.76.0",
"@evyweb/ioctopus": "^1.2.0", "@evyweb/ioctopus": "^1.2.0",
"@next/third-parties": "^14.2.5", "@next/third-parties": "^14.2.5",
"@unkey/ratelimit": "^2.1.2",
"@vercel/functions": "^1.4.0", "@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1", "city-timezones": "^1.2.1",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
@@ -10,6 +10,7 @@ import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cach
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache"; import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import { uniqueBy } from "@calcom/lib/array"; import { uniqueBy } from "@calcom/lib/array";
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify"; import { safeStringify } from "@calcom/lib/safeStringify";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
@@ -85,10 +86,16 @@ export default class GoogleCalendarService implements Calendar {
const selectedHostDestinationCalendar = event.destinationCalendar?.find( const selectedHostDestinationCalendar = event.destinationCalendar?.find(
(cal) => cal.credentialId === this.credential.id (cal) => cal.credentialId === this.credential.id
); );
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
.filter((domain) => domain.trim() !== "")
.some((domain) => event.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
const eventAttendees = event.attendees.map(({ id: _id, ...rest }) => ({ const eventAttendees = event.attendees.map(({ id: _id, ...rest }) => ({
...rest, ...rest,
responseStatus: "accepted", responseStatus: "accepted",
})); }));
const attendees: calendar_v3.Schema$EventAttendee[] = [ const attendees: calendar_v3.Schema$EventAttendee[] = [
{ {
...event.organizer, ...event.organizer,
@@ -100,7 +107,7 @@ export default class GoogleCalendarService implements Calendar {
// We use || instead of ?? here to handle empty strings // We use || instead of ?? here to handle empty strings
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email, email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
}, },
...eventAttendees, ...(event.hideOrganizerEmail && !isOrganizerExempt ? [] : eventAttendees),
]; ];
if (event.team?.members) { if (event.team?.members) {
+5 -2
View File
@@ -1,3 +1,6 @@
/**
* TODO: Consolidate this file with BookingLocationService and add tests
*/
import type { TFunction } from "i18next"; import type { TFunction } from "i18next";
import { z } from "zod"; import { z } from "zod";
@@ -58,7 +61,7 @@ export type EventLocationTypeFromApp = Ensure<
export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp; export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp;
export const DailyLocationType = "integrations:daily"; export const DailyLocationType = "integrations:daily";
export const CalVideoLocationType = DailyLocationType;
export const MeetLocationType = "integrations:google:meet"; export const MeetLocationType = "integrations:google:meet";
export const MSTeamsLocationType = "integrations:office365_video"; export const MSTeamsLocationType = "integrations:office365_video";
@@ -380,7 +383,7 @@ export const getLocationValueForDB = (
eventLocations: LocationObject[] eventLocations: LocationObject[]
) => { ) => {
let bookingLocation = bookingLocationTypeOrValue; let bookingLocation = bookingLocationTypeOrValue;
let conferenceCredentialId = undefined; let conferenceCredentialId: number | undefined = undefined;
eventLocations.forEach((location) => { eventLocations.forEach((location) => {
if (location.type === bookingLocationTypeOrValue) { if (location.type === bookingLocationTypeOrValue) {
@@ -1,363 +0,0 @@
import { describe, expect, it, afterEach, vi } from "vitest";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
afterEach(() => {
vi.resetAllMocks();
});
describe("jsonLogicToPrisma(Reporting)", () => {
describe("Text Operand", () => {
it("should support 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "A"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "A",
},
},
],
});
});
it("should support 'Not Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!=": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "abc",
},
},
},
],
});
});
it("should support 'Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ in: ["A", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "A",
},
},
],
});
});
it("should support 'Not Contains' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!": { in: ["a", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] } }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
string_contains: "a",
},
},
},
],
});
});
describe("Number Type", () => {
it("should support 'greater than' operator", () => {
let prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
">": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
});
prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
">": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// A number would also work
100,
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
});
});
it("should support 'greater than or equal to' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
">=": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gte: 100 } }],
});
});
it("should support 'less than' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
"<": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lt: 100 } }],
});
});
it("should support 'less than or equal to' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{
"<=": [
{
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
},
// Giving a string here to test that it is converted to a number
"100",
],
},
],
},
});
expect(prismaWhere).toEqual({
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lte: 100 } }],
});
});
it("'Equals' operator should query with string as well as number", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "1"] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
OR: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "1",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: 1,
},
},
],
},
],
});
});
});
});
describe("MultiSelect", () => {
it("should support 'Equals' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{ all: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, { in: [{ var: "" }, ["C", "D"]] }] },
],
},
});
expect(prismaWhere).toEqual({
AND: [
{
response: { path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"], array_contains: ["C", "D"] },
},
],
});
});
it("should support 'Any In' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ in: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, ["C", "D"]] }] },
});
expect(prismaWhere).toEqual({
AND: [
{
OR: [
{
response: {
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
equals: "C",
},
},
{
response: {
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
equals: "D",
},
},
],
},
],
});
});
it("should support 'Not In' operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: { and: [{ "!": { in: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, ["C", "D"]] } }] },
});
expect(prismaWhere).toEqual({
AND: [
{
NOT: {
OR: [
{
response: {
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
equals: "C",
},
},
{
response: {
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
equals: "D",
},
},
],
},
},
],
});
});
it("should support where All Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
and: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
AND: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where Any Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
],
},
});
expect(prismaWhere).toEqual({
OR: [
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "a",
},
},
{
response: {
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
equals: "b",
},
},
],
});
});
it("should support where None Match ['Equals', 'Equals'] operator", () => {
const prismaWhere = jsonLogicToPrisma({
logic: {
"!": {
or: [
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] },
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abcd"] },
],
},
},
});
expect(prismaWhere).toEqual({
NOT: {
OR: [
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abc" } },
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abcd" } },
],
},
});
});
});
});
@@ -1,276 +0,0 @@
// It can have many shapes, so just use any and we rely on unit tests to test all those scenarios.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type LogicData = Partial<Record<keyof typeof OPERATOR_MAP, any>>;
type NegatedLogicData = {
"!": LogicData;
};
export type JsonLogicQuery = {
logic: {
and?: LogicData[];
or?: LogicData[];
"!"?: {
and?: LogicData[];
or?: LogicData[];
};
} | null;
};
type PrismaWhere = {
AND?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
OR?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
NOT?: PrismaWhere;
};
const OPERATOR_MAP = {
"==": {
operator: "equals",
secondaryOperand: null,
},
in: {
operator: "string_contains",
secondaryOperand: null,
},
"!=": {
operator: "NOT.equals",
secondaryOperand: null,
},
"!": {
operator: "equals",
secondaryOperand: "",
},
"!!": {
operator: "NOT.equals",
secondaryOperand: "",
},
">": {
operator: "gt",
secondaryOperand: null,
},
">=": {
operator: "gte",
secondaryOperand: null,
},
"<": {
operator: "lt",
secondaryOperand: null,
},
"<=": {
operator: "lte",
secondaryOperand: null,
},
all: {
operator: "array_contains",
secondaryOperand: null,
},
};
/**
* Operators supported on array of basic queries
*/
const GROUP_OPERATOR_MAP = {
and: "AND",
or: "OR",
"!": "NOT",
} as const;
const NumberOperators = [">", ">=", "<", "<="];
const negatePrismaWhereClauseIfNeeded = (data: any, isNegation: boolean) => {
if (!isNegation) {
return data;
}
return {
NOT: {
...data,
},
};
};
const convertSingleQueryToPrismaWhereClause = (
operatorName: keyof typeof OPERATOR_MAP,
logicData: LogicData,
isNegation: boolean
) => {
const mappedOperator = OPERATOR_MAP[operatorName].operator;
const staticSecondaryOperand = OPERATOR_MAP[operatorName].secondaryOperand;
isNegation = isNegation || mappedOperator.startsWith("NOT.");
const prismaOperator = mappedOperator.replace("NOT.", "");
const operands =
logicData[operatorName] instanceof Array ? logicData[operatorName] : [logicData[operatorName]];
if (operatorName === "in" && operands[0]?.var && Array.isArray(operands[1])) {
// Case A: Item "in" array
// operands[0]: { var: ... }
// operands[1]: items to test against
// Convert 'in' operator to union of OR clauses
return negatePrismaWhereClauseIfNeeded(
{
OR: (operands[1] ?? []).map((value) => ({
response: {
path: [operands[0].var, "value"],
[`${OPERATOR_MAP["=="].operator}`]: value,
},
})),
},
isNegation
);
}
if (operatorName === "in" && typeof operands[0] === "string" && operands[1]?.var) {
// Case B: String "in" string
// operands[0]: string to test against
// operands[1]: { var: ... }
return negatePrismaWhereClauseIfNeeded(
{
response: {
path: [operands[1].var, "value"],
[`${prismaOperator}`]: operands[0],
},
},
isNegation
);
}
const mainOperand = operands[0].var;
let secondaryOperand;
if (operatorName === "all") {
secondaryOperand = operands[1].in[1];
} else {
secondaryOperand = staticSecondaryOperand || operands[1] || "";
}
const isNumberOperator = NumberOperators.includes(operatorName);
const secondaryOperandAsNumber = typeof secondaryOperand === "string" ? Number(secondaryOperand) : null;
if (secondaryOperandAsNumber && isNumberOperator) {
// We know that it's number operator so Prisma should query number
// Note that if we get string values in DB(e.g. '100'), those values can't be filtered with number operators.
return negatePrismaWhereClauseIfNeeded(
{
response: {
path: [mainOperand, "value"],
[`${prismaOperator}`]: secondaryOperandAsNumber,
},
},
isNegation
);
}
if (secondaryOperandAsNumber && !isNumberOperator) {
// We know that it's not number operator but the input field might have been a number and thus stored value in DB as number.
// Also, even for input type=number we might accidentally get string value(e.g. '100'). So, let reporting do it's best job with both number and string.
return negatePrismaWhereClauseIfNeeded(
{
OR: [
{
response: {
path: [mainOperand, "value"],
// Query as string e.g. equals '100'
[`${prismaOperator}`]: secondaryOperand,
},
},
{
response: {
path: [mainOperand, "value"],
// Query as number e.g. equals 100
[`${prismaOperator}`]: secondaryOperandAsNumber,
},
},
],
},
isNegation
);
}
return negatePrismaWhereClauseIfNeeded(
{
response: {
path: [mainOperand, "value"],
[`${prismaOperator}`]: secondaryOperand,
},
},
isNegation
);
};
const isNegation = (logicData: LogicData | NegatedLogicData) => {
if ("!" in logicData) {
const negatedLogicData = logicData["!"];
for (const [operatorName] of Object.entries(OPERATOR_MAP)) {
if (negatedLogicData[operatorName]) {
return true;
}
}
}
return false;
};
const convertQueriesToPrismaWhereClause = (logicData: LogicData) => {
const _isNegation = isNegation(logicData);
if (_isNegation) {
logicData = logicData["!"];
}
for (const [key] of Object.entries(OPERATOR_MAP)) {
const operatorName = key as keyof typeof OPERATOR_MAP;
if (logicData[operatorName]) {
return convertSingleQueryToPrismaWhereClause(operatorName, logicData, _isNegation);
}
}
};
export const jsonLogicToPrisma = (query: JsonLogicQuery) => {
try {
let logic = query.logic;
if (!logic) {
return {};
}
let prismaWhere: PrismaWhere = {};
let negateLogic = false;
// Case: Negation of "Any of these"
// Example: {"logic":{"!":{"or":[{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]},{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]}]}}}
if (logic["!"]) {
logic = logic["!"];
negateLogic = true;
}
// Case: All of these
if (logic.and) {
const where: PrismaWhere["AND"] = (prismaWhere[GROUP_OPERATOR_MAP["and"]] = []);
logic.and.forEach((and) => {
const res = convertQueriesToPrismaWhereClause(and);
if (!res) {
return;
}
where.push(res);
});
}
// Case: Any of these
else if (logic.or) {
const where: PrismaWhere["OR"] = (prismaWhere[GROUP_OPERATOR_MAP["or"]] = []);
logic.or.forEach((or) => {
const res = convertQueriesToPrismaWhereClause(or);
if (!res) {
return;
}
where.push(res);
});
}
if (negateLogic) {
prismaWhere = { NOT: { ...prismaWhere } };
}
return prismaWhere;
} catch (e) {
console.log("Error converting to prisma `where`", JSON.stringify(query), "Error is ", e);
return {};
}
};
@@ -84,7 +84,7 @@ export function getQueryBuilderConfigForFormFields(form: Pick<RoutingForm, "fiel
delete initialConfigCopy.operators.is_empty; delete initialConfigCopy.operators.is_empty;
delete initialConfigCopy.operators.is_not_empty; delete initialConfigCopy.operators.is_not_empty;
// Between and Not between aren't directly supported by prisma. So, we need to update jsonLogicToPrisma to generate gte and lte query for between. It can be implemented later. // These operators can be implemented later if needed.
delete initialConfigCopy.operators.between; delete initialConfigCopy.operators.between;
delete initialConfigCopy.operators.not_between; delete initialConfigCopy.operators.not_between;
@@ -9,7 +9,6 @@ import { ZFormQueryInputSchema } from "./formQuery.schema";
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema"; import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
import { ZGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema"; import { ZGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
import { forms } from "./procedures/forms"; import { forms } from "./procedures/forms";
import { ZReportInputSchema } from "./report.schema";
import { ZSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema"; import { ZSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema";
// eslint-disable-next-line @typescript-eslint/ban-types // eslint-disable-next-line @typescript-eslint/ban-types
@@ -76,11 +75,6 @@ const appRoutingForms = router({
return handler({ ctx, input }); return handler({ ctx, input });
}), }),
report: authedProcedure.input(ZReportInputSchema).query(async ({ ctx, input }) => {
const handler = await getHandler("report", () => import("./report.handler"));
return handler({ ctx, input });
}),
getAttributesForTeam: authedProcedure getAttributesForTeam: authedProcedure
.input(ZGetAttributesForTeamInputSchema) .input(ZGetAttributesForTeamInputSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -1,158 +0,0 @@
import { v4 as uuidv4 } from "uuid";
import { describe, it, expect } from "vitest";
import { buildResponsesForReporting } from "./report.handler";
describe("buildResponsesForReporting", () => {
it("for fields with options, it should return the labels of the options", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: "value1" },
[field2Id]: { value: ["option1", "option2"] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
],
},
];
const expectedResponses = [["value1", "Option 1, Option 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1", "Field 2"]);
});
it("for fields with options having null id, it should return what DB provided(as that would be labels only).", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: "value1" },
[field2Id]: { value: ["Option 1", "Option 2"] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
options: [
{ id: null, label: "Option 1" },
{ id: null, label: "Option 2" },
],
},
];
const expectedResponses = [["value1", "Option 1, Option 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1", "Field 2"]);
});
it("for fields with no options but having array value, it should return the value as is", () => {
const field2Id = uuidv4();
const responsesFromDb = [
{
[field2Id]: { value: ["Option 1", "Option 2"] },
},
];
const fields = [
{
id: field2Id,
label: "Field 2",
},
];
const expectedResponses = [["Option 1, Option 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 2"]);
});
it("should correctly handle numeric responses converting them to strings", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: 1 },
[field2Id]: { value: [1, 2] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
options: [
{ id: null, label: "Option 1" },
{ id: null, label: "Option 2" },
],
},
];
const expectedResponses = [["1", "1, 2"]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1", "Field 2"]);
});
it("should handle empty responses", () => {
const field1Id = uuidv4();
const responsesFromDb = [{}];
const fields = [{ id: field1Id, label: "Field 1" }];
const expectedResponses = [[""]];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(["Field 1"]);
});
it("should show correct header for deleted fields", () => {
const field1Id = uuidv4();
const field2Id = uuidv4();
const responsesFromDb = [
{
[field1Id]: { value: "value1" },
[field2Id]: { value: ["Option 1", "Option 2"] },
},
];
const fields = [
{ id: field1Id, label: "Field 1" },
{
id: field2Id,
label: "Field 2",
deleted: true,
options: [
{ id: "option1", label: "Option 1" },
{ id: "option2", label: "Option 2" },
],
},
];
const expectedResponses = [["value1", "Option 1, Option 2"]];
const expectedHeaders = ["Field 1", "Field 2(Deleted)"];
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
expect(responses).toEqual(expectedResponses);
expect(headers).toEqual(expectedHeaders);
});
});
@@ -1,182 +0,0 @@
import type { z } from "zod";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
import type { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
import { getSerializableForm } from "../lib/getSerializableForm";
import { ensureStringOrStringArray, getLabelsFromOptionIds } from "../lib/reportingUtils";
import type { FormResponse } from "../types/types";
import type { zodFieldView } from "../zod";
import type { TReportInputSchema } from "./report.schema";
interface ReportHandlerOptions {
ctx: {
prisma: PrismaClient;
user: NonNullable<TrpcSessionUser>;
};
input: TReportInputSchema;
}
const makeFormatDate = (locale: string, timeZone: string) => {
const formatDate = (date: Date): string => {
return new Intl.DateTimeFormat(locale, {
timeZone,
dateStyle: "medium",
timeStyle: "short",
}).format(date);
};
return formatDate;
};
const getRows = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
// Can be any prisma `where` clause
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const prismaWhere: Record<string, any> = input.jsonLogicQuery
? jsonLogicToPrisma(input.jsonLogicQuery)
: {};
const skip = input.cursor ?? 0;
const take = input.limit ? input.limit + 1 : 50;
logger.debug(
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
input.jsonLogicQuery
)}`
);
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
where: {
formId: input.formId,
...prismaWhere,
},
include: {
routedToBooking: {
select: {
createdAt: true,
user: {
select: { id: true, name: true, email: true },
},
assignmentReason: {
orderBy: {
createdAt: "desc",
},
take: 1,
},
},
},
},
orderBy: {
createdAt: "desc",
},
take,
skip,
});
return { skip, take, rows };
};
export const reportHandler = async (options: ReportHandlerOptions) => {
const {
ctx: { prisma },
input,
} = options;
const form = await prisma.app_RoutingForms_Form.findUnique({
where: {
id: input.formId,
},
});
if (!form) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Form not found",
});
}
// TODO: Second argument is required to return deleted operators.
const serializedForm = await getSerializableForm({ form, withDeletedFields: true });
const { rows, skip, take } = await getRows(options);
const fields = serializedForm?.fields || [];
const { responses, headers } = buildResponsesForReporting({
responsesFromDb: rows.map((r) => r.response),
fields,
});
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
return presenter({
rows,
options,
headers,
responses,
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
});
};
/**
* This is a temporary solution to make the report work. It should be incorporated into the report data itself.
* Right now we cannot filter by Routed To and Booked At because they are not part of the response data.
*/
function presenter(args: {
rows: Awaited<ReturnType<typeof getRows>>["rows"];
options: ReportHandlerOptions;
headers: string[];
responses: string[][];
nextCursor: number | null;
}) {
const { headers, responses, nextCursor, options, rows } = args;
const { ctx } = options;
const formatDate = makeFormatDate(ctx.user.locale, ctx.user.timeZone);
return {
nextCursor,
headers: [...headers, "Routed To", "Assignment Reason", "Booked At", "Submitted At"],
responses: responses.map((r, i) => {
const currentRow = rows[i];
return [
...r,
currentRow.routedToBooking?.user?.email || "",
currentRow.routedToBooking?.assignmentReason.length
? currentRow.routedToBooking.assignmentReason[0].reasonString
: "",
currentRow.routedToBooking?.createdAt ? formatDate(currentRow.routedToBooking.createdAt) : "",
formatDate(currentRow.createdAt),
];
}),
};
}
export default reportHandler;
export function buildResponsesForReporting({
responsesFromDb,
fields,
}: {
responsesFromDb: App_RoutingForms_FormResponse["response"][];
fields: Pick<z.infer<typeof zodFieldView>, "id" | "options" | "label" | "deleted">[];
}) {
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
const responses: string[][] = [];
responsesFromDb.forEach((r) => {
const rowResponses: string[] = [];
responses.push(rowResponses);
fields.forEach((field) => {
if (!r) {
return;
}
const response = r as FormResponse;
const value = response[field.id]?.value || "";
if (field.options) {
const optionIds = ensureStringOrStringArray(value);
const labels = getLabelsFromOptionIds({ options: field.options, optionIds });
rowResponses.push(labels.join(", "));
} else {
const arrayOfValues = value instanceof Array ? value : [value];
const transformedValue = arrayOfValues.join(", ");
rowResponses.push(transformedValue);
}
});
});
return { responses, headers };
}
@@ -1,12 +0,0 @@
import z from "zod";
export const ZReportInputSchema = z.object({
limit: z.number().default(50),
formId: z.string(),
jsonLogicQuery: z.object({
logic: z.union([z.record(z.any()), z.null()]),
}),
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
});
export type TReportInputSchema = z.infer<typeof ZReportInputSchema>;
+2 -2
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next"; import type { NextApiRequest } from "next";
import { stringify } from "querystring"; import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { defaultHandler } from "@calcom/lib/server/defaultHandler"; import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder"; import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
const params = { const params = {
response_type: "code", response_type: "code",
client_id, client_id,
redirect_uri: `${WEBAPP_URL}/api/integrations/${config.slug}/callback`, redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`,
scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration" scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration"
state: "", state: "",
}; };
+2 -2
View File
@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl"; import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
@@ -17,7 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/ /** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/${config.slug}/callback`); const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`);
const params = new URLSearchParams([ const params = new URLSearchParams([
["grant_type", "authorization_code"], ["grant_type", "authorization_code"],
["client_id", client_id], ["client_id", client_id],
+19 -17
View File
@@ -404,23 +404,25 @@ const BookerComponent = ({
src={orgBannerUrl} src={orgBannerUrl}
/> />
)} )}
<EventMeta {!hideEventTypeDetails && (
selectedTimeslot={selectedTimeslot} <EventMeta
classNames={{ selectedTimeslot={selectedTimeslot}
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer, classNames={{
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle, eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
eventMetaTimezoneSelect: eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect, eventMetaTimezoneSelect:
}} customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
event={event.data} }}
isPending={event.isPending} event={event.data}
isPlatform={isPlatform} isPending={event.isPending}
isPrivateLink={!!hashedLink} isPlatform={isPlatform}
locale={userLocale} isPrivateLink={!!hashedLink}
timeZones={timeZones} locale={userLocale}
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}> timeZones={timeZones}
{eventMetaChildren} roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
</EventMeta> {eventMetaChildren}
</EventMeta>
)}
{layout !== BookerLayouts.MONTH_VIEW && {layout !== BookerLayouts.MONTH_VIEW &&
!(layout === "mobile" && bookerState === "booking") && ( !(layout === "mobile" && bookerState === "booking") && (
<div className="mt-auto px-5 py-3"> <div className="mt-auto px-5 py-3">
@@ -13,6 +13,7 @@ export const getCalEventResponses = ({
bookingFields, bookingFields,
booking, booking,
responses, responses,
seatsEnabled,
}: { }: {
// If the eventType has been deleted and a booking is Accepted later on, then bookingFields will be null and we can't know the label of fields. So, we should store the label as well in the DB // If the eventType has been deleted and a booking is Accepted later on, then bookingFields will be null and we can't know the label of fields. So, we should store the label as well in the DB
// Also, it is no longer straightforward to identify if a field is system field or not // Also, it is no longer straightforward to identify if a field is system field or not
@@ -32,6 +33,7 @@ export const getCalEventResponses = ({
}; };
}>; }>;
responses?: z.infer<typeof bookingResponsesDbSchema>; responses?: z.infer<typeof bookingResponsesDbSchema>;
seatsEnabled?: boolean;
}) => { }) => {
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>; const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>; const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
@@ -61,8 +63,7 @@ export const getCalEventResponses = ({
throw new Error(`Missing label for booking field "${field.name}"`); throw new Error(`Missing label for booking field "${field.name}"`);
} }
// If the guests field is hidden (disableGuests is set on the event type) don't try and infer guests from attendees list if (field.name == "guests" && !!seatsEnabled) {
if (field.name == "guests" && field.hidden) {
backwardCompatibleResponses[field.name] = []; backwardCompatibleResponses[field.name] = [];
} }
@@ -1919,7 +1919,7 @@ async function handler(
if (!isDryRun) { if (!isDryRun) {
sendRoundRobinRescheduledEmailsAndSMS( sendRoundRobinRescheduledEmailsAndSMS(
copyEventAdditionalInfo, { ...copyEventAdditionalInfo, iCalUID },
rescheduledMembers, rescheduledMembers,
eventType.metadata eventType.metadata
); );
@@ -64,6 +64,7 @@ const _getBookingData = async <T extends z.ZodType>({
getCalEventResponses({ getCalEventResponses({
bookingFields: eventType.bookingFields, bookingFields: eventType.bookingFields,
responses, responses,
seatsEnabled: !!eventType.seatsPerTimeSlot,
}); });
return { return {
...parsedBody, ...parsedBody,
@@ -3,9 +3,13 @@
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table"; import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import debounce from "lodash/debounce"; import debounce from "lodash/debounce";
// eslint-disable-next-line no-restricted-imports
import isEqual from "lodash/isEqual";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useQueryState } from "nuqs"; import { useQueryState } from "nuqs";
import { createContext, useCallback, useEffect, useRef, useMemo } from "react"; import { useState, createContext, useCallback, useEffect, useRef, useMemo } from "react";
import { useElementByClassName } from "@calcom/lib/hooks/useElementByClassName";
import { useSegmentsNoop } from "./hooks/useSegmentsNoop"; import { useSegmentsNoop } from "./hooks/useSegmentsNoop";
import { import {
@@ -22,8 +26,12 @@ import {
import { import {
type FilterValue, type FilterValue,
type FilterSegmentOutput, type FilterSegmentOutput,
type SystemFilterSegment,
type CombinedFilterSegment,
type SegmentIdentifier,
type ActiveFilters, type ActiveFilters,
type UseSegments, type UseSegments,
SYSTEM_SEGMENT_PREFIX,
} from "./lib/types"; } from "./lib/types";
import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils"; import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils";
@@ -55,10 +63,10 @@ export type DataTableContextType = {
offset: number; offset: number;
limit: number; limit: number;
segments: FilterSegmentOutput[]; segments: CombinedFilterSegment[];
selectedSegment: FilterSegmentOutput | undefined; selectedSegment: CombinedFilterSegment | undefined;
segmentId: number | undefined; segmentId: SegmentIdentifier | null;
setSegmentId: (id: number | null) => void; setSegmentId: (id: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => void;
canSaveSegment: boolean; canSaveSegment: boolean;
isSegmentEnabled: boolean; isSegmentEnabled: boolean;
@@ -78,7 +86,8 @@ interface DataTableProviderProps {
defaultPageSize?: number; defaultPageSize?: number;
segments?: FilterSegmentOutput[]; segments?: FilterSegmentOutput[];
timeZone?: string; timeZone?: string;
preferredSegmentId?: number | null; preferredSegmentId?: SegmentIdentifier | null;
systemSegments?: SystemFilterSegment[];
} }
export function DataTableProvider({ export function DataTableProvider({
@@ -90,8 +99,18 @@ export function DataTableProvider({
segments: providedSegments, segments: providedSegments,
timeZone, timeZone,
preferredSegmentId, preferredSegmentId,
systemSegments,
}: DataTableProviderProps) { }: DataTableProviderProps) {
const pathname = usePathname() as string | null;
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
if (!tableIdentifier) {
throw new Error("tableIdentifier is required");
}
const filterToOpen = useRef<string | undefined>(undefined); const filterToOpen = useRef<string | undefined>(undefined);
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser); const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
const [sorting, setSorting] = useQueryState("sorting", sortingParser); const [sorting, setSorting] = useQueryState("sorting", sortingParser);
const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>( const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>(
@@ -99,34 +118,172 @@ export function DataTableProvider({
columnVisibilityParser columnVisibilityParser
); );
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser); const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
const [segmentId, setSegmentId] = useQueryState( const initialSegmentId = useMemo(
"segment", () => (preferredSegmentId ? String(preferredSegmentId.id) : null),
segmentIdParser.withDefault(preferredSegmentId ?? -1) [preferredSegmentId]
); );
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser); const [segmentId, _setSegmentId] = useQueryState(
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser); "segment",
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser); initialSegmentId ? segmentIdParser.withDefault(initialSegmentId) : segmentIdParser
);
const {
segments,
preferredSegmentId: fetchedPreferredSegmentId,
setPreference: setSegmentPreference,
isSegmentEnabled,
isSuccess: isSegmentFetchedSuccessfully,
} = useSegments({
tableIdentifier,
providedSegments,
systemSegments,
});
const findSelectedSegment = useCallback(
(segmentId: string) => {
return segments.find((segment) => {
if (
segment.type === "system" &&
segmentId &&
segmentId.startsWith(SYSTEM_SEGMENT_PREFIX) &&
segment.id === segmentId
) {
return true;
} else if (segment.type === "user") {
const segmentIdNumber = parseInt(segmentId, 10);
return segment.id === segmentIdNumber;
}
});
},
[segments]
);
const segmentIdObject = useMemo(() => {
if (segmentId && segmentId.startsWith(SYSTEM_SEGMENT_PREFIX)) {
return {
id: segmentId,
type: "system" as const,
};
} else {
const segmentIdNumber = parseInt(segmentId, 10);
if (!Number.isNaN(segmentIdNumber)) {
return {
id: segmentIdNumber,
type: "user" as const,
};
}
}
return null;
}, [segmentId]);
const [selectedSegment, setSelectedSegment] = useState<CombinedFilterSegment | undefined>(
findSelectedSegment(segmentId)
);
const setSegmentId = useCallback(
(segmentId: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => {
if (!segmentId) {
_setSegmentId(null);
setSelectedSegment(undefined);
setSegmentPreference({
tableIdentifier,
segmentId: null,
});
return;
}
const segment = providedSegment || findSelectedSegment(String(segmentId.id));
if (!segment) {
// If segmentId is invalid (or not found), clear the segmentId from the query params,
// but we still keep all the other states like activeFilters, etc.
// This is useful when someone shares a URL that is inaccessible to someone else.
_setSegmentId(null);
setSelectedSegment(undefined);
setSegmentPreference({
tableIdentifier,
segmentId: null,
});
return;
}
_setSegmentId(String(segmentId.id));
setSelectedSegment(segment);
setSegmentPreference({
tableIdentifier,
segmentId,
});
// apply the segment
setActiveFilters(segment.activeFilters);
if (segment.sorting) {
setSorting(segment.sorting);
}
if (segment.columnVisibility) {
setColumnVisibility(segment.columnVisibility);
}
if (segment.columnSizing) {
setColumnSizing(segment.columnSizing);
}
if (segment.perPage !== undefined) {
setPageSize(segment.perPage);
}
if (segment.searchTerm !== undefined) {
setSearchTerm(segment.searchTerm);
}
setPageIndex(0);
},
[
_setSegmentId,
setSelectedSegment,
setSegmentPreference,
tableIdentifier,
findSelectedSegment,
setActiveFilters,
setSorting,
setColumnVisibility,
setColumnSizing,
setPageSize,
setSearchTerm,
setPageIndex,
]
);
useEffect(() => {
if (!isSegmentFetchedSuccessfully) {
return;
}
// If the preferred segment id has been fetched
// and no segment id has been selected yet,
// then we set it.
if (fetchedPreferredSegmentId && !segmentId) {
setSegmentId(fetchedPreferredSegmentId);
} else if (segmentId) {
setSelectedSegment(findSelectedSegment(segmentId));
}
// We intentionally have only `isSegmentFetchedSuccessfully`
// in the dependency array.
}, [isSegmentFetchedSuccessfully]);
const clearSystemSegmentSelectionIfExists = useCallback(() => {
if (selectedSegment?.type === "system") {
setSegmentId(null);
}
}, [selectedSegment, setSegmentId]);
const setDebouncedSearchTerm = useMemo( const setDebouncedSearchTerm = useMemo(
() => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500), () => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500),
[setSearchTerm] [setSearchTerm]
); );
const pathname = usePathname() as string | null;
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
if (!tableIdentifier) {
throw new Error("tableIdentifier is required");
}
const addFilter = useCallback( const addFilter = useCallback(
(columnId: string) => { (columnId: string) => {
if (!activeFilters?.some((filter) => filter.f === columnId)) { if (!activeFilters?.some((filter) => filter.f === columnId)) {
// do not reset the page to 0 here, // do not reset the page to 0 here,
// because we don't have the filter value yet (`v: undefined`) // because we don't have the filter value yet (`v: undefined`)
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]); setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
clearSystemSegmentSelectionIfExists();
} }
}, },
[activeFilters, setActiveFilters] [activeFilters, setActiveFilters, clearSystemSegmentSelectionIfExists]
); );
const setPageIndexWrapper = useCallback( const setPageIndexWrapper = useCallback(
@@ -151,8 +308,9 @@ export function DataTableProvider({
} }
return newFilters; return newFilters;
}); });
clearSystemSegmentSelectionIfExists();
}, },
[setActiveFilters, setPageIndex] [setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
); );
const removeFilter = useCallback( const removeFilter = useCallback(
@@ -162,61 +320,72 @@ export function DataTableProvider({
const remainingFilters = prev.filter((filter) => filter.f !== columnId); const remainingFilters = prev.filter((filter) => filter.f !== columnId);
return remainingFilters.length === 0 ? null : remainingFilters; return remainingFilters.length === 0 ? null : remainingFilters;
}); });
clearSystemSegmentSelectionIfExists();
}, },
[setActiveFilters, setPageIndex] [setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
); );
const setPageSizeAndGoToFirstPage = useCallback( const setPageSizeAndGoToFirstPage = useCallback(
(newPageSize: number | null) => { (newPageSize: number | null) => {
setPageSize(newPageSize === defaultPageSize ? null : newPageSize); setPageSize(newPageSize === defaultPageSize ? null : newPageSize);
setPageIndex(null); setPageIndex(null);
clearSystemSegmentSelectionIfExists();
}, },
[setPageSize, setPageIndex, defaultPageSize] [setPageSize, setPageIndex, defaultPageSize, clearSystemSegmentSelectionIfExists]
);
const { segments, selectedSegment, canSaveSegment, setAndPersistSegmentId, isSegmentEnabled } = useSegments(
{
tableIdentifier,
activeFilters,
sorting,
columnVisibility,
columnSizing,
pageSize,
searchTerm,
defaultPageSize,
segmentId,
setSegmentId,
setActiveFilters,
setSorting,
setColumnVisibility,
setColumnSizing,
setPageSize,
setPageIndex,
setSearchTerm,
segments: providedSegments,
preferredSegmentId,
}
); );
const clearAll = useCallback( const clearAll = useCallback(
(exclude?: string[]) => { (exclude?: string[]) => {
setAndPersistSegmentId(null); setSegmentId(null);
setPageIndex(null); setPageIndex(null);
setActiveFilters((prev) => { setActiveFilters((prev) => {
const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f)); const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f));
return remainingFilters.length === 0 ? null : remainingFilters; return remainingFilters.length === 0 ? null : remainingFilters;
}); });
}, },
[setActiveFilters, setPageIndex, setAndPersistSegmentId] [setActiveFilters, setPageIndex, setSegmentId]
); );
const ctaContainerRef = useRef<HTMLDivElement | null>(null); // Check if current state differs from selected segment
useEffect(() => { const hasStateChanged = useMemo(() => {
if (ctaContainerClassName) { if (!selectedSegment) return false;
const element = document.getElementsByClassName(ctaContainerClassName)[0] as HTMLDivElement;
ctaContainerRef.current = element; return (
!isEqual(activeFilters, selectedSegment.activeFilters) ||
!isEqual(sorting, selectedSegment.sorting) ||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
!isEqual(pageSize, selectedSegment.perPage) ||
!isEqual(searchTerm || "", selectedSegment.searchTerm || "")
);
}, [selectedSegment, activeFilters, sorting, columnVisibility, columnSizing, pageSize, searchTerm]);
const canSaveSegment = useMemo(() => {
if (selectedSegment) {
return hasStateChanged;
} }
}, [ctaContainerClassName]); // if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
return (
activeFilters.length > 0 ||
sorting.length > 0 ||
Object.keys(columnVisibility).length > 0 ||
Object.keys(columnSizing).length > 0 ||
pageSize !== defaultPageSize ||
searchTerm?.length > 0
);
}, [
selectedSegment,
activeFilters,
sorting,
columnVisibility,
columnSizing,
pageSize,
searchTerm,
defaultPageSize,
hasStateChanged,
]);
const ctaContainerRef = useElementByClassName<HTMLDivElement>(ctaContainerClassName);
return ( return (
<DataTableContext.Provider <DataTableContext.Provider
@@ -243,8 +412,8 @@ export function DataTableProvider({
offset: pageIndex * pageSize, offset: pageIndex * pageSize,
segments, segments,
selectedSegment, selectedSegment,
segmentId: segmentId || undefined, segmentId: segmentIdObject,
setSegmentId: setAndPersistSegmentId, setSegmentId,
canSaveSegment, canSaveSegment,
isSegmentEnabled, isSegmentEnabled,
searchTerm, searchTerm,
@@ -21,7 +21,7 @@ export function DeleteSegmentDialog({
onSuccess: ({ id }) => { onSuccess: ({ id }) => {
utils.viewer.filterSegments.list.invalidate(); utils.viewer.filterSegments.list.invalidate();
showToast(t("filter_segment_deleted"), "success"); showToast(t("filter_segment_deleted"), "success");
if (segmentId === id) { if (segmentId && segmentId.type === "user" && segmentId.id === id) {
setSegmentId(null); setSegmentId(null);
} }
onClose(); onClose();
@@ -10,7 +10,7 @@ import { Form, TextField } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast"; import { showToast } from "@calcom/ui/components/toast";
import { useDataTable } from "../../hooks"; import { useDataTable } from "../../hooks";
import type { FilterSegmentOutput } from "../../lib/types"; import type { CombinedFilterSegment } from "../../lib/types";
type FormValues = { type FormValues = {
name: string; name: string;
@@ -20,7 +20,7 @@ export function DuplicateSegmentDialog({
segment, segment,
onClose, onClose,
}: { }: {
segment: FilterSegmentOutput; segment: CombinedFilterSegment;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useLocale(); const { t } = useLocale();
@@ -38,7 +38,7 @@ export function DuplicateSegmentDialog({
onSuccess: ({ id }) => { onSuccess: ({ id }) => {
utils.viewer.filterSegments.list.invalidate(); utils.viewer.filterSegments.list.invalidate();
showToast(t("filter_segment_duplicated"), "success"); showToast(t("filter_segment_duplicated"), "success");
setSegmentId(id); setSegmentId({ id, type: "user" });
onClose(); onClose();
}, },
onError: () => { onError: () => {
@@ -50,16 +50,24 @@ export function DuplicateSegmentDialog({
if (!segment) { if (!segment) {
return; return;
} }
const { id: _id, name: _name, team: _team, teamId, ...rest } = segment; if (segment.type === "user") {
const { type: _type, id: _id, name: _name, team: _team, teamId, ...rest } = segment;
if (segment.scope === "TEAM" && isAdminOrOwner) { if (segment.scope === "TEAM" && isAdminOrOwner) {
createSegment({ createSegment({
...rest, ...rest,
teamId: teamId ?? 0, teamId: teamId ?? 0,
scope: "TEAM", scope: "TEAM",
name: data.name, name: data.name,
}); });
} else { } else {
createSegment({
...rest,
scope: "USER",
name: data.name,
});
}
} else if (segment.type === "system") {
const { type: _type, ...rest } = segment;
createSegment({ createSegment({
...rest, ...rest,
scope: "USER", scope: "USER",
@@ -80,7 +88,13 @@ export function DuplicateSegmentDialog({
<DialogHeader title={t("duplicate_segment")} /> <DialogHeader title={t("duplicate_segment")} />
<Form form={form} handleSubmit={handleSubmit}> <Form form={form} handleSubmit={handleSubmit}>
<div className="space-y-4"> <div className="space-y-4">
<TextField required type="text" label={t("name")} {...form.register("name")} /> <TextField
required
data-testid="duplicate-segment-name"
type="text"
label={t("name")}
{...form.register("name")}
/>
<DialogFooter> <DialogFooter>
<Button color="minimal" onClick={onClose}> <Button color="minimal" onClick={onClose}>
{t("cancel")} {t("cancel")}
@@ -16,7 +16,12 @@ import {
import { Icon, type IconName } from "@calcom/ui/components/icon"; import { Icon, type IconName } from "@calcom/ui/components/icon";
import { useDataTable } from "../../hooks"; import { useDataTable } from "../../hooks";
import type { FilterSegmentOutput } from "../../lib/types"; import type {
FilterSegmentOutput,
CombinedFilterSegment,
SystemFilterSegmentInternal,
UserFilterSegment,
} from "../../lib/types";
import { DeleteSegmentDialog } from "./DeleteSegmentDialog"; import { DeleteSegmentDialog } from "./DeleteSegmentDialog";
import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog"; import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog";
import { RenameSegmentDialog } from "./RenameSegmentDialog"; import { RenameSegmentDialog } from "./RenameSegmentDialog";
@@ -24,23 +29,33 @@ import { RenameSegmentDialog } from "./RenameSegmentDialog";
type SubmenuItem = { type SubmenuItem = {
iconName: IconName; iconName: IconName;
labelKey: string; labelKey: string;
onClick: (segment: FilterSegmentOutput) => void; onClick: (segment: CombinedFilterSegment) => void;
isDestructive?: boolean; isDestructive?: boolean;
adminOnly: boolean; adminOnly: boolean;
enabledForSystemSegment?: boolean;
}; };
export function FilterSegmentSelect() { export function FilterSegmentSelect() {
const { t } = useLocale(); const { t } = useLocale();
const session = useSession();
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
const { segments, selectedSegment, segmentId, setSegmentId, isSegmentEnabled } = useDataTable(); const { segments, selectedSegment, segmentId, setSegmentId, isSegmentEnabled } = useDataTable();
const [segmentToRename, setSegmentToRename] = useState<FilterSegmentOutput | undefined>(); const [segmentToRename, setSegmentToRename] = useState<FilterSegmentOutput | undefined>();
const [segmentToDuplicate, setSegmentToDuplicate] = useState<FilterSegmentOutput | undefined>(); const [segmentToDuplicate, setSegmentToDuplicate] = useState<CombinedFilterSegment | undefined>();
const [segmentToDelete, setSegmentToDelete] = useState<FilterSegmentOutput | undefined>(); const [segmentToDelete, setSegmentToDelete] = useState<FilterSegmentOutput | undefined>();
const submenuItems: SubmenuItem[] = [ const submenuItems: SubmenuItem[] = [
{ {
iconName: "square-pen", iconName: "square-pen",
labelKey: "rename", labelKey: "rename",
onClick: (segment) => setSegmentToRename(segment), onClick: (segment) => {
if (segment.type === "system") {
setSegmentToRename(undefined);
} else {
const { type: _type, ...rest } = segment;
setSegmentToRename(rest);
}
},
adminOnly: true, adminOnly: true,
}, },
{ {
@@ -48,55 +63,79 @@ export function FilterSegmentSelect() {
labelKey: "duplicate", labelKey: "duplicate",
onClick: (segment) => setSegmentToDuplicate(segment), onClick: (segment) => setSegmentToDuplicate(segment),
adminOnly: false, adminOnly: false,
enabledForSystemSegment: true,
}, },
{ {
iconName: "trash-2", iconName: "trash-2",
labelKey: "delete", labelKey: "delete",
onClick: (segment) => setSegmentToDelete(segment), onClick: (segment) => {
if (segment.type === "system") {
setSegmentToDelete(undefined);
} else {
const { type: _type, ...rest } = segment;
setSegmentToDelete(rest);
}
},
isDestructive: true, isDestructive: true,
adminOnly: true, adminOnly: true,
}, },
]; ];
const segmentGroups = useMemo(() => { const segmentGroups = useMemo(() => {
const sortFn = (a: FilterSegmentOutput, b: FilterSegmentOutput) => a.name.localeCompare(b.name); const sortFn = (a: CombinedFilterSegment, b: CombinedFilterSegment) => a.name.localeCompare(b.name);
const personalSegments = segments?.filter((segment) => !segment.team) || []; const systemSegments =
segments?.filter((s): s is SystemFilterSegmentInternal => s.type === "system") || [];
const personalSegments =
segments?.filter((s): s is UserFilterSegment => s.type === "user" && !s.team) || [];
const teamSegments = const teamSegments =
segments?.filter( segments?.filter(
(segment): segment is FilterSegmentOutput & { team: NonNullable<FilterSegmentOutput["team"]> } => (s): s is UserFilterSegment & { team: NonNullable<FilterSegmentOutput["team"]> } =>
segment.team !== null s.type === "user" && s.team !== null
) || []; ) || [];
// Group team segments by team name const groups = [];
const teamSegmentsByTeam = teamSegments.reduce<{ [teamName: string]: FilterSegmentOutput[] }>(
(acc, segment) => {
const teamName = segment.team.name;
if (!acc[teamName]) {
acc[teamName] = [];
}
acc[teamName].push(segment);
return acc;
},
{}
);
return [ if (systemSegments.length > 0) {
...(personalSegments.length > 0 groups.push({
? [ label: t("default"),
{ segments: systemSegments.sort(sortFn),
label: t("personal"), isSystem: true,
segments: personalSegments.sort(sortFn), });
}, }
]
: []), // Personal segments
if (personalSegments.length > 0) {
groups.push({
label: t("personal"),
segments: personalSegments.sort(sortFn),
isSystem: false,
});
}
// Team segments (existing grouping logic)
const teamSegmentsByTeam = teamSegments.reduce<{
[teamName: string]: UserFilterSegment[];
}>((acc, segment) => {
const teamName = segment.team!.name;
if (!acc[teamName]) {
acc[teamName] = [];
}
acc[teamName].push(segment);
return acc;
}, {});
groups.push(
...Object.entries(teamSegmentsByTeam) ...Object.entries(teamSegmentsByTeam)
.map(([teamName, segments]) => ({ .map(([teamName, segments]) => ({
label: teamName, label: teamName,
segments: segments.sort(sortFn), segments: segments.sort(sortFn),
isSystem: false,
})) }))
.sort((a, b) => a.label.localeCompare(b.label)), .sort((a, b) => a.label.localeCompare(b.label))
]; );
return groups;
}, [segments, t]); }, [segments, t]);
if (!isSegmentEnabled) { if (!isSegmentEnabled) {
@@ -128,22 +167,43 @@ export function FilterSegmentSelect() {
{group.label && ( {group.label && (
<DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel> <DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel>
)} )}
{group.segments.map((segment) => ( {group.segments.map((segment) => {
<DropdownItemWithSubmenu let items: SubmenuItem[] = [];
key={segment.id} if (segment.type === "system") {
submenuItems={submenuItems} items = submenuItems.filter((item) => item.enabledForSystemSegment);
segment={segment} } else if (segment.type === "user" && segment.scope === "USER") {
onSelect={() => { items = submenuItems;
if (segmentId === segment.id) { } else if (segment.type === "user" && segment.scope === "TEAM") {
setSegmentId(null); items = submenuItems.filter(
} else { (item) =>
setSegmentId(segment.id); // Team segments: show if not admin-only or if user is admin/owner
} isAdminOrOwner || !item.adminOnly
}}> );
{segment.id === segmentId && <Icon name="check" className="ml-3 h-4 w-4" />} }
<span className="ml-3">{segment.name}</span>
</DropdownItemWithSubmenu> return (
))} <DropdownItemWithSubmenu
key={segment.id}
submenuItems={items}
segment={segment}
onSelect={() => {
if (segmentId && segmentId.type === segment.type && segmentId.id === segment.id) {
setSegmentId(null);
} else {
if (segment.type === "system") {
setSegmentId({ id: segment.id, type: "system" });
} else {
setSegmentId({ id: segment.id, type: "user" });
}
}
}}>
{segmentId && segmentId.type === segment.type && segmentId.id === segment.id && (
<Icon name="check" className="ml-3 h-4 w-4" />
)}
<span className="ml-3">{segment.name}</span>
</DropdownItemWithSubmenu>
);
})}
</div> </div>
))} ))}
</DropdownMenuContent> </DropdownMenuContent>
@@ -175,25 +235,12 @@ function DropdownItemWithSubmenu({
children, children,
}: { }: {
onSelect: () => void; onSelect: () => void;
segment: FilterSegmentOutput; segment: CombinedFilterSegment;
submenuItems: SubmenuItem[]; submenuItems: SubmenuItem[];
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { t } = useLocale(); const { t } = useLocale();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const session = useSession();
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
// Filter submenu items based on segment type and user role
const filteredSubmenuItems = submenuItems.filter((item) => {
if (!segment.team) {
// Personal segments: show all actions
return true;
}
// Team segments: show if not admin-only or if user is admin/owner
return !item.adminOnly || isAdminOrOwner;
});
return ( return (
<DropdownMenuItem className="cursor-pointer" onSelect={onSelect}> <DropdownMenuItem className="cursor-pointer" onSelect={onSelect}>
@@ -220,7 +267,7 @@ function DropdownItemWithSubmenu({
side="right" side="right"
sideOffset={8} sideOffset={8}
data-testid="filter-segment-select-submenu-content"> data-testid="filter-segment-select-submenu-content">
{filteredSubmenuItems.map((item, index) => ( {submenuItems.map((item, index) => (
<DropdownMenuItem key={index}> <DropdownMenuItem key={index}>
<DropdownItem <DropdownItem
color={item.isDestructive ? "destructive" : undefined} color={item.isDestructive ? "destructive" : undefined}
@@ -71,7 +71,13 @@ export function RenameSegmentDialog({
<DialogHeader title={t("rename_segment")} /> <DialogHeader title={t("rename_segment")} />
<Form form={form} handleSubmit={handleSubmit}> <Form form={form} handleSubmit={handleSubmit}>
<div className="space-y-4"> <div className="space-y-4">
<TextField required type="text" label={t("name")} {...form.register("name")} /> <TextField
required
data-testid="rename-segment-name"
type="text"
label={t("name")}
{...form.register("name")}
/>
<DialogFooter> <DialogFooter>
<Button type="submit" loading={isPending}> <Button type="submit" loading={isPending}>
{t("save")} {t("save")}
@@ -57,7 +57,7 @@ export function SaveFilterSegmentButton() {
} = useDataTable(); } = useDataTable();
const [saveMode, setSaveMode] = useState<"create" | "update">(() => const [saveMode, setSaveMode] = useState<"create" | "update">(() =>
selectedSegment ? "update" : "create" selectedSegment && selectedSegment.type === "user" ? "update" : "create"
); );
// When the dialog is not open, // When the dialog is not open,
@@ -66,16 +66,16 @@ export function SaveFilterSegmentButton() {
if (isOpen) { if (isOpen) {
return; return;
} }
setSaveMode(selectedSegment ? "update" : "create"); setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
}, [selectedSegment, isOpen]); }, [selectedSegment, isOpen]);
const { data: teams } = trpc.viewer.teams.list.useQuery(); const { data: teams } = trpc.viewer.teams.list.useQuery();
const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({ const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({
onSuccess: ({ id }) => { onSuccess: (segment) => {
utils.viewer.filterSegments.list.invalidate(); utils.viewer.filterSegments.list.invalidate();
showToast(t("filter_segment_saved"), "success"); showToast(t("filter_segment_saved"), "success");
setSegmentId(id); setSegmentId({ id: segment.id, type: "user" }, { type: "user", ...segment });
setIsOpen(false); setIsOpen(false);
}, },
onError: () => { onError: () => {
@@ -110,12 +110,7 @@ export function SaveFilterSegmentButton() {
searchTerm, searchTerm,
}; };
if (saveMode === "update") { if (saveMode === "update" && selectedSegment && selectedSegment.type === "user") {
if (!selectedSegment) {
// theoretically this should never happen
showToast(t("segment_not_found"), "error");
return;
}
const scope = selectedSegment.scope; const scope = selectedSegment.scope;
if (scope === "TEAM") { if (scope === "TEAM") {
updateSegment({ updateSegment({
@@ -156,7 +151,7 @@ export function SaveFilterSegmentButton() {
// Reset form state when dialog closes // Reset form state when dialog closes
setIsTeamSegment(false); setIsTeamSegment(false);
setSelectedTeamId(undefined); setSelectedTeamId(undefined);
setSaveMode(selectedSegment ? "update" : "create"); setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
form.reset(); form.reset();
} }
setIsOpen(open); setIsOpen(open);
@@ -184,7 +179,7 @@ export function SaveFilterSegmentButton() {
<DialogContent data-testid="save-filter-segment-dialog"> <DialogContent data-testid="save-filter-segment-dialog">
<DialogHeader title={t("save_segment")} /> <DialogHeader title={t("save_segment")} />
<Form form={form} handleSubmit={onSubmit}> <Form form={form} handleSubmit={onSubmit}>
{selectedSegment ? ( {selectedSegment && selectedSegment.type === "user" ? (
<div className="mb-4"> <div className="mb-4">
<RadioGroup <RadioGroup
defaultValue="update" defaultValue="update"
+32 -128
View File
@@ -1,35 +1,13 @@
// eslint-disable-next-line no-restricted-imports import { useMemo } from "react";
import { isEqual } from "lodash";
import { useCallback, useMemo, useEffect } from "react";
import { trpc } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react";
import { recalculateDateRange } from "../lib/dateRange"; import { recalculateDateRange } from "../lib/dateRange";
import { type UseSegments } from "../lib/types"; import { type UseSegments, SYSTEM_SEGMENT_PREFIX } from "../lib/types";
import { isDateRangeFilterValue } from "../lib/utils"; import { isDateRangeFilterValue } from "../lib/utils";
export const useSegments: UseSegments = ({ export const useSegments: UseSegments = ({ tableIdentifier, providedSegments, systemSegments }) => {
tableIdentifier, const { data: rawSegments, isSuccess } = trpc.viewer.filterSegments.list.useQuery(
activeFilters,
sorting,
columnVisibility,
columnSizing,
pageSize,
searchTerm,
defaultPageSize,
segmentId,
setSegmentId,
setActiveFilters,
setSorting,
setColumnVisibility,
setColumnSizing,
setPageSize,
setPageIndex,
setSearchTerm,
segments: providedSegments,
preferredSegmentId,
}) => {
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery(
{ {
tableIdentifier, tableIdentifier,
}, },
@@ -37,16 +15,19 @@ export const useSegments: UseSegments = ({
enabled: !providedSegments, // Only fetch if segments are not provided enabled: !providedSegments, // Only fetch if segments are not provided
} }
); );
const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation(); const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation();
// Recalculate date ranges based on the current timestamp const preferredSegmentId = useMemo(() => rawSegments?.preferredSegmentId || null, [rawSegments]);
const segments = useMemo(() => {
const segmentsSource = providedSegments || rawSegments?.segments;
if (!segmentsSource) return [];
return segmentsSource.map((segment) => ({ const segments = useMemo(() => {
const userSegments = providedSegments || rawSegments?.segments || [];
const processedSystemSegments = (systemSegments || []).map((segment) => ({
perPage: 10,
tableIdentifier,
...segment, ...segment,
id: `${SYSTEM_SEGMENT_PREFIX}${segment.id}`,
type: "system" as const,
activeFilters: segment.activeFilters.map((filter) => { activeFilters: segment.activeFilters.map((filter) => {
if (isDateRangeFilterValue(filter.v)) { if (isDateRangeFilterValue(filter.v)) {
return { return {
@@ -57,106 +38,29 @@ export const useSegments: UseSegments = ({
return filter; return filter;
}), }),
})); }));
}, [rawSegments, providedSegments]);
const selectedSegment = useMemo(() => { const processedUserSegments = userSegments.map((segment) => ({
return segments?.find((segment) => segment.id === segmentId); ...segment,
}, [segments, segmentId]); type: "user" as const,
activeFilters: segment.activeFilters.map((filter) => {
if (isDateRangeFilterValue(filter.v)) {
return {
...filter,
v: recalculateDateRange(filter.v),
};
}
return filter;
}),
}));
useEffect(() => { return [...processedSystemSegments, ...processedUserSegments];
if (segments && segmentId > 0 && !isFetchingSegments) { }, [rawSegments, providedSegments, systemSegments, tableIdentifier]);
const segment = segments.find((segment) => segment.id === segmentId);
if (!segment) {
// If segmentId is invalid (or not found), clear the segmentId from the query params,
// but we still keep all the other states like activeFilters, etc.
// This is useful when someone shares a URL that is inaccessible to someone else.
setSegmentId(null);
}
}
}, [segments, segmentId, setSegmentId, isFetchingSegments]);
const memoizedPreferredSegmentId = useMemo(
() => preferredSegmentId ?? rawSegments?.preferredSegmentId,
[preferredSegmentId, rawSegments]
);
useEffect(() => {
if (memoizedPreferredSegmentId) {
setSegmentId(memoizedPreferredSegmentId);
}
}, [memoizedPreferredSegmentId, setSegmentId]);
useEffect(() => {
if (selectedSegment) {
// segment is selected, so we apply the filters, sorting, etc. from the segment
setActiveFilters(selectedSegment.activeFilters);
setSorting(selectedSegment.sorting);
setColumnVisibility(selectedSegment.columnVisibility);
setColumnSizing(selectedSegment.columnSizing);
setPageSize(selectedSegment.perPage);
setSearchTerm(selectedSegment.searchTerm);
setPageIndex(0);
}
}, [
selectedSegment,
setActiveFilters,
setSorting,
setColumnVisibility,
setColumnSizing,
setPageSize,
setPageIndex,
setSearchTerm,
]);
const canSaveSegment = useMemo(() => {
if (!selectedSegment) {
// if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
return (
activeFilters.length > 0 ||
sorting.length > 0 ||
Object.keys(columnVisibility).length > 0 ||
Object.keys(columnSizing).length > 0 ||
pageSize !== defaultPageSize ||
searchTerm?.length > 0
);
} else {
// if a segment is selected, we can save the segment if the active filters, sorting, etc. are different from the segment
return (
!isEqual(activeFilters, selectedSegment.activeFilters) ||
!isEqual(sorting, selectedSegment.sorting) ||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
!isEqual(pageSize, selectedSegment.perPage) ||
!isEqual(searchTerm, selectedSegment.searchTerm)
);
}
}, [
selectedSegment,
activeFilters,
sorting,
columnVisibility,
columnSizing,
pageSize,
searchTerm,
defaultPageSize,
]);
const setAndPersistSegmentId = useCallback(
(segmentId: number | null) => {
setSegmentId(segmentId);
setPreference({
tableIdentifier,
segmentId,
});
},
[tableIdentifier, setSegmentId, setPreference]
);
return { return {
segments: segments ?? [], segments,
selectedSegment, preferredSegmentId,
canSaveSegment, isSuccess: Boolean(providedSegments) || isSuccess,
setAndPersistSegmentId, setPreference,
isSegmentEnabled: true, isSegmentEnabled: true,
}; };
}; };
@@ -6,9 +6,9 @@ import { type UseSegments } from "../lib/types";
export const useSegmentsNoop: UseSegments = ({}) => { export const useSegmentsNoop: UseSegments = ({}) => {
return { return {
segments: [], segments: [],
selectedSegment: undefined, preferredSegmentId: null,
canSaveSegment: false, isSuccess: false,
setAndPersistSegmentId: noop, setPreference: noop,
isSegmentEnabled: false, isSegmentEnabled: false,
}; };
}; };
+1 -1
View File
@@ -18,7 +18,7 @@ export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withD
DEFAULT_COLUMN_VISIBILITY DEFAULT_COLUMN_VISIBILITY
); );
export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING); export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING);
export const segmentIdParser = parseAsInteger.withDefault(-1); export const segmentIdParser = parseAsString.withDefault("");
export const pageIndexParser = parseAsInteger.withDefault(0); export const pageIndexParser = parseAsInteger.withDefault(0);
export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE); export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE);
export const searchTermParser = parseAsString.withDefault(""); export const searchTermParser = parseAsString.withDefault("");
+38 -26
View File
@@ -1,10 +1,12 @@
import type { SortingState, ColumnSort, VisibilityState, ColumnSizingState } from "@tanstack/react-table"; import type { SortingState, ColumnSort } from "@tanstack/react-table";
import { z } from "zod"; import { z } from "zod";
import type { IconName } from "@calcom/ui/components/icon"; import type { IconName } from "@calcom/ui/components/icon";
export type { SortingState } from "@tanstack/react-table"; export type { SortingState } from "@tanstack/react-table";
export const SYSTEM_SEGMENT_PREFIX = "system_";
export enum ColumnFilterType { export enum ColumnFilterType {
SINGLE_SELECT = "ss", SINGLE_SELECT = "ss",
MULTI_SELECT = "ms", MULTI_SELECT = "ms",
@@ -255,11 +257,31 @@ export type FilterSegmentOutput = {
team: { id: number; name: string } | null; team: { id: number; name: string } | null;
}; };
export type FilterSegmentsListResponse = { export type SystemFilterSegment = {
segments: FilterSegmentOutput[]; id: string;
preferredSegmentId: number | null; name: string;
activeFilters: ActiveFilters;
sorting?: SortingState;
columnVisibility?: Record<string, boolean>;
columnSizing?: Record<string, number>;
perPage?: number;
searchTerm?: string | null;
type: "system";
}; };
export type SystemFilterSegmentInternal = Omit<SystemFilterSegment, "perPage"> & {
tableIdentifier: string;
perPage: number;
};
export type UserFilterSegment = FilterSegmentOutput & {
type: "user";
};
export type CombinedFilterSegment = SystemFilterSegmentInternal | UserFilterSegment;
export type SegmentIdentifier = { id: string; type: "system" } | { id: number; type: "user" };
export type SegmentStorage = { export type SegmentStorage = {
[tableIdentifier: string]: { [tableIdentifier: string]: {
segmentId: number; segmentId: number;
@@ -277,30 +299,20 @@ export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn;
export type UseSegmentsProps = { export type UseSegmentsProps = {
tableIdentifier: string; tableIdentifier: string;
activeFilters: ActiveFilters; providedSegments?: FilterSegmentOutput[];
sorting: SortingState; systemSegments?: SystemFilterSegment[];
columnVisibility: VisibilityState;
columnSizing: ColumnSizingState;
pageSize: number;
searchTerm: string;
defaultPageSize: number;
segmentId: number;
setSegmentId: (segmentId: number | null) => void;
setActiveFilters: (activeFilters: ActiveFilters) => void;
setSorting: (sorting: SortingState) => void;
setColumnVisibility: (columnVisibility: VisibilityState) => void;
setColumnSizing: (columnSizing: ColumnSizingState) => void;
setPageSize: (pageSize: number) => void;
setPageIndex: (pageIndex: number) => void;
setSearchTerm: (searchTerm: string | null) => void;
segments?: FilterSegmentOutput[];
preferredSegmentId?: number | null;
}; };
export type UseSegmentsReturn = { export type UseSegmentsReturn = {
segments: FilterSegmentOutput[]; segments: CombinedFilterSegment[];
selectedSegment: FilterSegmentOutput | undefined; preferredSegmentId: SegmentIdentifier | null;
canSaveSegment: boolean; isSuccess: boolean;
setAndPersistSegmentId: (segmentId: number | null) => void; setPreference: ({
tableIdentifier,
segmentId,
}: {
tableIdentifier: string;
segmentId: SegmentIdentifier | null;
}) => void;
isSegmentEnabled: boolean; isSegmentEnabled: boolean;
}; };
@@ -73,6 +73,17 @@ export const handleRescheduleEventManager = async ({
const results = updateManager.results ?? []; const results = updateManager.results ?? [];
const calVideoResult = results.find((result) => result.type === "daily_video");
// Check if Cal Video Creation Failed - That is the fallback for Cal.com and is expected to always work
if (calVideoResult && !calVideoResult.success) {
handleRescheduleEventManager.error("Cal Video creation failed", {
error: calVideoResult.error,
bookingLocation,
});
// This happens only when Cal Video is down
throw new Error("Failed to set video conferencing link, but the meeting has been rescheduled");
}
const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({ const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
results: results, results: results,
}); });
@@ -80,7 +91,6 @@ export const handleRescheduleEventManager = async ({
let videoCallUrl = _videoCallUrl; let videoCallUrl = _videoCallUrl;
let metadata: AdditionalInformation = {}; let metadata: AdditionalInformation = {};
metadata = videoMetadata; metadata = videoMetadata;
if (results.length) { if (results.length) {
// Handle Google Meet results // Handle Google Meet results
if (bookingLocation === MeetLocationType) { if (bookingLocation === MeetLocationType) {
@@ -154,6 +164,7 @@ export const handleRescheduleEventManager = async ({
? calendarResult?.updatedEvent[0]?.iCalUID ? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined; : calendarResult?.updatedEvent?.iCalUID || undefined;
} }
const newReferencesToCreate = structuredClone(updateManager.referencesToCreate); const newReferencesToCreate = structuredClone(updateManager.referencesToCreate);
await BookingReferenceRepository.replaceBookingReferences({ await BookingReferenceRepository.replaceBookingReferences({
@@ -17,65 +17,238 @@ import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAn
import { describe, vi, expect } from "vitest"; import { describe, vi, expect } from "vitest";
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { BookingRepository } from "@calcom/lib/server/repository/booking"; import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums"; import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures"; import { test } from "@calcom/web/test/fixtures/fixtures";
vi.mock("@calcom/lib/EventManager"); vi.mock("@calcom/lib/EventManager");
vi.mock("@calcom/app-store/utils", () => ({
getAppFromSlug: vi.fn(),
}));
const testDestinationCalendar = { // Type definitions
integration: "test-calendar", type TestUserMetadata = {
externalId: "test-calendar", defaultConferencingApp?: {
appSlug: string;
appLink?: string;
};
}; };
const testUsers = [ type TestUser = {
{ id: number;
id: 1, name: string;
name: "user-1", timeZone: string;
timeZone: "Asia/Kolkata", username: string;
username: "host-1", email: string;
email: "host1@test.com", schedules: (typeof TestData.schedules.IstWorkHours)[];
schedules: [TestData.schedules.IstWorkHours], destinationCalendar?: { integration: string; externalId: string };
destinationCalendar: testDestinationCalendar, metadata?: TestUserMetadata;
}, };
{
id: 2, type MockBookingAttendee = ReturnType<typeof getMockBookingAttendee>;
name: "user-2",
timeZone: "Asia/Kolkata", // Test Data Builders
username: "host-2", const createTestUser = (overrides?: {
email: "host2@test.com", id?: number;
schedules: [TestData.schedules.IstWorkHours], name?: string;
}, username?: string;
{ email?: string;
id: 3, destinationCalendar?: { integration: string; externalId: string };
name: "user-3", metadata?: TestUserMetadata;
timeZone: "Asia/Kolkata", }) => ({
username: "host-3", id: overrides?.id ?? 1,
email: "host3@test.com", name: overrides?.name ?? `user-${overrides?.id ?? 1}`,
schedules: [TestData.schedules.IstWorkHours], timeZone: "Asia/Kolkata",
}, username: overrides?.username ?? `host-${overrides?.id ?? 1}`,
]; email: overrides?.email ?? `host${overrides?.id ?? 1}@test.com`,
schedules: [TestData.schedules.IstWorkHours],
destinationCalendar: overrides?.destinationCalendar,
metadata: overrides?.metadata,
defaultScheduleId: 1,
credentials: [],
selectedCalendars: [],
weekStart: "Monday" as const,
teams: [],
availability: [],
profiles: [],
outOfOffice: undefined,
smsLockState: undefined,
completedOnboarding: true,
locked: false,
organizationId: null,
});
const createTestDestinationCalendar = (overrides?: { integration?: string; externalId?: string }) => ({
integration: overrides?.integration ?? "test-calendar",
externalId: overrides?.externalId ?? "test-calendar",
});
const createTestBooking = async (params: {
eventTypeId: number;
userId: number;
bookingId: number;
bookingUid: string;
attendees?: MockBookingAttendee[];
location?: string;
}) => {
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
return {
id: params.bookingId,
eventTypeId: params.eventTypeId,
userId: params.userId,
uid: params.bookingUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
location: params.location ?? null,
attendees: params.attendees ?? [
getMockBookingAttendee({
id: 2,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
],
};
};
const createRoundRobinEventType = (params: {
id: number;
slug: string;
users: TestUser[];
hosts?: { userId: number; isFixed: boolean }[];
locations?: { type: string; credentialId?: number }[];
teamId?: number;
schedulingType?: SchedulingType;
parentId?: number;
}) => ({
id: params.id,
slug: params.slug,
schedulingType: params.schedulingType ?? SchedulingType.ROUND_ROBIN,
length: 45,
users: params.users.map((user) => ({ id: user.id })),
hosts: params.hosts ?? params.users.map((user) => ({ userId: user.id, isFixed: false })),
locations: params.locations,
teamId: params.teamId,
parentId: params.parentId,
});
// Assertion Helpers
const expectEventManagerCalledWith = (
spy: ReturnType<typeof vi.spyOn>,
expectedParams: {
location?: string;
uid: string;
changedOrganizer: boolean;
destinationCalendars?: Array<{ integration: string; externalId: string }>;
}
) => {
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
expectedParams.location
? expect.objectContaining({ location: expectedParams.location })
: expect.any(Object),
expectedParams.uid,
undefined,
expectedParams.changedOrganizer,
expectedParams.destinationCalendars ?? expect.any(Array)
);
};
// Mock Helpers
type MockEventManagerConfig = {
failConferencing?: boolean;
};
type ConferenceResult = {
type: string;
success: boolean;
uid: string;
originalEvent: Record<string, unknown>;
error?: string;
updatedEvent?: {
url: string;
};
};
const mockEventManagerReschedule = async (config?: MockEventManagerConfig) => {
const EventManager = (await import("@calcom/lib/EventManager")).default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spy = vi.spyOn(EventManager.prototype as any, "reschedule");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spy.mockImplementation(async (event: any) => {
const location = event.location;
const results: ConferenceResult[] = [];
// Check if location is a static link (starts with http:// or https://)
const isStaticLink = location?.startsWith("http://") || location?.startsWith("https://");
if (isStaticLink) {
// For static links, no conferencing result is generated
return { referencesToCreate: [] };
}
// Handle conferencing based on location type
if (location?.includes("integrations:")) {
const conferenceType = location.split(":")[1];
if (config?.failConferencing) {
results.push({
type: `${conferenceType}_video`,
success: false,
error: `Failed to create ${conferenceType} room`,
uid: event.uid || "test-uid",
originalEvent: {},
});
} else {
results.push({
type: `${conferenceType}_video`,
success: true,
uid: event.uid || "test-uid",
originalEvent: {},
updatedEvent: {
url:
conferenceType === "zoom"
? "https://zoom.us/j/123456789"
: conferenceType === "google"
? "https://meet.google.com/test-meeting"
: `https://${conferenceType}.co/test-room`,
},
});
}
}
return { results, referencesToCreate: [] };
});
return spy;
};
const mockGetAppFromSlug = async () => {
const { getAppFromSlug } = await import("@calcom/app-store/utils");
return vi.mocked(getAppFromSlug);
};
describe("roundRobinManualReassignment test", () => { describe("roundRobinManualReassignment test", () => {
setupAndTeardown(); setupAndTeardown();
test("manually reassign round robin organizer", async ({ emails }) => { test("should reassign round robin booking to new host and update workflows", async ({ emails }) => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default; const eventManagerRescheduleSpy = await mockEventManagerReschedule();
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule"); const testDestinationCalendar = createTestDestinationCalendar();
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] }); const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
const newHost = createTestUser({ id: 2 });
const users = [originalHost, newHost, createTestUser({ id: 3 })];
const users = testUsers;
const originalHost = users[0];
const newHost = users[1];
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 }); const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 });
const bookingToReassignUid = "booking-to-reassign"; const bookingToReassignUid = "booking-to-reassign";
const bookingData = await createBookingScenario( await createBookingScenario(
getScenarioData({ getScenarioData({
workflows: [ workflows: [
{ {
@@ -83,48 +256,34 @@ describe("roundRobinManualReassignment test", () => {
trigger: "NEW_EVENT", trigger: "NEW_EVENT",
action: "EMAIL_HOST", action: "EMAIL_HOST",
template: "REMINDER", template: "REMINDER",
activeEventTypeId: 1, activeOn: [1],
}, },
], ],
eventTypes: [ eventTypes: [
{ createRoundRobinEventType({
id: 1, id: 1,
slug: "round-robin-event", slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN, users,
length: 45, }),
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
},
], ],
bookings: [ bookings: [
{ await createTestBooking({
id: 123,
eventTypeId: 1, eventTypeId: 1,
userId: originalHost.id, userId: originalHost.id,
uid: bookingToReassignUid, bookingId: 123,
status: BookingStatus.ACCEPTED, bookingUid: bookingToReassignUid,
startTime: `${dateStringPlusOne}T05:00:00.000Z`, }),
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
attendees: [
getMockBookingAttendee({
id: 2,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
],
},
], ],
organizer: originalHost, organizer: originalHost,
usersApartFromOrganizer: users.slice(1), usersApartFromOrganizer: users.slice(1),
}) })
); );
await addWorkflowReminders([ await addWorkflowReminders([
{ {
bookingUid: bookingToReassignUid, bookingUid: bookingToReassignUid,
method: WorkflowMethods.EMAIL, method: WorkflowMethods.EMAIL,
scheduledDate: dateStringPlusTwo, scheduledDate: new Date(dateStringPlusTwo),
scheduled: true, scheduled: true,
workflowStepId: 1, workflowStepId: 1,
workflowId: 1, workflowId: 1,
@@ -135,16 +294,14 @@ describe("roundRobinManualReassignment test", () => {
bookingId: 123, bookingId: 123,
newUserId: newHost.id, newUserId: newHost.id,
orgId: null, orgId: null,
reassignedById: 1,
}); });
expect(eventManagerSpy).toBeCalledTimes(1); expectEventManagerCalledWith(eventManagerRescheduleSpy, {
expect(eventManagerSpy).toHaveBeenCalledWith( uid: bookingToReassignUid,
expect.any(Object), changedOrganizer: true,
bookingToReassignUid, destinationCalendars: expect.arrayContaining([expect.objectContaining(testDestinationCalendar)]),
undefined, });
true,
expect.arrayContaining([expect.objectContaining(testDestinationCalendar)])
);
expectBookingToBeInDatabase({ expectBookingToBeInDatabase({
uid: bookingToReassignUid, uid: bookingToReassignUid,
@@ -160,21 +317,73 @@ describe("roundRobinManualReassignment test", () => {
expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails }); expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails });
}); });
test("Manually reassign round robin host with fixed host as organizer", async () => { test("should include conferenceCredentialId when reassigning booking with with fixed host as organizer", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default; const eventManagerRescheduleSpy = await mockEventManagerReschedule();
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule"); const testDestinationCalendar = createTestDestinationCalendar();
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
const newHost = createTestUser({ id: 2 });
const users = [originalHost, newHost];
const users = testUsers; const bookingToReassignUid = "booking-with-conference-credential";
const conferenceCredentialId = 999;
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
users,
locations: [
{
type: "integrations:zoom",
credentialId: conferenceCredentialId,
},
],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 1,
userId: originalHost.id,
bookingId: 123,
bookingUid: bookingToReassignUid,
location: "integrations:zoom",
}),
],
organizer: originalHost,
usersApartFromOrganizer: users.slice(1),
})
);
await roundRobinManualReassignment({
bookingId: 123,
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
});
// Verify that EventManager.reschedule was called with an event containing conferenceCredentialId
expect(eventManagerRescheduleSpy).toHaveBeenCalledTimes(1);
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((calledEvent as any).conferenceCredentialId).toBe(conferenceCredentialId);
});
test("should reassign round robin attendee while keeping fixed host as organizer", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
const testDestinationCalendar = createTestDestinationCalendar();
const fixedHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
const currentRRHost = createTestUser({ id: 2 });
const newHost = createTestUser({ id: 3 });
const users = [fixedHost, currentRRHost, newHost];
const bookingToReassignUid = "booking-to-reassign"; const bookingToReassignUid = "booking-to-reassign";
const fixedHost = users[0];
const currentRRHost = users[1];
const newHost = users[2];
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
await createBookingScenario( await createBookingScenario(
getScenarioData({ getScenarioData({
workflows: [ workflows: [
@@ -186,27 +395,22 @@ describe("roundRobinManualReassignment test", () => {
}, },
], ],
eventTypes: [ eventTypes: [
{ createRoundRobinEventType({
id: 1, id: 1,
slug: "round-robin-event", slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN, users,
length: 45,
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({ hosts: users.map((user) => ({
userId: user.id, userId: user.id,
isFixed: user.id === fixedHost.id, isFixed: user.id === fixedHost.id,
})), })),
}, }),
], ],
bookings: [ bookings: [
{ await createTestBooking({
id: 123,
eventTypeId: 1, eventTypeId: 1,
userId: fixedHost.id, userId: fixedHost.id,
uid: bookingToReassignUid, bookingId: 123,
status: BookingStatus.ACCEPTED, bookingUid: bookingToReassignUid,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
attendees: [ attendees: [
getMockBookingAttendee({ getMockBookingAttendee({
id: 1, id: 1,
@@ -223,7 +427,7 @@ describe("roundRobinManualReassignment test", () => {
timeZone: currentRRHost.timeZone, timeZone: currentRRHost.timeZone,
}), }),
], ],
}, }),
], ],
organizer: fixedHost, organizer: fixedHost,
usersApartFromOrganizer: users.slice(1), usersApartFromOrganizer: users.slice(1),
@@ -234,65 +438,59 @@ describe("roundRobinManualReassignment test", () => {
bookingId: 123, bookingId: 123,
newUserId: newHost.id, newUserId: newHost.id,
orgId: null, orgId: null,
reassignedById: 1,
}); });
expect(eventManagerSpy).toBeCalledTimes(1); expectEventManagerCalledWith(eventManagerRescheduleSpy, {
uid: bookingToReassignUid,
changedOrganizer: false,
});
// Ensure organizer stays the same
expectBookingToBeInDatabase({ expectBookingToBeInDatabase({
uid: bookingToReassignUid, uid: bookingToReassignUid,
userId: fixedHost.id, userId: fixedHost.id,
}); });
const bookingRepo = new BookingRepository(prismaMock); // eslint-disable-next-line @typescript-eslint/no-explicit-any
const bookingRepo = new BookingRepository(prismaMock as any);
const attendees = await bookingRepo.getBookingAttendees(123); const attendees = await bookingRepo.getBookingAttendees(123);
expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false); expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true); expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true);
}); });
test("sends cancellation email to previous RR host when reassigning", async ({ emails }) => { test("should send cancellation email to previous round robin attendee when reassigning", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default; const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default; await mockEventManagerReschedule();
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
const sendRoundRobinCancelledEmailsAndSMSSpy = vi.spyOn( const sendRoundRobinCancelledEmailsAndSMSSpy = vi.spyOn(
await import("@calcom/emails"), await import("@calcom/emails"),
"sendRoundRobinCancelledEmailsAndSMS" "sendRoundRobinCancelledEmailsAndSMS"
); );
const users = testUsers; const testDestinationCalendar = createTestDestinationCalendar();
const originalHost = users[0]; const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
const previousRRHost = users[1]; const previousRRHost = createTestUser({ id: 2 });
const newHost = users[2]; const newHost = createTestUser({ id: 3 });
const users = [originalHost, previousRRHost, newHost];
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
const bookingToReassignUid = "booking-to-reassign-with-previous-rr-host"; const bookingToReassignUid = "booking-to-reassign-with-previous-rr-host";
await createBookingScenario( await createBookingScenario(
getScenarioData({ getScenarioData({
eventTypes: [ eventTypes: [
{ createRoundRobinEventType({
id: 1, id: 1,
slug: "round-robin-event", slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN, users,
length: 45, }),
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
},
], ],
bookings: [ bookings: [
{ await createTestBooking({
id: 124,
eventTypeId: 1, eventTypeId: 1,
userId: originalHost.id, userId: originalHost.id,
uid: bookingToReassignUid, bookingId: 124,
status: BookingStatus.ACCEPTED, bookingUid: bookingToReassignUid,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
attendees: [ attendees: [
getMockBookingAttendee({ getMockBookingAttendee({
id: 2, id: 2,
@@ -309,7 +507,7 @@ describe("roundRobinManualReassignment test", () => {
timeZone: previousRRHost.timeZone, timeZone: previousRRHost.timeZone,
}), }),
], ],
}, }),
], ],
organizer: originalHost, organizer: originalHost,
usersApartFromOrganizer: users.slice(1), usersApartFromOrganizer: users.slice(1),
@@ -320,8 +518,358 @@ describe("roundRobinManualReassignment test", () => {
bookingId: 124, bookingId: 124,
newUserId: newHost.id, newUserId: newHost.id,
orgId: null, orgId: null,
reassignedById: 1,
}); });
expect(sendRoundRobinCancelledEmailsAndSMSSpy).toHaveBeenCalledTimes(1); expect(sendRoundRobinCancelledEmailsAndSMSSpy).toHaveBeenCalledTimes(1);
}); });
}); });
describe("roundRobinManualReassignment - Location Changes", () => {
setupAndTeardown();
const createConferencingUsers = () => {
return {
googleMeetHost: createTestUser({
id: 1,
destinationCalendar: {
integration: "google_calendar",
externalId: "test-calendar",
},
metadata: {
defaultConferencingApp: {
appSlug: "google-meet",
},
},
}),
zoomHost: createTestUser({
id: 2,
metadata: {
defaultConferencingApp: {
appSlug: "zoom",
},
},
}),
noDefaultConferencingAppHost: createTestUser({
id: 3,
metadata: {},
}),
staticVideoLinkHost: createTestUser({
id: 4,
metadata: {
defaultConferencingApp: {
appSlug: "custom-video",
appLink: "https://custom-video.com/room123",
},
},
}),
};
};
const mockAppStore = async (config?: { googleMeet?: boolean; zoom?: boolean; customVideo?: boolean }) => {
const getAppFromSlug = await mockGetAppFromSlug();
getAppFromSlug.mockImplementation((slug?: string) => {
if (slug === "google-meet" && config?.googleMeet !== false) {
return {
appData: {
location: {
type: "integrations:google:meet",
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
if (slug === "zoom" && config?.zoom !== false) {
return {
appData: {
location: {
type: "integrations:zoom",
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
if (slug === "custom-video" && config?.customVideo !== false) {
return {
appData: {
location: {
type: "integrations:custom-video",
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
return undefined;
});
return getAppFromSlug;
};
describe("when event has Organizer's Default Conferencing App location", () => {
test("should update location from Google Meet to Zoom when reassigning between hosts with different default conferencing apps", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
await mockAppStore({ googleMeet: true, zoom: true });
const users = createConferencingUsers();
const googleMeetHost = users.googleMeetHost;
const zoomHost = users.zoomHost;
const bookingToReassignUid = "booking-location-change-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
teamId: 1,
users: Object.values(users),
locations: [{ type: OrganizerDefaultConferencingAppType }, { type: "integrations:daily" }],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 1,
userId: googleMeetHost.id,
bookingId: 125,
bookingUid: bookingToReassignUid,
location: "integrations:google:meet",
}),
],
organizer: googleMeetHost,
usersApartFromOrganizer: Object.values(users).slice(1),
})
);
await roundRobinManualReassignment({
bookingId: 125,
newUserId: zoomHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
});
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
location: "integrations:zoom",
uid: bookingToReassignUid,
changedOrganizer: true,
destinationCalendars: expect.arrayContaining([
expect.objectContaining({
integration: "google_calendar",
externalId: "test-calendar",
}),
]),
});
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
userId: zoomHost.id,
});
});
test("should fallback to first available event location in Event Type when new host has no default conferencing app", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
// (await mockGetAppFromSlug()).mockReturnValue(undefined);
const users = createConferencingUsers();
const googleMeetHost = users.googleMeetHost;
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
const bookingToReassignUid = "booking-fallback-location-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 2,
slug: "round-robin-event-fallback",
users: Object.values(users),
locations: [
{ type: OrganizerDefaultConferencingAppType },
{ type: "integrations:first-available" },
],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 2,
userId: googleMeetHost.id,
bookingId: 126,
bookingUid: bookingToReassignUid,
location: "integrations:google:meet",
}),
],
organizer: googleMeetHost,
usersApartFromOrganizer: Object.values(users).slice(1),
})
);
await roundRobinManualReassignment({
bookingId: 126,
newUserId: noDefaultConferencingAppHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
});
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((calledEvent as any).location).toBe("integrations:first-available");
});
test("should update location from Google Meet to static video link when new host has static video link app", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
await mockAppStore({ customVideo: true });
const users = createConferencingUsers();
const googleMeetHost = users.googleMeetHost;
const staticVideoLinkHost = users.staticVideoLinkHost;
const bookingToReassignUid = "booking-static-link-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 4,
slug: "managed-event",
// This is a managed event type child which allows Organizer's Default Conferencing App
parentId: 1,
users: Object.values(users),
locations: [{ type: OrganizerDefaultConferencingAppType }],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 4,
userId: googleMeetHost.id,
bookingId: 128,
bookingUid: bookingToReassignUid,
location: "integrations:google:meet",
}),
],
organizer: googleMeetHost,
usersApartFromOrganizer: Object.values(users).slice(1),
})
);
await roundRobinManualReassignment({
bookingId: 128,
newUserId: staticVideoLinkHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
});
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((calledEvent as any).location).toBe("https://custom-video.com/room123");
});
test("should update location from Static Video Link to Google Meet when new host has Google Meet app", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
await mockAppStore({ googleMeet: true });
const users = createConferencingUsers();
const staticVideoLinkHost = users.staticVideoLinkHost;
const googleMeetHost = users.googleMeetHost;
const bookingToReassignUid = "booking-static-link-to-google-meet-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 3,
slug: "round-robin-event-error",
users: Object.values(users),
teamId: 1,
locations: [{ type: OrganizerDefaultConferencingAppType }],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 3,
userId: staticVideoLinkHost.id,
bookingId: 127,
bookingUid: bookingToReassignUid,
location: "https://custom-video.com/room123",
}),
],
organizer: staticVideoLinkHost,
usersApartFromOrganizer: Object.values(users).filter((user) => user.id !== staticVideoLinkHost.id),
})
);
await roundRobinManualReassignment({
bookingId: 127,
newUserId: googleMeetHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
});
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((calledEvent as any).location).toBe("integrations:google:meet");
await expectBookingToBeInDatabase({
uid: bookingToReassignUid,
userId: googleMeetHost.id,
location: "integrations:google:meet",
});
});
});
test("should throw error when Cal Video fallback fails", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
await mockEventManagerReschedule({ failConferencing: true });
const users = createConferencingUsers();
const googleMeetHost = users.googleMeetHost;
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
const bookingToReassignUid = "booking-calvideo-error-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 3,
slug: "round-robin-event-error",
users: Object.values(users),
locations: [{ type: OrganizerDefaultConferencingAppType }],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 3,
userId: googleMeetHost.id,
bookingId: 127,
bookingUid: bookingToReassignUid,
location: "integrations:google:meet",
}),
],
organizer: googleMeetHost,
usersApartFromOrganizer: Object.values(users).slice(1),
})
);
await expect(
roundRobinManualReassignment({
bookingId: 127,
newUserId: noDefaultConferencingAppHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
})
).rejects.toThrow("Failed to set video conferencing link, but the meeting has been rescheduled");
});
});
@@ -1,7 +1,6 @@
// eslint-disable-next-line no-restricted-imports // eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { import {
sendRoundRobinCancelledEmailsAndSMS, sendRoundRobinCancelledEmailsAndSMS,
@@ -28,10 +27,10 @@ import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKey
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj"; import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import logger from "@calcom/lib/logger"; import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n"; import { getTranslation } from "@calcom/lib/server/i18n";
import { BookingLocationService } from "@calcom/lib/server/service/bookingLocationService";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat"; import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums"; import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils"; import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar"; import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -126,7 +125,6 @@ export const roundRobinManualReassignment = async ({
const originalOrganizer = booking.user; const originalOrganizer = booking.user;
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId; const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
const newUser = newUserHost.user; const newUser = newUserHost.user;
const newUserT = await getTranslation(newUser.locale || "en", "common"); const newUserT = await getTranslation(newUser.locale || "en", "common");
const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common"); const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common");
@@ -149,7 +147,7 @@ export const roundRobinManualReassignment = async ({
const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common"); const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common");
let bookingLocation = booking.location; let bookingLocation = booking.location;
let conferenceCredentialId: number | null = null;
if (hasOrganizerChanged) { if (hasOrganizerChanged) {
const bookingResponses = booking.responses; const bookingResponses = booking.responses;
const responseSchema = getBookingResponsesSchema({ const responseSchema = getBookingResponsesSchema({
@@ -159,15 +157,20 @@ export const roundRobinManualReassignment = async ({
const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses); const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
const responses = responseSafeParse.success ? responseSafeParse.data : undefined; const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
if (eventType.locations.some((location) => location.type === OrganizerDefaultConferencingAppType)) { // Determine the location for the new host
const newUserMetadataSafeParse = userMetadataSchema.safeParse(newUser.metadata); const isManagedEventType = !!eventType.parentId;
const defaultLocationUrl = newUserMetadataSafeParse.success const isTeamEventType = !!eventType.teamId;
? newUserMetadataSafeParse?.data?.defaultConferencingApp?.appLink
: undefined; const locationResult = BookingLocationService.getLocationForHost({
const currentBookingLocation = booking.location || "integrations:daily"; hostMetadata: newUser.metadata,
bookingLocation = eventTypeLocations: eventType.locations,
defaultLocationUrl || isManagedEventType,
getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation; isTeamEventType,
});
bookingLocation = locationResult.bookingLocation;
if (locationResult.requiresActualLink) {
conferenceCredentialId = locationResult.conferenceCredentialId;
} }
const newBookingTitle = getEventName({ const newBookingTitle = getEventName({
@@ -219,6 +222,18 @@ export const roundRobinManualReassignment = async ({
}); });
} }
// When organizer hasn't changed, still extract conferenceCredentialId from event type locations
if (!hasOrganizerChanged && bookingLocation) {
const { conferenceCredentialId: extractedCredentialId } =
BookingLocationService.getLocationDetailsFromType({
locationType: bookingLocation,
eventTypeLocations: eventType.locations || [],
});
if (extractedCredentialId) {
conferenceCredentialId = extractedCredentialId;
}
}
const destinationCalendar = await getDestinationCalendar({ const destinationCalendar = await getDestinationCalendar({
eventType, eventType,
booking, booking,
@@ -290,6 +305,7 @@ export const roundRobinManualReassignment = async ({
customReplyToEmail: eventType?.customReplyToEmail, customReplyToEmail: eventType?.customReplyToEmail,
location: bookingLocation, location: bookingLocation,
...(platformClientParams ? platformClientParams : {}), ...(platformClientParams ? platformClientParams : {}),
conferenceCredentialId: conferenceCredentialId ?? undefined,
}; };
const credentials = await prisma.credential.findMany({ const credentials = await prisma.credential.findMany({
-4
View File
@@ -30,10 +30,6 @@ const opts: JacksonOption = {
}, },
idpEnabled: true, idpEnabled: true,
clientSecretVerifier, clientSecretVerifier,
ory: {
projectId: undefined,
sdkToken: undefined,
},
}; };
declare global { declare global {
@@ -1,6 +1,8 @@
import { useId } from "react"; import { useId } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge"; import { Badge } from "@calcom/ui/components/badge";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { RoutingFormTableRow } from "../lib/types"; import type { RoutingFormTableRow } from "../lib/types";
import { CellWithOverflowX } from "./CellWithOverflowX"; import { CellWithOverflowX } from "./CellWithOverflowX";
@@ -9,21 +11,39 @@ export function BookedByCell({
attendees, attendees,
rowId, rowId,
}: { }: {
attendees: RoutingFormTableRow["bookingAttendees"] | undefined; attendees: RoutingFormTableRow["bookingAttendees"];
rowId: number; rowId: number;
}) { }) {
const cellId = useId(); const cellId = useId();
const { t } = useLocale();
if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />; if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />;
return ( return (
<div className="flex min-w-[200px] flex-wrap gap-1"> <div className="flex min-w-[200px] flex-wrap gap-1">
{attendees.map((attendee) => ( {attendees.map((attendee) => {
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]"> const tooltipContent = (
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}> <div className="space-y-1">
{attendee.name} <div>
</Badge> {t("email")}: {attendee.email}
</CellWithOverflowX> </div>
))} {attendee.phoneNumber && (
<div>
{t("phone")}: {attendee.phoneNumber}
</div>
)}
</div>
);
return (
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
<Tooltip content={tooltipContent || undefined}>
<Badge variant="gray" className="whitespace-nowrap">
{attendee.name}
</Badge>
</Tooltip>
</CellWithOverflowX>
);
})}
</div> </div>
); );
} }
@@ -16,6 +16,7 @@ import {
convertMapToFacetedValues, convertMapToFacetedValues,
type FilterableColumn, type FilterableColumn,
} from "@calcom/features/data-table"; } from "@calcom/features/data-table";
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import { RoutingFormResponsesDownload } from "../../filters/Download"; import { RoutingFormResponsesDownload } from "../../filters/Download";
@@ -35,8 +36,7 @@ const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_R
}; };
export function RoutingFormResponsesTable() { export function RoutingFormResponsesTable() {
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } = const { isAll, teamId, userId, routingFormId } = useInsightsParameters();
useInsightsParameters();
const { data: headers, isSuccess: isHeadersSuccess } = const { data: headers, isSuccess: isHeadersSuccess } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({ trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
@@ -53,22 +53,15 @@ export function RoutingFormResponsesTable() {
isAll, isAll,
}); });
const insightsRoutingParams = useInsightsRoutingParameters();
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable(); const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({ const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
teamId, ...insightsRoutingParams,
startDate,
endDate,
userId,
memberUserIds,
isAll,
routingFormId,
columnFilters,
sorting, sorting,
limit, limit,
offset, offset,
}); });
const processedData = useMemo(() => { const processedData = useMemo(() => {
if (!isHeadersSuccess || !data) return []; if (!isHeadersSuccess || !data) return [];
return data.data as RoutingFormTableRow[]; return data.data as RoutingFormTableRow[];
@@ -88,6 +81,9 @@ export function RoutingFormResponsesTable() {
columnVisibility: { columnVisibility: {
formId: false, formId: false,
bookingUserId: false, bookingUserId: false,
attendeeName: false,
attendeeEmail: false,
attendeePhone: false,
utm_source: false, utm_source: false,
utm_medium: false, utm_medium: false,
utm_campaign: false, utm_campaign: false,
@@ -1,37 +1,24 @@
"use client"; "use client";
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
import classNames from "@calcom/ui/classNames"; import classNames from "@calcom/ui/classNames";
import { SkeletonText } from "@calcom/ui/components/skeleton"; import { SkeletonText } from "@calcom/ui/components/skeleton";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { valueFormatter } from "../../lib"; import { valueFormatter } from "../../lib";
import { ChartCard } from "../ChartCard"; import { ChartCard } from "../ChartCard";
export const RoutingKPICards = () => { export const RoutingKPICards = () => {
const { t } = useLocale(); const { t } = useLocale();
const { teamId, startDate, endDate, userId, memberUserIds, isAll, routingFormId, columnFilters } = const insightsRoutingParameters = useInsightsRoutingParameters();
useInsightsParameters();
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery( const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(insightsRoutingParameters, {
{ staleTime: 30000,
teamId, trpc: {
startDate, context: { skipBatch: true },
endDate,
userId,
memberUserIds,
isAll,
routingFormId,
columnFilters,
}, },
{ });
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
}
);
const categories: { const categories: {
title: string; title: string;
@@ -2,6 +2,7 @@ import { useState } from "react";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import type { SortingState } from "@calcom/features/data-table"; import type { SortingState } from "@calcom/features/data-table";
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
import { downloadAsCsv } from "@calcom/lib/csvUtils"; import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc"; import { trpc } from "@calcom/trpc";
@@ -15,8 +16,6 @@ import {
} from "@calcom/ui/components/dropdown"; } from "@calcom/ui/components/dropdown";
import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast"; import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number]; type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number];
type Props = { type Props = {
@@ -27,9 +26,9 @@ const BATCH_SIZE = 100; // Increased batch size for downloads
export const RoutingFormResponsesDownload = ({ sorting }: Props) => { export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
const { t } = useLocale(); const { t } = useLocale();
const { teamId, userId, memberUserIds, routingFormId, isAll, startDate, endDate, columnFilters } =
useInsightsParameters();
const [isDownloading, setIsDownloading] = useState(false); const [isDownloading, setIsDownloading] = useState(false);
const insightsRoutingParameters = useInsightsRoutingParameters();
const { startDate, endDate } = insightsRoutingParameters;
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -40,14 +39,7 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
total: number; total: number;
}> => { }> => {
const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({ const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
teamId, ...insightsRoutingParameters,
startDate,
endDate,
userId,
memberUserIds,
isAll: isAll,
routingFormId,
columnFilters,
sorting, sorting,
limit: BATCH_SIZE, limit: BATCH_SIZE,
offset, offset,
@@ -35,7 +35,7 @@ export const useInsightsBookings = () => {
size: 200, size: 200,
meta: { meta: {
filter: { filter: {
type: ColumnFilterType.SINGLE_SELECT, type: ColumnFilterType.MULTI_SELECT,
}, },
}, },
enableColumnFilter: true, enableColumnFilter: true,
@@ -103,19 +103,53 @@ export const useInsightsColumns = ({
size: 200, size: 200,
enableColumnFilter: false, enableColumnFilter: false,
enableSorting: false, enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
placeholder: t("name_or_email"),
},
},
},
cell: (info) => { cell: (info) => {
return <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />; return <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
}, },
}), }),
// Invisible attendee name filter
columnHelper.accessor("bookingAttendees", {
id: "attendeeName",
header: t("attendee_name"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
cell: () => null,
}),
// Invisible attendee email filter
columnHelper.accessor("bookingAttendees", {
id: "attendeeEmail",
header: t("attendee_email_variable"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
cell: () => null,
}),
// Invisible attendee phone filter
columnHelper.accessor("bookingAttendees", {
id: "attendeePhone",
header: t("attendee_phone_number"),
enableColumnFilter: true,
enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.TEXT,
},
},
cell: () => null,
}),
...((headers || []).map((fieldHeader) => { ...((headers || []).map((fieldHeader) => {
const isText = [ const isText = [
RoutingFormFieldType.TEXT, RoutingFormFieldType.TEXT,
@@ -145,19 +179,28 @@ export const useInsightsColumns = ({
return acc; return acc;
}, {} as Record<string, string>) ?? {}; }, {} as Record<string, string>) ?? {};
return columnHelper.accessor(`response.${fieldHeader.id}`, { return columnHelper.accessor((row) => row.fields.find((field) => field.fieldId === fieldHeader.id), {
id: fieldHeader.id, id: fieldHeader.id,
header: startCase(fieldHeader.label), header: startCase(fieldHeader.label),
enableSorting: false, enableSorting: false,
cell: (info) => { cell: (info) => {
const values = info.getValue(); let values;
if (isMultiSelect) {
values = info.getValue()?.valueStringArray;
} else if (isNumber) {
values = info.getValue()?.valueNumber;
} else {
values = info.getValue()?.valueString;
}
if (isMultiSelect || isSingleSelect) { if (isMultiSelect || isSingleSelect) {
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values); const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
return ( return (
result.success && ( result.success && (
<ResponseValueCell <ResponseValueCell
optionMap={optionMap} optionMap={optionMap}
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]} values={Array.isArray(result.data) ? result.data : [result.data]}
rowId={info.row.original.id} rowId={info.row.original.id}
/> />
) )
@@ -167,7 +210,7 @@ export const useInsightsColumns = ({
return ( return (
result.success && ( result.success && (
<div className="truncate"> <div className="truncate">
<span title={String(result.data.value)}>{result.data.value}</span> <span title={String(result.data)}>{result.data}</span>
</div> </div>
) )
); );
@@ -51,7 +51,7 @@ export function useInsightsParameters() {
}, [createdAtRange?.preset]); }, [createdAtRange?.preset]);
const columnFilters = useColumnFilters({ const columnFilters = useColumnFilters({
exclude: ["bookingUserId", "formId", "createdAt", "eventTypeId"], exclude: ["createdAt"],
}); });
return { return {
+4 -16
View File
@@ -6,25 +6,13 @@ export type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFo
export type HeaderRow = RouterOutputs["viewer"]["insights"]["routingFormResponsesHeaders"][number]; export type HeaderRow = RouterOutputs["viewer"]["insights"]["routingFormResponsesHeaders"][number];
export const ZResponseMultipleValues = z.object({ export const ZResponseMultipleValues = z.array(z.string());
label: z.string(),
value: z.array(z.string()),
});
export const ZResponseSingleValue = z.object({ export const ZResponseSingleValue = z.string();
label: z.string(),
value: z.string(),
});
export const ZResponseTextValue = z.object({ export const ZResponseTextValue = z.string();
label: z.string(),
value: z.string(),
});
export const ZResponseNumericValue = z.object({ export const ZResponseNumericValue = z.number();
label: z.string(),
value: z.number(),
});
export const ZResponseValue = z.union([ export const ZResponseValue = z.union([
ZResponseMultipleValues, ZResponseMultipleValues,
@@ -39,9 +39,19 @@ export type RoutingFormStatsInput = {
sorting?: Sorting[]; sorting?: Sorting[];
}; };
export type RoutingFormResponsesInput = RoutingFormStatsInput & { export type InsightsRoutingServiceInput = {
offset?: number; scope: "user" | "team" | "org";
limit?: number; selectedTeamId?: number;
columnFilters?: ColumnFilter[];
startDate: string;
endDate: string;
};
export type InsightsRoutingServicePaginatedInput = InsightsRoutingServiceInput & {
sorting?: Sorting[];
offset: number;
limit: number;
}; };
export const routingFormStatsInputSchema = z.object({ export const routingFormStatsInputSchema = z.object({
@@ -56,11 +66,20 @@ export const routingFormStatsInputSchema = z.object({
sorting: z.array(ZSorting).optional(), sorting: z.array(ZSorting).optional(),
}) satisfies z.ZodType<RoutingFormStatsInput>; }) satisfies z.ZodType<RoutingFormStatsInput>;
export const routingFormResponsesInputSchema = z.object({ export const insightsRoutingServiceInputSchema = z.object({
...routingFormStatsInputSchema.shape, scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
offset: z.number().optional(), selectedTeamId: z.number().optional(),
limit: z.number().max(100).optional(), columnFilters: z.array(ZColumnFilter).optional(),
}) satisfies z.ZodType<RoutingFormResponsesInput>; startDate: z.string(),
endDate: z.string(),
}) satisfies z.ZodType<InsightsRoutingServiceInput>;
export const insightsRoutingServicePaginatedInputSchema = z.object({
...insightsRoutingServiceInputSchema.shape,
offset: z.number(),
limit: z.number().max(100),
sorting: z.array(ZSorting).optional(),
}) satisfies z.ZodType<InsightsRoutingServicePaginatedInput>;
export const routingRepositoryBaseInputSchema = z.object({ export const routingRepositoryBaseInputSchema = z.object({
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]), scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
@@ -10,17 +10,10 @@ import {
} from "@calcom/app-store/routing-forms/lib/FieldTypes"; } from "@calcom/app-store/routing-forms/lib/FieldTypes";
import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod"; import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { makeWhereClause, makeOrderBy } from "@calcom/features/data-table/lib/server";
import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-table/lib/types";
import type {
RoutingFormResponsesInput,
RoutingFormStatsInput,
} from "@calcom/features/insights/server/raw-data.schema";
import { WEBAPP_URL } from "@calcom/lib/constants"; import { WEBAPP_URL } from "@calcom/lib/constants";
import type { InsightsRoutingBaseService } from "@calcom/lib/server/service/InsightsRoutingBaseService";
import { readonlyPrisma as prisma } from "@calcom/prisma"; import { readonlyPrisma as prisma } from "@calcom/prisma";
import { type ResponseValue, ZResponse } from "../lib/types";
type RoutingFormInsightsTeamFilter = { type RoutingFormInsightsTeamFilter = {
userId?: number | null; userId?: number | null;
teamId?: number | null; teamId?: number | null;
@@ -29,14 +22,6 @@ type RoutingFormInsightsTeamFilter = {
routingFormId?: string | null; routingFormId?: string | null;
}; };
type RoutingFormStatsFilter = RoutingFormStatsInput & {
organizationId: number | null;
};
type RoutingFormResponsesFilter = RoutingFormResponsesInput & {
organizationId: number | null;
};
type WhereForTeamOrAllTeams = Pick<Prisma.App_RoutingForms_FormWhereInput, "id" | "teamId" | "userId">; type WhereForTeamOrAllTeams = Pick<Prisma.App_RoutingForms_FormWhereInput, "id" | "teamId" | "userId">;
class RoutingEventsInsights { class RoutingEventsInsights {
@@ -100,57 +85,6 @@ class RoutingEventsInsights {
return formsWhereCondition; return formsWhereCondition;
} }
static async getRoutingFormStats({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
userId,
memberUserIds,
columnFilters,
sorting,
}: RoutingFormStatsFilter) {
const whereClause = await this.getWhereClauseForRoutingFormResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
userId,
memberUserIds,
columnFilters,
sorting,
});
if (whereClause.bookingUid) {
// If bookingUid filter is applied, total count should be either 0 or 1.
// So this metrics doesn't provide any value.
return null;
}
const totalPromise = prisma.routingFormResponse.count({
where: whereClause,
});
const totalWithoutBookingPromise = prisma.routingFormResponse.count({
where: {
...whereClause,
bookingUid: null,
},
});
const [total, totalWithoutBooking] = await Promise.all([totalPromise, totalWithoutBookingPromise]);
return {
total,
totalWithoutBooking,
totalWithBooking: total - totalWithoutBooking,
};
}
static async getRoutingFormsForFilters({ static async getRoutingFormsForFilters({
userId, userId,
teamId, teamId,
@@ -183,250 +117,37 @@ class RoutingEventsInsights {
}); });
} }
static async getWhereClauseForRoutingFormResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
userId,
memberUserIds,
columnFilters,
}: RoutingFormResponsesFilter) {
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const getLowercasedFilterValue = <TData extends ColumnFilterType>(filter: TypedColumnFilter<TData>) => {
if (filter.value.type === ColumnFilterType.TEXT) {
return {
...filter.value,
data: {
...filter.value.data,
operand: filter.value.data.operand.toLowerCase(),
},
};
}
return filter.value;
};
const bookingStatusOrder = columnFilters.find((filter) => filter.id === "bookingStatusOrder");
const bookingAssignmentReason = columnFilters.find(
(filter) => filter.id === "bookingAssignmentReason"
) as TypedColumnFilter<ColumnFilterType.TEXT> | undefined;
const assignmentReasonValue = bookingAssignmentReason
? getLowercasedFilterValue(bookingAssignmentReason)
: undefined;
const bookingUid = columnFilters.find((filter) => filter.id === "bookingUid") as
| TypedColumnFilter<ColumnFilterType.TEXT>
| undefined;
const responseFilters = columnFilters.filter(
(filter) =>
filter.id !== "bookingStatusOrder" &&
filter.id !== "bookingAssignmentReason" &&
filter.id !== "bookingUid"
);
const whereClause: Prisma.RoutingFormResponseWhereInput = {
...(formsTeamWhereCondition.id !== undefined && {
formId: formsTeamWhereCondition.id as string | Prisma.StringFilter<"RoutingFormResponse">,
}),
...(formsTeamWhereCondition.teamId !== undefined && {
formTeamId: formsTeamWhereCondition.teamId as number | Prisma.IntFilter<"RoutingFormResponse">,
}),
...(formsTeamWhereCondition.userId !== undefined && {
formUserId: formsTeamWhereCondition.userId as number | Prisma.IntFilter<"RoutingFormResponse">,
}),
// bookingStatus
...(bookingStatusOrder &&
makeWhereClause({ columnName: "bookingStatusOrder", filterValue: bookingStatusOrder.value })),
// bookingAssignmentReason
...(assignmentReasonValue &&
makeWhereClause({
columnName: "bookingAssignmentReasonLowercase",
filterValue: assignmentReasonValue,
})),
// memberUserId
...(memberUserIds && memberUserIds.length > 0 && { bookingUserId: { in: memberUserIds } }),
// createdAt
...(startDate &&
endDate && {
createdAt: {
gte: dayjs(startDate).startOf("day").toDate(),
lte: dayjs(endDate).endOf("day").toDate(),
},
}),
// bookingUid
...(bookingUid &&
makeWhereClause({
columnName: "bookingUid",
filterValue: bookingUid.value,
})),
// AND clause
...(responseFilters.length > 0 && {
AND: responseFilters.map((fieldFilter) => {
return makeWhereClause({
columnName: "responseLowercase",
filterValue: getLowercasedFilterValue(fieldFilter),
json: { path: [fieldFilter.id, "value"] },
});
}),
}),
};
return whereClause;
}
static async getRoutingFormPaginatedResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
limit,
offset,
userId,
memberUserIds,
columnFilters,
sorting,
}: RoutingFormResponsesFilter) {
const whereClause = await this.getWhereClauseForRoutingFormResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
userId,
memberUserIds,
columnFilters,
sorting,
});
const totalResponsePromise = prisma.routingFormResponse.count({
where: whereClause,
});
const responsesPromise = prisma.routingFormResponse.findMany({
select: {
id: true,
response: true,
formId: true,
formName: true,
bookingUid: true,
bookingStatus: true,
bookingStatusOrder: true,
bookingCreatedAt: true,
bookingAttendees: true,
bookingUserId: true,
bookingUserName: true,
bookingUserEmail: true,
bookingUserAvatarUrl: true,
bookingAssignmentReason: true,
bookingStartTime: true,
bookingEndTime: true,
createdAt: true,
utm_source: true,
utm_medium: true,
utm_campaign: true,
utm_term: true,
utm_content: true,
},
where: whereClause,
orderBy: sorting && sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
take: limit,
skip: offset,
});
const [totalResponses, responses] = await Promise.all([totalResponsePromise, responsesPromise]);
type Response = Omit<
(typeof responses)[number],
"response" | "responseLowercase" | "bookingAttendees"
> & {
response: Record<string, ResponseValue>;
responseLowercase: Record<string, ResponseValue>;
bookingAttendees?: { name: string; email: string; timeZone: string }[];
};
return {
total: totalResponses,
data: responses as Response[],
};
}
static async getRoutingFormPaginatedResponsesForDownload({ static async getRoutingFormPaginatedResponsesForDownload({
teamId, headersPromise,
startDate, dataPromise,
endDate, }: {
isAll, headersPromise: ReturnType<typeof RoutingEventsInsights.getRoutingFormHeaders>;
organizationId, dataPromise: ReturnType<typeof InsightsRoutingBaseService.prototype.getTableData>;
routingFormId, }) {
limit,
offset,
userId,
memberUserIds,
columnFilters,
sorting,
}: RoutingFormResponsesFilter) {
const headersPromise = this.getRoutingFormHeaders({
userId,
teamId,
isAll,
organizationId,
routingFormId,
});
const dataPromise = this.getRoutingFormPaginatedResponses({
teamId,
startDate,
endDate,
isAll,
organizationId,
routingFormId,
userId,
memberUserIds,
limit,
offset,
columnFilters,
sorting,
});
const [headers, data] = await Promise.all([headersPromise, dataPromise]); const [headers, data] = await Promise.all([headersPromise, dataPromise]);
const dataWithFlatResponse = data.data.map((item) => { const dataWithFlatResponse = data.data.map((item) => {
const { bookingAttendees } = item; const bookingAttendees = item.bookingAttendees || [];
const responseParseResult = ZResponse.safeParse(item.response);
const response = responseParseResult.success ? responseParseResult.data : {};
const fields = (headers || []).reduce((acc, header) => { const fields = (headers || []).reduce((acc, header) => {
const id = header.id; const id = header.id;
if (!response[id]) { const field = item.fields.find((field) => field.fieldId === id);
if (!field) {
acc[header.label] = ""; acc[header.label] = "";
return acc; return acc;
} }
if (header.type === "select") { if (header.type === "select") {
acc[header.label] = header.options?.find((option) => option.id === response[id].value)?.label; acc[header.label] = header.options?.find((option) => option.id === field.valueString)?.label;
} else if (header.type === "multiselect" && Array.isArray(response[id].value)) { } else if (header.type === "multiselect" && Array.isArray(field.valueStringArray)) {
acc[header.label] = (response[id].value as string[]) acc[header.label] = field.valueStringArray
.map((value) => header.options?.find((option) => option.id === value)?.label) .map((value) => header.options?.find((option) => option.id === value)?.label)
.filter((label): label is string => label !== undefined) .filter((label): label is string => label !== undefined)
.sort() .sort()
.join(", "); .join(", ");
} else if (header.type === "number") {
acc[header.label] = field.valueNumber?.toString() || "";
} else { } else {
acc[header.label] = response[id].value as string; acc[header.label] = field.valueString || "";
} }
return acc; return acc;
}, {} as Record<string, string | undefined>); }, {} as Record<string, string | undefined>);
@@ -442,9 +163,6 @@ class RoutingEventsInsights {
"Booking Created At": item.bookingCreatedAt?.toISOString() || "", "Booking Created At": item.bookingCreatedAt?.toISOString() || "",
"Booking Start Time": item.bookingStartTime?.toISOString() || "", "Booking Start Time": item.bookingStartTime?.toISOString() || "",
"Booking End Time": item.bookingEndTime?.toISOString() || "", "Booking End Time": item.bookingEndTime?.toISOString() || "",
"Attendee Name": (bookingAttendees as any)?.[0]?.name,
"Attendee Email": (bookingAttendees as any)?.[0]?.email,
"Attendee Timezone": (bookingAttendees as any)?.[0]?.timeZone,
"Assignment Reason": item.bookingAssignmentReason || "", "Assignment Reason": item.bookingAssignmentReason || "",
"Routed To Name": item.bookingUserName || "", "Routed To Name": item.bookingUserName || "",
"Routed To Email": item.bookingUserEmail || "", "Routed To Email": item.bookingUserEmail || "",
@@ -454,6 +172,12 @@ class RoutingEventsInsights {
utm_campaign: item.utm_campaign || "", utm_campaign: item.utm_campaign || "",
utm_term: item.utm_term || "", utm_term: item.utm_term || "",
utm_content: item.utm_content || "", utm_content: item.utm_content || "",
...((bookingAttendees || [])
.filter((attendee) => typeof attendee.name === "string" && typeof attendee.email === "string")
.reduce((acc, attendee, index) => {
acc[`Attendee ${index + 1}`] = `${attendee.name} (${attendee.email})`;
return acc;
}, {} as Record<string, string>) || {}),
}; };
}); });
@@ -4,8 +4,8 @@ import { z } from "zod";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { import {
rawDataInputSchema, rawDataInputSchema,
routingFormResponsesInputSchema, insightsRoutingServiceInputSchema,
routingFormStatsInputSchema, insightsRoutingServicePaginatedInputSchema,
routingRepositoryBaseInputSchema, routingRepositoryBaseInputSchema,
bookingRepositoryBaseInputSchema, bookingRepositoryBaseInputSchema,
} from "@calcom/features/insights/server/raw-data.schema"; } from "@calcom/features/insights/server/raw-data.schema";
@@ -827,55 +827,44 @@ export const insightsRouter = router({
}); });
}), }),
routingFormsByStatus: userBelongsToTeamProcedure routingFormsByStatus: userBelongsToTeamProcedure
.input(routingFormStatsInputSchema) .input(insightsRoutingServiceInputSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormStats({ const insightsRoutingService = createInsightsRoutingService(ctx, input);
teamId: input.teamId, return await insightsRoutingService.getRoutingFormStats();
startDate: input.startDate,
endDate: input.endDate,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
columnFilters: input.columnFilters,
sorting: input.sorting,
});
}), }),
routingFormResponses: userBelongsToTeamProcedure routingFormResponses: userBelongsToTeamProcedure
.input(routingFormResponsesInputSchema) .input(insightsRoutingServicePaginatedInputSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormPaginatedResponses({ const insightsRoutingService = createInsightsRoutingService(ctx, input);
teamId: input.teamId, return await insightsRoutingService.getTableData({
startDate: input.startDate, sorting: input.sorting,
endDate: input.endDate,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
limit: input.limit, limit: input.limit,
offset: input.offset, offset: input.offset,
columnFilters: input.columnFilters,
sorting: input.sorting,
}); });
}), }),
routingFormResponsesForDownload: userBelongsToTeamProcedure routingFormResponsesForDownload: userBelongsToTeamProcedure
.input(routingFormResponsesInputSchema) .input(insightsRoutingServicePaginatedInputSchema)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({ const headersPromise = RoutingEventsInsights.getRoutingFormHeaders({
teamId: input.teamId,
startDate: input.startDate,
endDate: input.endDate,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
userId: ctx.user.id, userId: ctx.user.id,
memberUserIds: input.memberUserIds, teamId: input.selectedTeamId,
limit: input.limit ?? BATCH_SIZE, isAll: input.scope === "org",
offset: input.offset, organizationId: ctx.user.organizationId,
columnFilters: input.columnFilters, routingFormId: (input.columnFilters || []).find((filter) => filter.id === "formId")?.value?.data as
| string
| undefined,
});
const insightsRoutingService = createInsightsRoutingService(ctx, input);
const dataPromise = insightsRoutingService.getTableData({
sorting: input.sorting, sorting: input.sorting,
limit: input.limit,
offset: input.offset,
});
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
headersPromise,
dataPromise,
}); });
}), }),
getRoutingFormFieldOptions: userBelongsToTeamProcedure getRoutingFormFieldOptions: userBelongsToTeamProcedure
@@ -136,13 +136,11 @@ export const useSchedule = ({
eventTypeId: eventId ?? undefined, eventTypeId: eventId ?? undefined,
}); });
const schedule = isTeamEvent const schedule = trpc.viewer.slots.getSchedule.useQuery(input, {
? trpc.viewer.highPerf.getTeamSchedule.useQuery(input, { ...options,
...options, // Only enable if we're not using API V2
// Only enable if we're not using API V2 enabled: options.enabled && !isCallingApiV2Slots,
enabled: options.enabled && !isCallingApiV2Slots, });
})
: trpc.viewer.slots.getSchedule.useQuery(input, options);
if (isCallingApiV2Slots && !teamScheduleV2.failureReason) { if (isCallingApiV2Slots && !teamScheduleV2.failureReason) {
updateEmbedBookerState({ updateEmbedBookerState({
+4 -1
View File
@@ -2,6 +2,7 @@
import { sortBy } from "lodash"; import { sortBy } from "lodash";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { MeetLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils"; import getApps from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { getUid } from "@calcom/lib/CalEventParser"; import { getUid } from "@calcom/lib/CalEventParser";
@@ -494,12 +495,14 @@ const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => {
calendarDescription: getRichDescription(calEvent), calendarDescription: getRichDescription(calEvent),
}; };
const isMeetLocationType = calEvent.location === MeetLocationType;
// Determine if the calendar event should include attendees // Determine if the calendar event should include attendees
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",") const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
.filter((domain) => domain.trim() !== "") .filter((domain) => domain.trim() !== "")
.some((domain) => calEvent.organizer.email.toLowerCase().endsWith(domain.toLowerCase())); .some((domain) => calEvent.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
if (calEvent.hideOrganizerEmail && !isOrganizerExempt) { if (calEvent.hideOrganizerEmail && !isOrganizerExempt && !isMeetLocationType) {
calendarEvent.attendees = []; calendarEvent.attendees = [];
} }
+1
View File
@@ -56,6 +56,7 @@ const user: User & { credentials: CredentialPayload[] } = {
timeFormat: 12, timeFormat: 12,
travelSchedules: [], travelSchedules: [],
locked: false, locked: false,
isPlatformManaged: false,
}; };
const customInputs: CustomInputSchema[] = []; const customInputs: CustomInputSchema[] = [];
@@ -0,0 +1,18 @@
import { useEffect, useRef } from "react";
export function useElementByClassName<T extends HTMLElement = HTMLDivElement>(
className?: string
): React.RefObject<T> {
const elementRef = useRef<T | null>(null);
useEffect(() => {
if (className) {
const element = document.getElementsByClassName(className)[0] as T;
elementRef.current = element || null;
} else {
elementRef.current = null;
}
}, [className]);
return elementRef;
}
+1
View File
@@ -15,6 +15,7 @@
"@formbricks/api": "^1.7.0", "@formbricks/api": "^1.7.0",
"@formbricks/js": "^2.1.0", "@formbricks/js": "^2.1.0",
"@sendgrid/client": "^7.7.0", "@sendgrid/client": "^7.7.0",
"@unkey/ratelimit": "^2.1.3",
"@vercel/og": "^0.6.3", "@vercel/og": "^0.6.3",
"city-timezones": "^1.2.1", "city-timezones": "^1.2.1",
"i18next": "^23.2.3", "i18next": "^23.2.3",
+17
View File
@@ -30,6 +30,16 @@ export function rateLimiter() {
ms: 5000, ms: 5000,
}; };
const onError = (err: Error, identifier: string) => {
log.error("Unkey rate limiter encountered unknown error", {
error: err.message,
stack: err.stack,
identifier,
timestamp: new Date().toISOString(),
});
return { success: true, limit: 10, remaining: 999, reset: 0 };
};
const limiter = { const limiter = {
core: new Ratelimit({ core: new Ratelimit({
rootKey: UNKEY_ROOT_KEY, rootKey: UNKEY_ROOT_KEY,
@@ -37,6 +47,7 @@ export function rateLimiter() {
limit: 10, limit: 10,
duration: "60s", duration: "60s",
timeout, timeout,
onError,
}), }),
common: new Ratelimit({ common: new Ratelimit({
rootKey: UNKEY_ROOT_KEY, rootKey: UNKEY_ROOT_KEY,
@@ -44,6 +55,7 @@ export function rateLimiter() {
limit: 200, limit: 200,
duration: "60s", duration: "60s",
timeout, timeout,
onError,
}), }),
forcedSlowMode: new Ratelimit({ forcedSlowMode: new Ratelimit({
rootKey: UNKEY_ROOT_KEY, rootKey: UNKEY_ROOT_KEY,
@@ -51,6 +63,7 @@ export function rateLimiter() {
limit: 1, limit: 1,
duration: "30s", duration: "30s",
timeout, timeout,
onError,
}), }),
api: new Ratelimit({ api: new Ratelimit({
rootKey: UNKEY_ROOT_KEY, rootKey: UNKEY_ROOT_KEY,
@@ -58,6 +71,7 @@ export function rateLimiter() {
limit: API_KEY_RATE_LIMIT, limit: API_KEY_RATE_LIMIT,
duration: "60s", duration: "60s",
timeout, timeout,
onError,
}), }),
ai: new Ratelimit({ ai: new Ratelimit({
rootKey: UNKEY_ROOT_KEY, rootKey: UNKEY_ROOT_KEY,
@@ -65,6 +79,7 @@ export function rateLimiter() {
limit: 20, limit: 20,
duration: "1d", duration: "1d",
timeout, timeout,
onError,
}), }),
sms: new Ratelimit({ sms: new Ratelimit({
rootKey: UNKEY_ROOT_KEY, rootKey: UNKEY_ROOT_KEY,
@@ -72,6 +87,7 @@ export function rateLimiter() {
limit: 50, limit: 50,
duration: "5m", duration: "5m",
timeout, timeout,
onError,
}), }),
smsMonth: new Ratelimit({ smsMonth: new Ratelimit({
rootKey: UNKEY_ROOT_KEY, rootKey: UNKEY_ROOT_KEY,
@@ -79,6 +95,7 @@ export function rateLimiter() {
limit: 250, limit: 250,
duration: "30d", duration: "30d",
timeout, timeout,
onError,
}), }),
}; };
@@ -296,7 +296,7 @@ describe("FilterSegmentRepository.get()", () => {
}); });
expect(result.segments).toHaveLength(1); expect(result.segments).toHaveLength(1);
expect(result.preferredSegmentId).toBe(segment.id); expect(result.preferredSegmentId).toEqual({ id: segment.id, type: "user" });
expect(result.segments[0].name).toBe("Important Contacts"); expect(result.segments[0].name).toBe("Important Contacts");
}); });
+93 -14
View File
@@ -1,4 +1,4 @@
import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types"; import type { FilterSegmentOutput, SegmentIdentifier } from "@calcom/features/data-table/lib/types";
import { import {
ZActiveFilters, ZActiveFilters,
ZSortingState, ZSortingState,
@@ -6,14 +6,14 @@ import {
ZColumnVisibility, ZColumnVisibility,
} from "@calcom/features/data-table/lib/types"; } from "@calcom/features/data-table/lib/types";
import { prisma } from "@calcom/prisma"; import { prisma } from "@calcom/prisma";
import type { FilterSegment, UserFilterSegmentPreference } from "@calcom/prisma/client"; import type { UserFilterSegmentPreference } from "@calcom/prisma/client";
import type { TCreateFilterSegmentInputSchema, TUpdateFilterSegmentInputSchema } from "./filterSegment.type"; import type { TCreateFilterSegmentInputSchema, TUpdateFilterSegmentInputSchema } from "./filterSegment.type";
export interface IFilterSegmentRepository { export interface IFilterSegmentRepository {
get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }): Promise<{ get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }): Promise<{
segments: FilterSegmentOutput[]; segments: FilterSegmentOutput[];
preferredSegmentId: number | null; preferredSegmentId: SegmentIdentifier | null;
}>; }>;
create({ create({
@@ -22,7 +22,7 @@ export interface IFilterSegmentRepository {
}: { }: {
userId: number; userId: number;
input: TCreateFilterSegmentInputSchema; input: TCreateFilterSegmentInputSchema;
}): Promise<FilterSegment>; }): Promise<FilterSegmentOutput>;
update({ update({
userId, userId,
@@ -30,7 +30,7 @@ export interface IFilterSegmentRepository {
}: { }: {
userId: number; userId: number;
input: TUpdateFilterSegmentInputSchema; input: TUpdateFilterSegmentInputSchema;
}): Promise<FilterSegment>; }): Promise<FilterSegmentOutput>;
delete({ userId, id }: { userId: number; id: number }): Promise<void>; delete({ userId, id }: { userId: number; id: number }): Promise<void>;
@@ -41,7 +41,7 @@ export interface IFilterSegmentRepository {
}: { }: {
userId: number; userId: number;
tableIdentifier: string; tableIdentifier: string;
segmentId: number | null; segmentId: SegmentIdentifier | null;
}): Promise<UserFilterSegmentPreference | null>; }): Promise<UserFilterSegmentPreference | null>;
} }
@@ -124,12 +124,19 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
}, },
select: { select: {
segmentId: true, segmentId: true,
systemSegmentId: true,
}, },
}); });
const preferredSegmentId = preference?.segmentId
? { id: preference.segmentId, type: "user" as const }
: preference?.systemSegmentId
? { id: preference.systemSegmentId, type: "system" as const }
: null;
return { return {
segments: parsedSegments, segments: parsedSegments,
preferredSegmentId: preference?.segmentId || null, preferredSegmentId,
}; };
} }
@@ -174,9 +181,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
...(scope === "TEAM" ? { teamId } : {}), ...(scope === "TEAM" ? { teamId } : {}),
userId, userId,
}, },
select: {
id: true,
name: true,
tableIdentifier: true,
scope: true,
activeFilters: true,
sorting: true,
columnVisibility: true,
columnSizing: true,
perPage: true,
searchTerm: true,
createdAt: true,
updatedAt: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
name: true,
},
},
},
}); });
return filterSegment; // Parse the segment with Zod schemas
const parsedSegment: FilterSegmentOutput = {
...filterSegment,
activeFilters: ZActiveFilters.catch([]).parse(filterSegment.activeFilters),
sorting: ZSortingState.catch([]).parse(filterSegment.sorting),
columnVisibility: ZColumnVisibility.catch({}).parse(filterSegment.columnVisibility),
columnSizing: ZColumnSizing.catch({}).parse(filterSegment.columnSizing),
};
return parsedSegment;
} }
async update({ userId, input }: { userId: number; input: TUpdateFilterSegmentInputSchema }) { async update({ userId, input }: { userId: number; input: TUpdateFilterSegmentInputSchema }) {
@@ -228,9 +266,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
perPage, perPage,
searchTerm, searchTerm,
}, },
select: {
id: true,
name: true,
tableIdentifier: true,
scope: true,
activeFilters: true,
sorting: true,
columnVisibility: true,
columnSizing: true,
perPage: true,
searchTerm: true,
createdAt: true,
updatedAt: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
name: true,
},
},
},
}); });
return updatedSegment; // Parse the segment with Zod schemas
const parsedSegment: FilterSegmentOutput = {
...updatedSegment,
activeFilters: ZActiveFilters.catch([]).parse(updatedSegment.activeFilters),
sorting: ZSortingState.catch([]).parse(updatedSegment.sorting),
columnVisibility: ZColumnVisibility.catch({}).parse(updatedSegment.columnVisibility),
columnSizing: ZColumnSizing.catch({}).parse(updatedSegment.columnSizing),
};
return parsedSegment;
} }
async delete({ userId, id }: { userId: number; id: number }) { async delete({ userId, id }: { userId: number; id: number }) {
@@ -282,7 +351,7 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
}: { }: {
userId: number; userId: number;
tableIdentifier: string; tableIdentifier: string;
segmentId: number | null; segmentId: SegmentIdentifier | null;
}) { }) {
if (segmentId === null) { if (segmentId === null) {
await prisma.userFilterSegmentPreference.deleteMany({ await prisma.userFilterSegmentPreference.deleteMany({
@@ -294,6 +363,18 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
return null; return null;
} }
const isSystemSegment = segmentId.type === "system";
const updateData = isSystemSegment
? {
segmentId: null,
systemSegmentId: segmentId.id,
}
: {
segmentId: segmentId.id,
systemSegmentId: null,
};
const preference = await prisma.userFilterSegmentPreference.upsert({ const preference = await prisma.userFilterSegmentPreference.upsert({
where: { where: {
userId_tableIdentifier: { userId_tableIdentifier: {
@@ -301,13 +382,11 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
tableIdentifier, tableIdentifier,
}, },
}, },
update: { update: updateData,
segmentId,
},
create: { create: {
userId, userId,
tableIdentifier, tableIdentifier,
segmentId, ...updateData,
}, },
}); });
@@ -1,6 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types";
import { import {
ZActiveFilters, ZActiveFilters,
ZSortingState, ZSortingState,
@@ -8,11 +7,6 @@ import {
ZColumnVisibility, ZColumnVisibility,
} from "@calcom/features/data-table/lib/types"; } from "@calcom/features/data-table/lib/types";
export type FilterSegmentsListResponse = {
segments: FilterSegmentOutput[];
preferredSegmentId: number | null;
};
export const ZListFilterSegmentsInputSchema = z.object({ export const ZListFilterSegmentsInputSchema = z.object({
tableIdentifier: z.string(), tableIdentifier: z.string(),
}); });
@@ -58,9 +52,14 @@ export const ZCreateFilterSegmentInputSchema = z.discriminatedUnion("scope", [
export type TCreateFilterSegmentInputSchema = z.infer<typeof ZCreateFilterSegmentInputSchema>; export type TCreateFilterSegmentInputSchema = z.infer<typeof ZCreateFilterSegmentInputSchema>;
const ZSegmentIdentifier = z.discriminatedUnion("type", [
z.object({ id: z.string(), type: z.literal("system") }),
z.object({ id: z.number(), type: z.literal("user") }),
]);
export const ZSetFilterSegmentPreferenceInputSchema = z.object({ export const ZSetFilterSegmentPreferenceInputSchema = z.object({
tableIdentifier: z.string(), tableIdentifier: z.string(),
segmentId: z.number().nullable(), segmentId: ZSegmentIdentifier.nullable(),
}); });
export type TSetFilterSegmentPreferenceInputSchema = z.infer<typeof ZSetFilterSegmentPreferenceInputSchema>; export type TSetFilterSegmentPreferenceInputSchema = z.infer<typeof ZSetFilterSegmentPreferenceInputSchema>;
@@ -298,8 +298,16 @@ export class InsightsBookingBaseService {
return null; return null;
} }
if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") { if (id === "eventTypeId" && isMultiSelectFilterValue(value)) {
return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`; const eventTypeIds = value.data.map((id) => Number(id));
if (eventTypeIds.length === 0) {
return null;
}
return Prisma.sql`("eventTypeId" IN (${Prisma.join(eventTypeIds)}) OR "eventParentId" IN (${Prisma.join(
eventTypeIds
)}))`;
} }
if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") { if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {

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