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",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.318",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
+10
View File
@@ -9,6 +9,7 @@ import { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -33,6 +34,7 @@ describe("AppController", () => {
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeyString: string;
@@ -108,6 +110,14 @@ describe("AppController", () => {
organization: { connect: { id: organization.id } },
});
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
app = moduleRef.createNestApplication();
await app.init();
});
@@ -18,7 +18,6 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
@@ -27,7 +26,6 @@ import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@cal
import {
CancelBookingInput_2024_08_13,
CancelSeatedBookingInput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
@@ -35,11 +33,9 @@ import {
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
import { Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
describe("Seated bookings", () => {
let app: INestApplication;
let organization: Team;
@@ -48,7 +44,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
@@ -58,7 +53,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let apiKeyString: string;
let seatedEventTypeId: number;
const maxRecurrenceCount = 3;
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
@@ -83,7 +77,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
@@ -190,6 +183,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -264,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid: createdSeatedBooking.seatUid,
bookingFieldsResponses: {
guests: [],
name: createdSeatedBooking.attendees[0].name,
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
},
@@ -278,6 +273,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -517,6 +513,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -21,7 +21,12 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
@@ -44,6 +49,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let normalEventType: EventType;
const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let variableLengthRecurringEventType: EventType;
const VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG = `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -105,6 +112,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
user.id
);
variableLengthRecurringEventType = await eventTypesRepositoryFixture.create(
{
title: `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`,
slug: VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG,
length: 15,
metadata: { multipleDuration: [15, 30, 60] },
recurringEvent: { freq: 2, count: 3, interval: 1 }, // Weekly, 3 times, every 1 week
},
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -246,6 +264,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("create recurring bookings", () => {
it("should create recurring bookings with custom lengthInMinutes", async () => {
const lengthInMinutes = 30;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 1, 14, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes,
recurrenceCount: 2,
attendee: {
name: "Mr Recurring",
email: "mr_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: { status: string; data: RecurringBookingOutput_2024_08_13[] } = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data).toHaveLength(2);
const firstBooking = responseBody.data[0];
const secondBooking = responseBody.data[1];
expect(firstBooking.duration).toEqual(lengthInMinutes);
expect(secondBooking.duration).toEqual(lengthInMinutes);
});
});
it("should reject recurring booking with invalid lengthInMinutes", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 3, 1, 10, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes: 45, // Not in allowed [15, 30, 60]
recurrenceCount: 2,
attendee: {
name: "Mr Invalid",
email: "mr_invalid@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -420,6 +420,9 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
}
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
const repeatsEvery = occurrence.interval;
@@ -456,7 +459,7 @@ export class InputBookingsService_2024_08_13 {
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
for (let i = 0; i < repeatsTimes; i++) {
const endTime = startTime.plus({ minutes: eventType.length });
const endTime = startTime.plus({ minutes: lengthInMinutes });
events.push({
start: startTime.toISO(),
@@ -1,6 +1,5 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
@@ -4,10 +4,12 @@ import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/ato
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
import { AtomsVerificationController } from "@/modules/atoms/controllers/atoms.verification.controller";
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
@@ -32,6 +34,7 @@ import { Module } from "@nestjs/common";
AtomsRepository,
UsersService,
SchedulesAtomsService,
VerificationAtomsService,
RedisService,
],
exports: [EventTypesAtomService],
@@ -40,6 +43,7 @@ import { Module } from "@nestjs/common";
AtomsEventTypesController,
AtomsConferencingAppsController,
AtomsSchedulesController,
AtomsVerificationController,
],
})
export class AtomsModule {}
@@ -0,0 +1,108 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
Body,
UseGuards,
Version,
VERSION_NEUTRAL,
HttpCode,
HttpStatus,
Get,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - verification endpoints for atoms")
@DocsExcludeController(true)
export class AtomsVerificationController {
constructor(private readonly verificationService: VerificationAtomsService) {}
@Post("/verification/email/send-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "atoms_verification_email_send_code" })
async sendEmailVerificationCode(
@Body() body: SendVerificationEmailInput
): Promise<SendVerificationEmailOutput> {
const result = await this.verificationService.sendEmailVerificationCode({
email: body.email,
username: body.username,
language: body.language,
isVerifyingEmail: body.isVerifyingEmail,
});
return {
data: { sent: result.ok && !result.skipped },
status: SUCCESS_STATUS,
};
}
@Get("/verification/email/check")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async checkEmailVerificationRequired(
@Query() query: CheckEmailVerificationRequiredParams
): Promise<ApiResponse<boolean>> {
const required = await this.verificationService.checkEmailVerificationRequired({
email: query.email,
userSessionEmail: query.userSessionEmail,
});
return {
data: required,
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async verifyEmailCode(@Body() body: VerifyEmailCodeInput): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeUnAuthenticated({
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code-authenticated")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async verifyEmailCodeAuthenticated(
@Body() body: VerifyEmailCodeInput,
@GetUser() user: UserWithProfile
): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeAuthenticated(user, {
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString } from "class-validator";
export class CheckEmailVerificationRequiredParams {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "user@example.com" })
@IsOptional()
@IsString()
userSessionEmail?: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString, IsBoolean } from "class-validator";
export class SendVerificationEmailInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "johndoe" })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ example: "en" })
@IsOptional()
@IsString()
language?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isVerifyingEmail?: boolean;
}
@@ -0,0 +1,12 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsString } from "class-validator";
export class VerifyEmailCodeInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiProperty({ example: "123456" })
@IsString()
code!: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class SendVerificationEmailData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly sent!: boolean;
}
export class SendVerificationEmailOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => SendVerificationEmailData)
@Expose()
@ApiProperty({ type: SendVerificationEmailData })
readonly data!: SendVerificationEmailData;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class VerifyEmailCodeData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly verified!: boolean;
}
export class VerifyEmailCodeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => VerifyEmailCodeData)
@Expose()
@ApiProperty({ type: VerifyEmailCodeData })
readonly data!: VerifyEmailCodeData;
}
@@ -0,0 +1,64 @@
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
import {
verifyCodeUnAuthenticated,
verifyCodeAuthenticated,
sendEmailVerificationByCode,
checkEmailVerificationRequired,
} from "@calcom/platform-libraries";
@Injectable()
export class VerificationAtomsService {
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
return await checkEmailVerificationRequired(input);
}
async verifyEmailCodeUnAuthenticated(input: VerifyEmailCodeInput) {
try {
return await verifyCodeUnAuthenticated(input);
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email and code are required");
}
}
throw new BadRequestException("Verification failed");
}
}
async verifyEmailCodeAuthenticated(user: UserWithProfile, input: VerifyEmailCodeInput) {
try {
return await verifyCodeAuthenticated({
user,
email: input.email,
code: input.code,
});
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email, code, and user ID are required");
}
}
throw new UnauthorizedException("Verification failed");
}
}
async sendEmailVerificationCode(input: SendVerificationEmailInput) {
return await sendEmailVerificationByCode({
email: input.email,
username: input.username,
language: input.language,
isVerifyingEmail: input.isVerifyingEmail,
});
}
}
@@ -6,7 +6,6 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -22,7 +21,6 @@ import { PassportModule } from "@nestjs/passport";
MembershipsModule,
TokensModule,
DeploymentsModule,
ProfilesModule,
],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard],
@@ -4,6 +4,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { JwtService } from "@/modules/jwt/jwt.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
@@ -20,6 +21,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { createRequest } from "node-mocks-http";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
@@ -45,6 +47,7 @@ describe("ApiAuthStrategy", () => {
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
@@ -65,6 +68,7 @@ describe("ApiAuthStrategy", () => {
}),
ProfilesModule,
TokensModule,
MembershipsModule,
],
providers: [
MockedRedisService,
@@ -91,6 +95,7 @@ describe("ApiAuthStrategy", () => {
teamRepositoryFixture = new TeamRepositoryFixture(module);
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
membershipRepositoryFixture = new MembershipRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
organizationTwo = await teamRepositoryFixture.create({
name: `api-auth-organization-2-${randomString()}`,
@@ -121,6 +126,20 @@ describe("ApiAuthStrategy", () => {
organization: { connect: { id: organizationTwo.id } },
});
await membershipRepositoryFixture.create({
user: { connect: { id: validOAuthUser.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
await membershipRepositoryFixture.create({
user: { connect: { id: validOAuthUser.id } },
team: { connect: { id: organizationTwo.id } },
role: "OWNER",
accepted: true,
});
const data = {
logo: "logo-url",
name: "name",
@@ -4,9 +4,9 @@ import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service";
import { UsersService } from "@/modules/users/services/users.service";
@@ -45,8 +45,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
private readonly userRepository: UsersRepository,
private readonly apiKeyRepository: ApiKeysRepository,
private readonly oauthRepository: OAuthClientRepository,
private readonly profilesRepository: ProfilesRepository,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly membershipsRepository: MembershipsRepository
) {
super();
}
@@ -172,7 +172,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
}
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
const platformCreatorId =
(await this.membershipsRepository.findPlatformOwnerUserId(client.organizationId)) ||
(await this.membershipsRepository.findPlatformAdminUserId(client.organizationId));
if (!platformCreatorId) {
throw new UnauthorizedException(
@@ -20,6 +20,36 @@ export class MembershipsRepository {
return membership;
}
async findPlatformOwnerUserId(organizationId: number): Promise<number | undefined> {
const owner = await this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
role: "OWNER",
accepted: true,
},
select: {
userId: true,
},
});
return owner?.userId ?? undefined;
}
async findPlatformAdminUserId(organizationId: number): Promise<number | undefined> {
const admin = await this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
role: "ADMIN",
accepted: true,
},
select: {
userId: true,
},
});
return admin?.userId ?? undefined;
}
async findMembershipByTeamId(teamId: number, userId: number) {
const membership = await this.dbRead.prisma.membership.findUnique({
where: {
@@ -15,6 +15,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -63,6 +64,7 @@ describe("OAuthFlow Endpoints", () => {
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let user: User;
let organization: Team;
@@ -89,6 +91,7 @@ describe("OAuthFlow Endpoints", () => {
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await usersRepositoryFixtures.create({
email: userEmail,
@@ -104,6 +107,13 @@ describe("OAuthFlow Endpoints", () => {
movedFromUser: { connect: { id: user.id } },
organization: { connect: { id: organization.id } },
});
await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
oAuthClient = await createOAuthClient(organization.id);
});
@@ -39,6 +39,7 @@ import {
HttpStatus,
NotFoundException,
Query,
Logger,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
@@ -66,6 +67,8 @@ export type EventTypeHandlerResponse = {
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsEventTypesController {
private readonly logger = new Logger("OrganizationsEventTypesController");
constructor(
private readonly organizationsEventTypesService: OrganizationsEventTypesService,
private readonly inputService: InputOrganizationsEventTypesService,
@@ -88,6 +91,10 @@ export class OrganizationsEventTypesController {
teamId,
bodyEventType
);
this.logger.debug(
"nl debug - create org team event type - transformedBody",
JSON.stringify(transformedBody, null, 2)
);
const eventType = await this.organizationsEventTypesService.createOrganizationTeamEventType(
user,
@@ -47,6 +47,10 @@ export class OrganizationsEventTypesService {
prisma: this.dbWrite.prisma,
},
});
this.logger.debug(
"nl debug - create org team event type - eventTypeCreated",
JSON.stringify(eventTypeCreated, null, 2)
);
return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user, true);
}
@@ -133,6 +133,7 @@ export class TeamsEventTypesService {
});
const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId);
this.logger.debug("nl debug - update team event type - eventType", JSON.stringify(eventType, null, 2));
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
@@ -1,6 +1,6 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps, SearchParams } from "app/_types";
import type { PageProps, Params, SearchParams } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
@@ -15,20 +15,34 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import LegacyPage from "~/team/type-view";
import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
import CachedTeamBooker, {
generateMetadata as generateCachedMetadata,
getOrgContext,
} from "./pageWithCachedData";
import { getTeamId } from "./queries";
async function isCachedTeamBookingEnabled(params: Params, searchParams: SearchParams): Promise<boolean> {
if (searchParams.experimentalTeamBookingPageCache !== "true") return false;
const { teamSlug, currentOrgDomain, isValidOrgDomain } = await getOrgContext(params);
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
const teamId = await getTeamId(teamSlug, orgSlug);
if (!teamId) return false;
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository(prisma);
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
const isTeamFeatureEnabled = await featuresRepository.checkIfTeamHasFeature(
teamId,
"team-booking-page-cache"
);
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true";
return isTeamFeatureEnabled;
}
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await generateCachedMetadata({ params, searchParams });
}
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx);
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
@@ -68,7 +82,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await CachedTeamBooker({ params, searchParams });
}
@@ -41,7 +41,7 @@ const _getTeamMetadataForBooking = (teamData: NonNullable<TeamData>, eventTypeId
};
};
async function _getOrgContext(params: Params) {
export async function getOrgContext(params: Params) {
const result = paramsSchema.safeParse({
slug: params?.slug,
type: params?.type,
@@ -74,7 +74,7 @@ const _getMultipleDurationValue = (
};
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
if (!teamData) return {}; // should never happen
@@ -123,7 +123,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
};
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
// Handle org redirects
@@ -13,6 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums";
@@ -158,3 +159,14 @@ export async function getCRMData(
crmRecordId,
};
}
export async function getTeamId(teamSlug: string, orgSlug: string | null): Promise<number | null> {
const teamRepo = new TeamRepository(prisma);
const team = await teamRepo.findFirstBySlugAndParentSlug({
slug: teamSlug,
parentSlug: orgSlug,
select: { id: true },
});
return team?.id ?? null;
}
@@ -1,9 +1,14 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsList from "~/bookings/views/bookings-listing-view";
@@ -26,10 +31,11 @@ const Page = async ({ params }: PageProps) => {
redirect("/bookings/upcoming");
}
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<BookingsList status={parsed.data.status} />
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
</ShellMainAppDir>
);
};
+1 -1
View File
@@ -910,7 +910,7 @@ export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: Rerout
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow preventCloseOnOutsideClick>
<DialogContent preventCloseOnOutsideClick>
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
<RerouteDialogContentAndFooter
booking={teamEventTypeBooking}
+39 -33
View File
@@ -3,42 +3,48 @@
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
import * as Sentry from "@sentry/nextjs";
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
if (process.env.NODE_ENV === "production") {
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
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.
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.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.
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled.
// 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
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
// Define how likely Replay events are sampled.
// 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
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
// 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,
// 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,
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: !!process.env.SENTRY_DEBUG,
beforeSend(event) {
if (
event.exception?.values?.some(
(e) =>
// 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
e.value?.includes("Non-Error promise rejection captured with") ||
e.value?.includes("Object Not Found Matching Id")
)
) {
return null;
}
// Setting this option to true will print useful information to the console while you're setting up Sentry.
debug: !!process.env.SENTRY_DEBUG,
beforeSend(event) {
if (
event.exception?.values?.some(
(e) =>
// 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
e.value?.includes("Non-Error promise rejection captured with") ||
e.value?.includes("Object Not Found Matching Id")
)
) {
return null;
}
event.tags = {
...event.tags,
errorSource: "client",
};
return event;
},
});
event.tags = {
...event.tags,
errorSource: "client",
};
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";
export async function register() {
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") {
await import("./sentry.edge.config");
if (process.env.NODE_ENV === "production") {
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") {
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,
email: true,
timeZone: true,
isPlatformManaged: true,
};
const eventType = await prisma.eventType.findUnique({
where: {
@@ -75,6 +76,7 @@ export const getEventTypesFromDB = async (id: number) => {
hideBranding: true,
},
},
createdByOAuthClientId: true,
},
},
workflows: {
-16
View File
@@ -60,22 +60,6 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
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);
if (routingFormRewriteResponse) {
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
@@ -1,7 +1,7 @@
"use client";
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 { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
@@ -17,6 +17,7 @@ import {
ZMultiSelectFilterValue,
ZDateRangeFilterValue,
ZTextFilterValue,
type SystemFilterSegment,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -55,11 +56,45 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
type BookingsProps = {
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) {
const pathname = usePathname();
const systemSegments = useSystemSegments(props.userId);
return (
<DataTableProvider useSegments={useSegments}>
<DataTableProvider
useSegments={useSegments}
systemSegments={systemSegments}
tableIdentifier={pathname || undefined}>
<BookingsContent {...props} />
</DataTableProvider>
);
@@ -228,16 +228,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
: previousBooking;
const isPlatformBooking = eventType.users[0]?.isPlatformManaged || eventType.team?.createdByOAuthClientId;
return {
props: {
orgSlug: currentOrgDomain,
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
hideBranding: await shouldHideBrandingForEvent({
eventTypeId: eventType.id,
team: eventType.team,
owner: eventType.users[0] ?? null,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
}),
hideBranding: isPlatformBooking
? true
: await shouldHideBrandingForEvent({
eventTypeId: eventType.id,
team: eventType.team,
owner: eventType.users[0] ?? null,
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
}),
profile,
eventType,
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]",
"[--cal-brand-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1]",
"[--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"
key={pathname}>
+2 -1
View File
@@ -7,6 +7,7 @@ import {
ColumnFilterType,
type FilterableColumn,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import {
AverageEventDurationChart,
BookingKPICards,
@@ -31,7 +32,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
export default function InsightsPage({ timeZone }: { timeZone: string }) {
return (
<DataTableProvider timeZone={timeZone}>
<DataTableProvider useSegments={useSegments} timeZone={timeZone}>
<InsightsOrgTeamsProvider>
<InsightsPageContent />
</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",
"version": "5.6.2",
"version": "5.6.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -29,7 +29,7 @@
"yarn": "3.4.1"
},
"dependencies": {
"@boxyhq/saml-jackson": "1.42.0",
"@boxyhq/saml-jackson": "1.52.2",
"@calcom/app-store": "*",
"@calcom/app-store-cli": "*",
"@calcom/dayjs": "*",
@@ -77,7 +77,6 @@
"@tanstack/react-query": "^5.17.15",
"@team-plain/typescript-sdk": "^5.9.0",
"@types/turndown": "^5.0.1",
"@unkey/ratelimit": "^0.1.1",
"@upstash/redis": "^1.35.2",
"@vercel/edge-config": "^0.1.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) {
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).waitFor();
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) {
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 prisma from "@calcom/prisma";
import { addFilter, openFilter, clearFilters } from "./filter-helpers";
import { clearFilters, applySelectFilter } from "./filter-helpers";
import { test } from "./lib/fixtures";
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.keyboard.press("Escape");
// Choose User filter item from dropdown
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 applySelectFilter(page, "userId", member.username || "");
await clearFilters(page);
await expect(page.url()).not.toContain("userId");
await expect(page).not.toHaveURL(/[?&]userId=/);
});
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: {
tailwindcss: {},
autoprefixer: {},
},
};
if (process.env.NODE_ENV === "production") {
config.plugins.autoprefixer = {};
}
module.exports = config;
@@ -3472,5 +3472,7 @@
"webhook_metadata": "Metadata",
"stats": "Stats",
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
-12
View File
@@ -41,18 +41,6 @@
},
"pages/api/trpc/workflows/[trpc].ts": {
"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. |
| 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 |
| 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
-1
View File
@@ -118,7 +118,6 @@
"@daily-co/daily-js": "^0.76.0",
"@evyweb/ioctopus": "^1.2.0",
"@next/third-parties": "^14.2.5",
"@unkey/ratelimit": "^2.1.2",
"@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1",
"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 { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import { uniqueBy } from "@calcom/lib/array";
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
@@ -85,10 +86,16 @@ export default class GoogleCalendarService implements Calendar {
const selectedHostDestinationCalendar = event.destinationCalendar?.find(
(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 }) => ({
...rest,
responseStatus: "accepted",
}));
const attendees: calendar_v3.Schema$EventAttendee[] = [
{
...event.organizer,
@@ -100,7 +107,7 @@ export default class GoogleCalendarService implements Calendar {
// We use || instead of ?? here to handle empty strings
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
},
...eventAttendees,
...(event.hideOrganizerEmail && !isOrganizerExempt ? [] : eventAttendees),
];
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 { z } from "zod";
@@ -58,7 +61,7 @@ export type EventLocationTypeFromApp = Ensure<
export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp;
export const DailyLocationType = "integrations:daily";
export const CalVideoLocationType = DailyLocationType;
export const MeetLocationType = "integrations:google:meet";
export const MSTeamsLocationType = "integrations:office365_video";
@@ -380,7 +383,7 @@ export const getLocationValueForDB = (
eventLocations: LocationObject[]
) => {
let bookingLocation = bookingLocationTypeOrValue;
let conferenceCredentialId = undefined;
let conferenceCredentialId: number | undefined = undefined;
eventLocations.forEach((location) => {
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_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.not_between;
@@ -9,7 +9,6 @@ import { ZFormQueryInputSchema } from "./formQuery.schema";
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
import { ZGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
import { forms } from "./procedures/forms";
import { ZReportInputSchema } from "./report.schema";
import { ZSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema";
// eslint-disable-next-line @typescript-eslint/ban-types
@@ -76,11 +75,6 @@ const appRoutingForms = router({
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
.input(ZGetAttributesForTeamInputSchema)
.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 { 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 { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
const params = {
response_type: "code",
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"
state: "",
};
+2 -2
View File
@@ -1,6 +1,6 @@
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 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 **/
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([
["grant_type", "authorization_code"],
["client_id", client_id],
+19 -17
View File
@@ -404,23 +404,25 @@ const BookerComponent = ({
src={orgBannerUrl}
/>
)}
<EventMeta
selectedTimeslot={selectedTimeslot}
classNames={{
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
eventMetaTimezoneSelect:
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
}}
event={event.data}
isPending={event.isPending}
isPlatform={isPlatform}
isPrivateLink={!!hashedLink}
locale={userLocale}
timeZones={timeZones}
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
{eventMetaChildren}
</EventMeta>
{!hideEventTypeDetails && (
<EventMeta
selectedTimeslot={selectedTimeslot}
classNames={{
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
eventMetaTimezoneSelect:
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
}}
event={event.data}
isPending={event.isPending}
isPlatform={isPlatform}
isPrivateLink={!!hashedLink}
locale={userLocale}
timeZones={timeZones}
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
{eventMetaChildren}
</EventMeta>
)}
{layout !== BookerLayouts.MONTH_VIEW &&
!(layout === "mobile" && bookerState === "booking") && (
<div className="mt-auto px-5 py-3">
@@ -13,6 +13,7 @@ export const getCalEventResponses = ({
bookingFields,
booking,
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
// 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>;
seatsEnabled?: boolean;
}) => {
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
@@ -61,8 +63,7 @@ export const getCalEventResponses = ({
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" && field.hidden) {
if (field.name == "guests" && !!seatsEnabled) {
backwardCompatibleResponses[field.name] = [];
}
@@ -1919,7 +1919,7 @@ async function handler(
if (!isDryRun) {
sendRoundRobinRescheduledEmailsAndSMS(
copyEventAdditionalInfo,
{ ...copyEventAdditionalInfo, iCalUID },
rescheduledMembers,
eventType.metadata
);
@@ -64,6 +64,7 @@ const _getBookingData = async <T extends z.ZodType>({
getCalEventResponses({
bookingFields: eventType.bookingFields,
responses,
seatsEnabled: !!eventType.seatsPerTimeSlot,
});
return {
...parsedBody,
@@ -3,9 +3,13 @@
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import debounce from "lodash/debounce";
// eslint-disable-next-line no-restricted-imports
import isEqual from "lodash/isEqual";
import { usePathname } from "next/navigation";
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 {
@@ -22,8 +26,12 @@ import {
import {
type FilterValue,
type FilterSegmentOutput,
type SystemFilterSegment,
type CombinedFilterSegment,
type SegmentIdentifier,
type ActiveFilters,
type UseSegments,
SYSTEM_SEGMENT_PREFIX,
} from "./lib/types";
import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils";
@@ -55,10 +63,10 @@ export type DataTableContextType = {
offset: number;
limit: number;
segments: FilterSegmentOutput[];
selectedSegment: FilterSegmentOutput | undefined;
segmentId: number | undefined;
setSegmentId: (id: number | null) => void;
segments: CombinedFilterSegment[];
selectedSegment: CombinedFilterSegment | undefined;
segmentId: SegmentIdentifier | null;
setSegmentId: (id: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => void;
canSaveSegment: boolean;
isSegmentEnabled: boolean;
@@ -78,7 +86,8 @@ interface DataTableProviderProps {
defaultPageSize?: number;
segments?: FilterSegmentOutput[];
timeZone?: string;
preferredSegmentId?: number | null;
preferredSegmentId?: SegmentIdentifier | null;
systemSegments?: SystemFilterSegment[];
}
export function DataTableProvider({
@@ -90,8 +99,18 @@ export function DataTableProvider({
segments: providedSegments,
timeZone,
preferredSegmentId,
systemSegments,
}: 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 [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
const [sorting, setSorting] = useQueryState("sorting", sortingParser);
const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>(
@@ -99,34 +118,172 @@ export function DataTableProvider({
columnVisibilityParser
);
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
const [segmentId, setSegmentId] = useQueryState(
"segment",
segmentIdParser.withDefault(preferredSegmentId ?? -1)
const initialSegmentId = useMemo(
() => (preferredSegmentId ? String(preferredSegmentId.id) : null),
[preferredSegmentId]
);
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
const [segmentId, _setSegmentId] = useQueryState(
"segment",
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(
() => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500),
[setSearchTerm]
);
const pathname = usePathname() as string | null;
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
if (!tableIdentifier) {
throw new Error("tableIdentifier is required");
}
const addFilter = useCallback(
(columnId: string) => {
if (!activeFilters?.some((filter) => filter.f === columnId)) {
// do not reset the page to 0 here,
// because we don't have the filter value yet (`v: undefined`)
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
clearSystemSegmentSelectionIfExists();
}
},
[activeFilters, setActiveFilters]
[activeFilters, setActiveFilters, clearSystemSegmentSelectionIfExists]
);
const setPageIndexWrapper = useCallback(
@@ -151,8 +308,9 @@ export function DataTableProvider({
}
return newFilters;
});
clearSystemSegmentSelectionIfExists();
},
[setActiveFilters, setPageIndex]
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
);
const removeFilter = useCallback(
@@ -162,61 +320,72 @@ export function DataTableProvider({
const remainingFilters = prev.filter((filter) => filter.f !== columnId);
return remainingFilters.length === 0 ? null : remainingFilters;
});
clearSystemSegmentSelectionIfExists();
},
[setActiveFilters, setPageIndex]
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
);
const setPageSizeAndGoToFirstPage = useCallback(
(newPageSize: number | null) => {
setPageSize(newPageSize === defaultPageSize ? null : newPageSize);
setPageIndex(null);
clearSystemSegmentSelectionIfExists();
},
[setPageSize, setPageIndex, defaultPageSize]
);
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,
}
[setPageSize, setPageIndex, defaultPageSize, clearSystemSegmentSelectionIfExists]
);
const clearAll = useCallback(
(exclude?: string[]) => {
setAndPersistSegmentId(null);
setSegmentId(null);
setPageIndex(null);
setActiveFilters((prev) => {
const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f));
return remainingFilters.length === 0 ? null : remainingFilters;
});
},
[setActiveFilters, setPageIndex, setAndPersistSegmentId]
[setActiveFilters, setPageIndex, setSegmentId]
);
const ctaContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (ctaContainerClassName) {
const element = document.getElementsByClassName(ctaContainerClassName)[0] as HTMLDivElement;
ctaContainerRef.current = element;
// Check if current state differs from selected segment
const hasStateChanged = useMemo(() => {
if (!selectedSegment) return false;
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 (
<DataTableContext.Provider
@@ -243,8 +412,8 @@ export function DataTableProvider({
offset: pageIndex * pageSize,
segments,
selectedSegment,
segmentId: segmentId || undefined,
setSegmentId: setAndPersistSegmentId,
segmentId: segmentIdObject,
setSegmentId,
canSaveSegment,
isSegmentEnabled,
searchTerm,
@@ -21,7 +21,7 @@ export function DeleteSegmentDialog({
onSuccess: ({ id }) => {
utils.viewer.filterSegments.list.invalidate();
showToast(t("filter_segment_deleted"), "success");
if (segmentId === id) {
if (segmentId && segmentId.type === "user" && segmentId.id === id) {
setSegmentId(null);
}
onClose();
@@ -10,7 +10,7 @@ import { Form, TextField } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { useDataTable } from "../../hooks";
import type { FilterSegmentOutput } from "../../lib/types";
import type { CombinedFilterSegment } from "../../lib/types";
type FormValues = {
name: string;
@@ -20,7 +20,7 @@ export function DuplicateSegmentDialog({
segment,
onClose,
}: {
segment: FilterSegmentOutput;
segment: CombinedFilterSegment;
onClose: () => void;
}) {
const { t } = useLocale();
@@ -38,7 +38,7 @@ export function DuplicateSegmentDialog({
onSuccess: ({ id }) => {
utils.viewer.filterSegments.list.invalidate();
showToast(t("filter_segment_duplicated"), "success");
setSegmentId(id);
setSegmentId({ id, type: "user" });
onClose();
},
onError: () => {
@@ -50,16 +50,24 @@ export function DuplicateSegmentDialog({
if (!segment) {
return;
}
const { id: _id, name: _name, team: _team, teamId, ...rest } = segment;
if (segment.scope === "TEAM" && isAdminOrOwner) {
createSegment({
...rest,
teamId: teamId ?? 0,
scope: "TEAM",
name: data.name,
});
} else {
if (segment.type === "user") {
const { type: _type, id: _id, name: _name, team: _team, teamId, ...rest } = segment;
if (segment.scope === "TEAM" && isAdminOrOwner) {
createSegment({
...rest,
teamId: teamId ?? 0,
scope: "TEAM",
name: data.name,
});
} else {
createSegment({
...rest,
scope: "USER",
name: data.name,
});
}
} else if (segment.type === "system") {
const { type: _type, ...rest } = segment;
createSegment({
...rest,
scope: "USER",
@@ -80,7 +88,13 @@ export function DuplicateSegmentDialog({
<DialogHeader title={t("duplicate_segment")} />
<Form form={form} handleSubmit={handleSubmit}>
<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>
<Button color="minimal" onClick={onClose}>
{t("cancel")}
@@ -16,7 +16,12 @@ import {
import { Icon, type IconName } from "@calcom/ui/components/icon";
import { useDataTable } from "../../hooks";
import type { FilterSegmentOutput } from "../../lib/types";
import type {
FilterSegmentOutput,
CombinedFilterSegment,
SystemFilterSegmentInternal,
UserFilterSegment,
} from "../../lib/types";
import { DeleteSegmentDialog } from "./DeleteSegmentDialog";
import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog";
import { RenameSegmentDialog } from "./RenameSegmentDialog";
@@ -24,23 +29,33 @@ import { RenameSegmentDialog } from "./RenameSegmentDialog";
type SubmenuItem = {
iconName: IconName;
labelKey: string;
onClick: (segment: FilterSegmentOutput) => void;
onClick: (segment: CombinedFilterSegment) => void;
isDestructive?: boolean;
adminOnly: boolean;
enabledForSystemSegment?: boolean;
};
export function FilterSegmentSelect() {
const { t } = useLocale();
const session = useSession();
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
const { segments, selectedSegment, segmentId, setSegmentId, isSegmentEnabled } = useDataTable();
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 submenuItems: SubmenuItem[] = [
{
iconName: "square-pen",
labelKey: "rename",
onClick: (segment) => setSegmentToRename(segment),
onClick: (segment) => {
if (segment.type === "system") {
setSegmentToRename(undefined);
} else {
const { type: _type, ...rest } = segment;
setSegmentToRename(rest);
}
},
adminOnly: true,
},
{
@@ -48,55 +63,79 @@ export function FilterSegmentSelect() {
labelKey: "duplicate",
onClick: (segment) => setSegmentToDuplicate(segment),
adminOnly: false,
enabledForSystemSegment: true,
},
{
iconName: "trash-2",
labelKey: "delete",
onClick: (segment) => setSegmentToDelete(segment),
onClick: (segment) => {
if (segment.type === "system") {
setSegmentToDelete(undefined);
} else {
const { type: _type, ...rest } = segment;
setSegmentToDelete(rest);
}
},
isDestructive: true,
adminOnly: true,
},
];
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 =
segments?.filter(
(segment): segment is FilterSegmentOutput & { team: NonNullable<FilterSegmentOutput["team"]> } =>
segment.team !== null
(s): s is UserFilterSegment & { team: NonNullable<FilterSegmentOutput["team"]> } =>
s.type === "user" && s.team !== null
) || [];
// Group team segments by team name
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;
},
{}
);
const groups = [];
return [
...(personalSegments.length > 0
? [
{
label: t("personal"),
segments: personalSegments.sort(sortFn),
},
]
: []),
if (systemSegments.length > 0) {
groups.push({
label: t("default"),
segments: systemSegments.sort(sortFn),
isSystem: true,
});
}
// 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)
.map(([teamName, segments]) => ({
label: teamName,
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]);
if (!isSegmentEnabled) {
@@ -128,22 +167,43 @@ export function FilterSegmentSelect() {
{group.label && (
<DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel>
)}
{group.segments.map((segment) => (
<DropdownItemWithSubmenu
key={segment.id}
submenuItems={submenuItems}
segment={segment}
onSelect={() => {
if (segmentId === segment.id) {
setSegmentId(null);
} else {
setSegmentId(segment.id);
}
}}>
{segment.id === segmentId && <Icon name="check" className="ml-3 h-4 w-4" />}
<span className="ml-3">{segment.name}</span>
</DropdownItemWithSubmenu>
))}
{group.segments.map((segment) => {
let items: SubmenuItem[] = [];
if (segment.type === "system") {
items = submenuItems.filter((item) => item.enabledForSystemSegment);
} else if (segment.type === "user" && segment.scope === "USER") {
items = submenuItems;
} else if (segment.type === "user" && segment.scope === "TEAM") {
items = submenuItems.filter(
(item) =>
// Team segments: show if not admin-only or if user is admin/owner
isAdminOrOwner || !item.adminOnly
);
}
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>
))}
</DropdownMenuContent>
@@ -175,25 +235,12 @@ function DropdownItemWithSubmenu({
children,
}: {
onSelect: () => void;
segment: FilterSegmentOutput;
segment: CombinedFilterSegment;
submenuItems: SubmenuItem[];
children: React.ReactNode;
}) {
const { t } = useLocale();
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 (
<DropdownMenuItem className="cursor-pointer" onSelect={onSelect}>
@@ -220,7 +267,7 @@ function DropdownItemWithSubmenu({
side="right"
sideOffset={8}
data-testid="filter-segment-select-submenu-content">
{filteredSubmenuItems.map((item, index) => (
{submenuItems.map((item, index) => (
<DropdownMenuItem key={index}>
<DropdownItem
color={item.isDestructive ? "destructive" : undefined}
@@ -71,7 +71,13 @@ export function RenameSegmentDialog({
<DialogHeader title={t("rename_segment")} />
<Form form={form} handleSubmit={handleSubmit}>
<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>
<Button type="submit" loading={isPending}>
{t("save")}
@@ -57,7 +57,7 @@ export function SaveFilterSegmentButton() {
} = useDataTable();
const [saveMode, setSaveMode] = useState<"create" | "update">(() =>
selectedSegment ? "update" : "create"
selectedSegment && selectedSegment.type === "user" ? "update" : "create"
);
// When the dialog is not open,
@@ -66,16 +66,16 @@ export function SaveFilterSegmentButton() {
if (isOpen) {
return;
}
setSaveMode(selectedSegment ? "update" : "create");
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
}, [selectedSegment, isOpen]);
const { data: teams } = trpc.viewer.teams.list.useQuery();
const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({
onSuccess: ({ id }) => {
onSuccess: (segment) => {
utils.viewer.filterSegments.list.invalidate();
showToast(t("filter_segment_saved"), "success");
setSegmentId(id);
setSegmentId({ id: segment.id, type: "user" }, { type: "user", ...segment });
setIsOpen(false);
},
onError: () => {
@@ -110,12 +110,7 @@ export function SaveFilterSegmentButton() {
searchTerm,
};
if (saveMode === "update") {
if (!selectedSegment) {
// theoretically this should never happen
showToast(t("segment_not_found"), "error");
return;
}
if (saveMode === "update" && selectedSegment && selectedSegment.type === "user") {
const scope = selectedSegment.scope;
if (scope === "TEAM") {
updateSegment({
@@ -156,7 +151,7 @@ export function SaveFilterSegmentButton() {
// Reset form state when dialog closes
setIsTeamSegment(false);
setSelectedTeamId(undefined);
setSaveMode(selectedSegment ? "update" : "create");
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
form.reset();
}
setIsOpen(open);
@@ -184,7 +179,7 @@ export function SaveFilterSegmentButton() {
<DialogContent data-testid="save-filter-segment-dialog">
<DialogHeader title={t("save_segment")} />
<Form form={form} handleSubmit={onSubmit}>
{selectedSegment ? (
{selectedSegment && selectedSegment.type === "user" ? (
<div className="mb-4">
<RadioGroup
defaultValue="update"
+32 -128
View File
@@ -1,35 +1,13 @@
// eslint-disable-next-line no-restricted-imports
import { isEqual } from "lodash";
import { useCallback, useMemo, useEffect } from "react";
import { useMemo } from "react";
import { trpc } from "@calcom/trpc/react";
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";
export const useSegments: UseSegments = ({
tableIdentifier,
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(
export const useSegments: UseSegments = ({ tableIdentifier, providedSegments, systemSegments }) => {
const { data: rawSegments, isSuccess } = trpc.viewer.filterSegments.list.useQuery(
{
tableIdentifier,
},
@@ -37,16 +15,19 @@ export const useSegments: UseSegments = ({
enabled: !providedSegments, // Only fetch if segments are not provided
}
);
const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation();
// Recalculate date ranges based on the current timestamp
const segments = useMemo(() => {
const segmentsSource = providedSegments || rawSegments?.segments;
if (!segmentsSource) return [];
const preferredSegmentId = useMemo(() => rawSegments?.preferredSegmentId || null, [rawSegments]);
return segmentsSource.map((segment) => ({
const segments = useMemo(() => {
const userSegments = providedSegments || rawSegments?.segments || [];
const processedSystemSegments = (systemSegments || []).map((segment) => ({
perPage: 10,
tableIdentifier,
...segment,
id: `${SYSTEM_SEGMENT_PREFIX}${segment.id}`,
type: "system" as const,
activeFilters: segment.activeFilters.map((filter) => {
if (isDateRangeFilterValue(filter.v)) {
return {
@@ -57,106 +38,29 @@ export const useSegments: UseSegments = ({
return filter;
}),
}));
}, [rawSegments, providedSegments]);
const selectedSegment = useMemo(() => {
return segments?.find((segment) => segment.id === segmentId);
}, [segments, segmentId]);
const processedUserSegments = userSegments.map((segment) => ({
...segment,
type: "user" as const,
activeFilters: segment.activeFilters.map((filter) => {
if (isDateRangeFilterValue(filter.v)) {
return {
...filter,
v: recalculateDateRange(filter.v),
};
}
return filter;
}),
}));
useEffect(() => {
if (segments && segmentId > 0 && !isFetchingSegments) {
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 [...processedSystemSegments, ...processedUserSegments];
}, [rawSegments, providedSegments, systemSegments, tableIdentifier]);
return {
segments: segments ?? [],
selectedSegment,
canSaveSegment,
setAndPersistSegmentId,
segments,
preferredSegmentId,
isSuccess: Boolean(providedSegments) || isSuccess,
setPreference,
isSegmentEnabled: true,
};
};
@@ -6,9 +6,9 @@ import { type UseSegments } from "../lib/types";
export const useSegmentsNoop: UseSegments = ({}) => {
return {
segments: [],
selectedSegment: undefined,
canSaveSegment: false,
setAndPersistSegmentId: noop,
preferredSegmentId: null,
isSuccess: false,
setPreference: noop,
isSegmentEnabled: false,
};
};
+1 -1
View File
@@ -18,7 +18,7 @@ export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withD
DEFAULT_COLUMN_VISIBILITY
);
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 pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE);
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 type { IconName } from "@calcom/ui/components/icon";
export type { SortingState } from "@tanstack/react-table";
export const SYSTEM_SEGMENT_PREFIX = "system_";
export enum ColumnFilterType {
SINGLE_SELECT = "ss",
MULTI_SELECT = "ms",
@@ -255,11 +257,31 @@ export type FilterSegmentOutput = {
team: { id: number; name: string } | null;
};
export type FilterSegmentsListResponse = {
segments: FilterSegmentOutput[];
preferredSegmentId: number | null;
export type SystemFilterSegment = {
id: string;
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 = {
[tableIdentifier: string]: {
segmentId: number;
@@ -277,30 +299,20 @@ export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn;
export type UseSegmentsProps = {
tableIdentifier: string;
activeFilters: ActiveFilters;
sorting: SortingState;
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;
providedSegments?: FilterSegmentOutput[];
systemSegments?: SystemFilterSegment[];
};
export type UseSegmentsReturn = {
segments: FilterSegmentOutput[];
selectedSegment: FilterSegmentOutput | undefined;
canSaveSegment: boolean;
setAndPersistSegmentId: (segmentId: number | null) => void;
segments: CombinedFilterSegment[];
preferredSegmentId: SegmentIdentifier | null;
isSuccess: boolean;
setPreference: ({
tableIdentifier,
segmentId,
}: {
tableIdentifier: string;
segmentId: SegmentIdentifier | null;
}) => void;
isSegmentEnabled: boolean;
};
@@ -73,6 +73,17 @@ export const handleRescheduleEventManager = async ({
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({
results: results,
});
@@ -80,7 +91,6 @@ export const handleRescheduleEventManager = async ({
let videoCallUrl = _videoCallUrl;
let metadata: AdditionalInformation = {};
metadata = videoMetadata;
if (results.length) {
// Handle Google Meet results
if (bookingLocation === MeetLocationType) {
@@ -154,6 +164,7 @@ export const handleRescheduleEventManager = async ({
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
}
const newReferencesToCreate = structuredClone(updateManager.referencesToCreate);
await BookingReferenceRepository.replaceBookingReferences({
@@ -17,65 +17,238 @@ import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAn
import { describe, vi, expect } from "vitest";
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
vi.mock("@calcom/lib/EventManager");
vi.mock("@calcom/app-store/utils", () => ({
getAppFromSlug: vi.fn(),
}));
const testDestinationCalendar = {
integration: "test-calendar",
externalId: "test-calendar",
// Type definitions
type TestUserMetadata = {
defaultConferencingApp?: {
appSlug: string;
appLink?: string;
};
};
const testUsers = [
{
id: 1,
name: "user-1",
timeZone: "Asia/Kolkata",
username: "host-1",
email: "host1@test.com",
schedules: [TestData.schedules.IstWorkHours],
destinationCalendar: testDestinationCalendar,
},
{
id: 2,
name: "user-2",
timeZone: "Asia/Kolkata",
username: "host-2",
email: "host2@test.com",
schedules: [TestData.schedules.IstWorkHours],
},
{
id: 3,
name: "user-3",
timeZone: "Asia/Kolkata",
username: "host-3",
email: "host3@test.com",
schedules: [TestData.schedules.IstWorkHours],
},
];
type TestUser = {
id: number;
name: string;
timeZone: string;
username: string;
email: string;
schedules: (typeof TestData.schedules.IstWorkHours)[];
destinationCalendar?: { integration: string; externalId: string };
metadata?: TestUserMetadata;
};
type MockBookingAttendee = ReturnType<typeof getMockBookingAttendee>;
// Test Data Builders
const createTestUser = (overrides?: {
id?: number;
name?: string;
username?: string;
email?: string;
destinationCalendar?: { integration: string; externalId: string };
metadata?: TestUserMetadata;
}) => ({
id: overrides?.id ?? 1,
name: overrides?.name ?? `user-${overrides?.id ?? 1}`,
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", () => {
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 EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
const testDestinationCalendar = createTestDestinationCalendar();
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 bookingToReassignUid = "booking-to-reassign";
const bookingData = await createBookingScenario(
await createBookingScenario(
getScenarioData({
workflows: [
{
@@ -83,48 +256,34 @@ describe("roundRobinManualReassignment test", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
{
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
},
users,
}),
],
bookings: [
{
id: 123,
await createTestBooking({
eventTypeId: 1,
userId: originalHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
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",
}),
],
},
bookingId: 123,
bookingUid: bookingToReassignUid,
}),
],
organizer: originalHost,
usersApartFromOrganizer: users.slice(1),
})
);
await addWorkflowReminders([
{
bookingUid: bookingToReassignUid,
method: WorkflowMethods.EMAIL,
scheduledDate: dateStringPlusTwo,
scheduledDate: new Date(dateStringPlusTwo),
scheduled: true,
workflowStepId: 1,
workflowId: 1,
@@ -135,16 +294,14 @@ describe("roundRobinManualReassignment test", () => {
bookingId: 123,
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
});
expect(eventManagerSpy).toBeCalledTimes(1);
expect(eventManagerSpy).toHaveBeenCalledWith(
expect.any(Object),
bookingToReassignUid,
undefined,
true,
expect.arrayContaining([expect.objectContaining(testDestinationCalendar)])
);
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
uid: bookingToReassignUid,
changedOrganizer: true,
destinationCalendars: expect.arrayContaining([expect.objectContaining(testDestinationCalendar)]),
});
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
@@ -160,21 +317,73 @@ describe("roundRobinManualReassignment test", () => {
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 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 fixedHost = users[0];
const currentRRHost = users[1];
const newHost = users[2];
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
workflows: [
@@ -186,27 +395,22 @@ describe("roundRobinManualReassignment test", () => {
},
],
eventTypes: [
{
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: users.map((user) => ({ id: user.id })),
users,
hosts: users.map((user) => ({
userId: user.id,
isFixed: user.id === fixedHost.id,
})),
},
}),
],
bookings: [
{
id: 123,
await createTestBooking({
eventTypeId: 1,
userId: fixedHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
bookingId: 123,
bookingUid: bookingToReassignUid,
attendees: [
getMockBookingAttendee({
id: 1,
@@ -223,7 +427,7 @@ describe("roundRobinManualReassignment test", () => {
timeZone: currentRRHost.timeZone,
}),
],
},
}),
],
organizer: fixedHost,
usersApartFromOrganizer: users.slice(1),
@@ -234,65 +438,59 @@ describe("roundRobinManualReassignment test", () => {
bookingId: 123,
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
});
expect(eventManagerSpy).toBeCalledTimes(1);
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
uid: bookingToReassignUid,
changedOrganizer: false,
});
// Ensure organizer stays the same
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
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);
expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
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 EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
await mockEventManagerReschedule();
const sendRoundRobinCancelledEmailsAndSMSSpy = vi.spyOn(
await import("@calcom/emails"),
"sendRoundRobinCancelledEmailsAndSMS"
);
const users = testUsers;
const originalHost = users[0];
const previousRRHost = users[1];
const newHost = users[2];
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
const testDestinationCalendar = createTestDestinationCalendar();
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
const previousRRHost = createTestUser({ id: 2 });
const newHost = createTestUser({ id: 3 });
const users = [originalHost, previousRRHost, newHost];
const bookingToReassignUid = "booking-to-reassign-with-previous-rr-host";
await createBookingScenario(
getScenarioData({
eventTypes: [
{
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
},
users,
}),
],
bookings: [
{
id: 124,
await createTestBooking({
eventTypeId: 1,
userId: originalHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
bookingId: 124,
bookingUid: bookingToReassignUid,
attendees: [
getMockBookingAttendee({
id: 2,
@@ -309,7 +507,7 @@ describe("roundRobinManualReassignment test", () => {
timeZone: previousRRHost.timeZone,
}),
],
},
}),
],
organizer: originalHost,
usersApartFromOrganizer: users.slice(1),
@@ -320,8 +518,358 @@ describe("roundRobinManualReassignment test", () => {
bookingId: 124,
newUserId: newHost.id,
orgId: null,
reassignedById: 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
import { cloneDeep } from "lodash";
import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import {
sendRoundRobinCancelledEmailsAndSMS,
@@ -28,10 +27,10 @@ import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKey
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { BookingLocationService } from "@calcom/lib/server/service/bookingLocationService";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { prisma } from "@calcom/prisma";
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 { CalendarEvent } from "@calcom/types/Calendar";
@@ -126,7 +125,6 @@ export const roundRobinManualReassignment = async ({
const originalOrganizer = booking.user;
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
const newUser = newUserHost.user;
const newUserT = await getTranslation(newUser.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");
let bookingLocation = booking.location;
let conferenceCredentialId: number | null = null;
if (hasOrganizerChanged) {
const bookingResponses = booking.responses;
const responseSchema = getBookingResponsesSchema({
@@ -159,15 +157,20 @@ export const roundRobinManualReassignment = async ({
const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
if (eventType.locations.some((location) => location.type === OrganizerDefaultConferencingAppType)) {
const newUserMetadataSafeParse = userMetadataSchema.safeParse(newUser.metadata);
const defaultLocationUrl = newUserMetadataSafeParse.success
? newUserMetadataSafeParse?.data?.defaultConferencingApp?.appLink
: undefined;
const currentBookingLocation = booking.location || "integrations:daily";
bookingLocation =
defaultLocationUrl ||
getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation;
// Determine the location for the new host
const isManagedEventType = !!eventType.parentId;
const isTeamEventType = !!eventType.teamId;
const locationResult = BookingLocationService.getLocationForHost({
hostMetadata: newUser.metadata,
eventTypeLocations: eventType.locations,
isManagedEventType,
isTeamEventType,
});
bookingLocation = locationResult.bookingLocation;
if (locationResult.requiresActualLink) {
conferenceCredentialId = locationResult.conferenceCredentialId;
}
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({
eventType,
booking,
@@ -290,6 +305,7 @@ export const roundRobinManualReassignment = async ({
customReplyToEmail: eventType?.customReplyToEmail,
location: bookingLocation,
...(platformClientParams ? platformClientParams : {}),
conferenceCredentialId: conferenceCredentialId ?? undefined,
};
const credentials = await prisma.credential.findMany({
-4
View File
@@ -30,10 +30,6 @@ const opts: JacksonOption = {
},
idpEnabled: true,
clientSecretVerifier,
ory: {
projectId: undefined,
sdkToken: undefined,
},
};
declare global {
@@ -1,6 +1,8 @@
import { useId } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Badge } from "@calcom/ui/components/badge";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { RoutingFormTableRow } from "../lib/types";
import { CellWithOverflowX } from "./CellWithOverflowX";
@@ -9,21 +11,39 @@ export function BookedByCell({
attendees,
rowId,
}: {
attendees: RoutingFormTableRow["bookingAttendees"] | undefined;
attendees: RoutingFormTableRow["bookingAttendees"];
rowId: number;
}) {
const cellId = useId();
const { t } = useLocale();
if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />;
return (
<div className="flex min-w-[200px] flex-wrap gap-1">
{attendees.map((attendee) => (
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}>
{attendee.name}
</Badge>
</CellWithOverflowX>
))}
{attendees.map((attendee) => {
const tooltipContent = (
<div className="space-y-1">
<div>
{t("email")}: {attendee.email}
</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>
);
}
@@ -16,6 +16,7 @@ import {
convertMapToFacetedValues,
type FilterableColumn,
} from "@calcom/features/data-table";
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
import { trpc } from "@calcom/trpc";
import { RoutingFormResponsesDownload } from "../../filters/Download";
@@ -35,8 +36,7 @@ const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_R
};
export function RoutingFormResponsesTable() {
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
useInsightsParameters();
const { isAll, teamId, userId, routingFormId } = useInsightsParameters();
const { data: headers, isSuccess: isHeadersSuccess } =
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
@@ -53,22 +53,15 @@ export function RoutingFormResponsesTable() {
isAll,
});
const insightsRoutingParams = useInsightsRoutingParameters();
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
teamId,
startDate,
endDate,
userId,
memberUserIds,
isAll,
routingFormId,
columnFilters,
...insightsRoutingParams,
sorting,
limit,
offset,
});
const processedData = useMemo(() => {
if (!isHeadersSuccess || !data) return [];
return data.data as RoutingFormTableRow[];
@@ -88,6 +81,9 @@ export function RoutingFormResponsesTable() {
columnVisibility: {
formId: false,
bookingUserId: false,
attendeeName: false,
attendeeEmail: false,
attendeePhone: false,
utm_source: false,
utm_medium: false,
utm_campaign: false,
@@ -1,37 +1,24 @@
"use client";
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import classNames from "@calcom/ui/classNames";
import { SkeletonText } from "@calcom/ui/components/skeleton";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
import { valueFormatter } from "../../lib";
import { ChartCard } from "../ChartCard";
export const RoutingKPICards = () => {
const { t } = useLocale();
const { teamId, startDate, endDate, userId, memberUserIds, isAll, routingFormId, columnFilters } =
useInsightsParameters();
const insightsRoutingParameters = useInsightsRoutingParameters();
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(
{
teamId,
startDate,
endDate,
userId,
memberUserIds,
isAll,
routingFormId,
columnFilters,
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(insightsRoutingParameters, {
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
{
staleTime: 30000,
trpc: {
context: { skipBatch: true },
},
}
);
});
const categories: {
title: string;
@@ -2,6 +2,7 @@ import { useState } from "react";
import dayjs from "@calcom/dayjs";
import type { SortingState } from "@calcom/features/data-table";
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
import { downloadAsCsv } from "@calcom/lib/csvUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
@@ -15,8 +16,6 @@ import {
} from "@calcom/ui/components/dropdown";
import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast";
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number];
type Props = {
@@ -27,9 +26,9 @@ const BATCH_SIZE = 100; // Increased batch size for downloads
export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
const { t } = useLocale();
const { teamId, userId, memberUserIds, routingFormId, isAll, startDate, endDate, columnFilters } =
useInsightsParameters();
const [isDownloading, setIsDownloading] = useState(false);
const insightsRoutingParameters = useInsightsRoutingParameters();
const { startDate, endDate } = insightsRoutingParameters;
const utils = trpc.useUtils();
@@ -40,14 +39,7 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
total: number;
}> => {
const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
teamId,
startDate,
endDate,
userId,
memberUserIds,
isAll: isAll,
routingFormId,
columnFilters,
...insightsRoutingParameters,
sorting,
limit: BATCH_SIZE,
offset,
@@ -35,7 +35,7 @@ export const useInsightsBookings = () => {
size: 200,
meta: {
filter: {
type: ColumnFilterType.SINGLE_SELECT,
type: ColumnFilterType.MULTI_SELECT,
},
},
enableColumnFilter: true,
@@ -103,19 +103,53 @@ export const useInsightsColumns = ({
size: 200,
enableColumnFilter: false,
enableSorting: false,
meta: {
filter: {
type: ColumnFilterType.TEXT,
textOptions: {
placeholder: t("name_or_email"),
},
},
},
cell: (info) => {
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) => {
const isText = [
RoutingFormFieldType.TEXT,
@@ -145,19 +179,28 @@ export const useInsightsColumns = ({
return acc;
}, {} 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,
header: startCase(fieldHeader.label),
enableSorting: false,
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) {
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
return (
result.success && (
<ResponseValueCell
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}
/>
)
@@ -167,7 +210,7 @@ export const useInsightsColumns = ({
return (
result.success && (
<div className="truncate">
<span title={String(result.data.value)}>{result.data.value}</span>
<span title={String(result.data)}>{result.data}</span>
</div>
)
);
@@ -51,7 +51,7 @@ export function useInsightsParameters() {
}, [createdAtRange?.preset]);
const columnFilters = useColumnFilters({
exclude: ["bookingUserId", "formId", "createdAt", "eventTypeId"],
exclude: ["createdAt"],
});
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 const ZResponseMultipleValues = z.object({
label: z.string(),
value: z.array(z.string()),
});
export const ZResponseMultipleValues = z.array(z.string());
export const ZResponseSingleValue = z.object({
label: z.string(),
value: z.string(),
});
export const ZResponseSingleValue = z.string();
export const ZResponseTextValue = z.object({
label: z.string(),
value: z.string(),
});
export const ZResponseTextValue = z.string();
export const ZResponseNumericValue = z.object({
label: z.string(),
value: z.number(),
});
export const ZResponseNumericValue = z.number();
export const ZResponseValue = z.union([
ZResponseMultipleValues,
@@ -39,9 +39,19 @@ export type RoutingFormStatsInput = {
sorting?: Sorting[];
};
export type RoutingFormResponsesInput = RoutingFormStatsInput & {
offset?: number;
limit?: number;
export type InsightsRoutingServiceInput = {
scope: "user" | "team" | "org";
selectedTeamId?: number;
columnFilters?: ColumnFilter[];
startDate: string;
endDate: string;
};
export type InsightsRoutingServicePaginatedInput = InsightsRoutingServiceInput & {
sorting?: Sorting[];
offset: number;
limit: number;
};
export const routingFormStatsInputSchema = z.object({
@@ -56,11 +66,20 @@ export const routingFormStatsInputSchema = z.object({
sorting: z.array(ZSorting).optional(),
}) satisfies z.ZodType<RoutingFormStatsInput>;
export const routingFormResponsesInputSchema = z.object({
...routingFormStatsInputSchema.shape,
offset: z.number().optional(),
limit: z.number().max(100).optional(),
}) satisfies z.ZodType<RoutingFormResponsesInput>;
export const insightsRoutingServiceInputSchema = z.object({
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
selectedTeamId: z.number().optional(),
columnFilters: z.array(ZColumnFilter).optional(),
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({
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";
import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod";
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 type { InsightsRoutingBaseService } from "@calcom/lib/server/service/InsightsRoutingBaseService";
import { readonlyPrisma as prisma } from "@calcom/prisma";
import { type ResponseValue, ZResponse } from "../lib/types";
type RoutingFormInsightsTeamFilter = {
userId?: number | null;
teamId?: number | null;
@@ -29,14 +22,6 @@ type RoutingFormInsightsTeamFilter = {
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">;
class RoutingEventsInsights {
@@ -100,57 +85,6 @@ class RoutingEventsInsights {
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({
userId,
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({
teamId,
startDate,
endDate,
isAll,
organizationId,
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,
});
headersPromise,
dataPromise,
}: {
headersPromise: ReturnType<typeof RoutingEventsInsights.getRoutingFormHeaders>;
dataPromise: ReturnType<typeof InsightsRoutingBaseService.prototype.getTableData>;
}) {
const [headers, data] = await Promise.all([headersPromise, dataPromise]);
const dataWithFlatResponse = data.data.map((item) => {
const { bookingAttendees } = item;
const responseParseResult = ZResponse.safeParse(item.response);
const response = responseParseResult.success ? responseParseResult.data : {};
const bookingAttendees = item.bookingAttendees || [];
const fields = (headers || []).reduce((acc, header) => {
const id = header.id;
if (!response[id]) {
const field = item.fields.find((field) => field.fieldId === id);
if (!field) {
acc[header.label] = "";
return acc;
}
if (header.type === "select") {
acc[header.label] = header.options?.find((option) => option.id === response[id].value)?.label;
} else if (header.type === "multiselect" && Array.isArray(response[id].value)) {
acc[header.label] = (response[id].value as string[])
acc[header.label] = header.options?.find((option) => option.id === field.valueString)?.label;
} else if (header.type === "multiselect" && Array.isArray(field.valueStringArray)) {
acc[header.label] = field.valueStringArray
.map((value) => header.options?.find((option) => option.id === value)?.label)
.filter((label): label is string => label !== undefined)
.sort()
.join(", ");
} else if (header.type === "number") {
acc[header.label] = field.valueNumber?.toString() || "";
} else {
acc[header.label] = response[id].value as string;
acc[header.label] = field.valueString || "";
}
return acc;
}, {} as Record<string, string | undefined>);
@@ -442,9 +163,6 @@ class RoutingEventsInsights {
"Booking Created At": item.bookingCreatedAt?.toISOString() || "",
"Booking Start Time": item.bookingStartTime?.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 || "",
"Routed To Name": item.bookingUserName || "",
"Routed To Email": item.bookingUserEmail || "",
@@ -454,6 +172,12 @@ class RoutingEventsInsights {
utm_campaign: item.utm_campaign || "",
utm_term: item.utm_term || "",
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 {
rawDataInputSchema,
routingFormResponsesInputSchema,
routingFormStatsInputSchema,
insightsRoutingServiceInputSchema,
insightsRoutingServicePaginatedInputSchema,
routingRepositoryBaseInputSchema,
bookingRepositoryBaseInputSchema,
} from "@calcom/features/insights/server/raw-data.schema";
@@ -827,55 +827,44 @@ export const insightsRouter = router({
});
}),
routingFormsByStatus: userBelongsToTeamProcedure
.input(routingFormStatsInputSchema)
.input(insightsRoutingServiceInputSchema)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormStats({
teamId: input.teamId,
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,
});
const insightsRoutingService = createInsightsRoutingService(ctx, input);
return await insightsRoutingService.getRoutingFormStats();
}),
routingFormResponses: userBelongsToTeamProcedure
.input(routingFormResponsesInputSchema)
.input(insightsRoutingServicePaginatedInputSchema)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormPaginatedResponses({
teamId: input.teamId,
startDate: input.startDate,
endDate: input.endDate,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
const insightsRoutingService = createInsightsRoutingService(ctx, input);
return await insightsRoutingService.getTableData({
sorting: input.sorting,
limit: input.limit,
offset: input.offset,
columnFilters: input.columnFilters,
sorting: input.sorting,
});
}),
routingFormResponsesForDownload: userBelongsToTeamProcedure
.input(routingFormResponsesInputSchema)
.input(insightsRoutingServicePaginatedInputSchema)
.query(async ({ ctx, input }) => {
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
teamId: input.teamId,
startDate: input.startDate,
endDate: input.endDate,
isAll: input.isAll,
organizationId: ctx.user.organizationId ?? null,
routingFormId: input.routingFormId,
const headersPromise = RoutingEventsInsights.getRoutingFormHeaders({
userId: ctx.user.id,
memberUserIds: input.memberUserIds,
limit: input.limit ?? BATCH_SIZE,
offset: input.offset,
columnFilters: input.columnFilters,
teamId: input.selectedTeamId,
isAll: input.scope === "org",
organizationId: ctx.user.organizationId,
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,
limit: input.limit,
offset: input.offset,
});
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
headersPromise,
dataPromise,
});
}),
getRoutingFormFieldOptions: userBelongsToTeamProcedure
@@ -136,13 +136,11 @@ export const useSchedule = ({
eventTypeId: eventId ?? undefined,
});
const schedule = isTeamEvent
? trpc.viewer.highPerf.getTeamSchedule.useQuery(input, {
...options,
// Only enable if we're not using API V2
enabled: options.enabled && !isCallingApiV2Slots,
})
: trpc.viewer.slots.getSchedule.useQuery(input, options);
const schedule = trpc.viewer.slots.getSchedule.useQuery(input, {
...options,
// Only enable if we're not using API V2
enabled: options.enabled && !isCallingApiV2Slots,
});
if (isCallingApiV2Slots && !teamScheduleV2.failureReason) {
updateEmbedBookerState({
+4 -1
View File
@@ -2,6 +2,7 @@
import { sortBy } from "lodash";
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
import { MeetLocationType } from "@calcom/app-store/locations";
import getApps from "@calcom/app-store/utils";
import dayjs from "@calcom/dayjs";
import { getUid } from "@calcom/lib/CalEventParser";
@@ -494,12 +495,14 @@ const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => {
calendarDescription: getRichDescription(calEvent),
};
const isMeetLocationType = calEvent.location === MeetLocationType;
// Determine if the calendar event should include attendees
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
.filter((domain) => domain.trim() !== "")
.some((domain) => calEvent.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
if (calEvent.hideOrganizerEmail && !isOrganizerExempt) {
if (calEvent.hideOrganizerEmail && !isOrganizerExempt && !isMeetLocationType) {
calendarEvent.attendees = [];
}
+1
View File
@@ -56,6 +56,7 @@ const user: User & { credentials: CredentialPayload[] } = {
timeFormat: 12,
travelSchedules: [],
locked: false,
isPlatformManaged: false,
};
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/js": "^2.1.0",
"@sendgrid/client": "^7.7.0",
"@unkey/ratelimit": "^2.1.3",
"@vercel/og": "^0.6.3",
"city-timezones": "^1.2.1",
"i18next": "^23.2.3",
+17
View File
@@ -30,6 +30,16 @@ export function rateLimiter() {
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 = {
core: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -37,6 +47,7 @@ export function rateLimiter() {
limit: 10,
duration: "60s",
timeout,
onError,
}),
common: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -44,6 +55,7 @@ export function rateLimiter() {
limit: 200,
duration: "60s",
timeout,
onError,
}),
forcedSlowMode: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -51,6 +63,7 @@ export function rateLimiter() {
limit: 1,
duration: "30s",
timeout,
onError,
}),
api: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -58,6 +71,7 @@ export function rateLimiter() {
limit: API_KEY_RATE_LIMIT,
duration: "60s",
timeout,
onError,
}),
ai: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -65,6 +79,7 @@ export function rateLimiter() {
limit: 20,
duration: "1d",
timeout,
onError,
}),
sms: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -72,6 +87,7 @@ export function rateLimiter() {
limit: 50,
duration: "5m",
timeout,
onError,
}),
smsMonth: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -79,6 +95,7 @@ export function rateLimiter() {
limit: 250,
duration: "30d",
timeout,
onError,
}),
};
@@ -296,7 +296,7 @@ describe("FilterSegmentRepository.get()", () => {
});
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");
});
+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 {
ZActiveFilters,
ZSortingState,
@@ -6,14 +6,14 @@ import {
ZColumnVisibility,
} from "@calcom/features/data-table/lib/types";
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";
export interface IFilterSegmentRepository {
get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }): Promise<{
segments: FilterSegmentOutput[];
preferredSegmentId: number | null;
preferredSegmentId: SegmentIdentifier | null;
}>;
create({
@@ -22,7 +22,7 @@ export interface IFilterSegmentRepository {
}: {
userId: number;
input: TCreateFilterSegmentInputSchema;
}): Promise<FilterSegment>;
}): Promise<FilterSegmentOutput>;
update({
userId,
@@ -30,7 +30,7 @@ export interface IFilterSegmentRepository {
}: {
userId: number;
input: TUpdateFilterSegmentInputSchema;
}): Promise<FilterSegment>;
}): Promise<FilterSegmentOutput>;
delete({ userId, id }: { userId: number; id: number }): Promise<void>;
@@ -41,7 +41,7 @@ export interface IFilterSegmentRepository {
}: {
userId: number;
tableIdentifier: string;
segmentId: number | null;
segmentId: SegmentIdentifier | null;
}): Promise<UserFilterSegmentPreference | null>;
}
@@ -124,12 +124,19 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
},
select: {
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 {
segments: parsedSegments,
preferredSegmentId: preference?.segmentId || null,
preferredSegmentId,
};
}
@@ -174,9 +181,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
...(scope === "TEAM" ? { teamId } : {}),
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 }) {
@@ -228,9 +266,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
perPage,
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 }) {
@@ -282,7 +351,7 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
}: {
userId: number;
tableIdentifier: string;
segmentId: number | null;
segmentId: SegmentIdentifier | null;
}) {
if (segmentId === null) {
await prisma.userFilterSegmentPreference.deleteMany({
@@ -294,6 +363,18 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
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({
where: {
userId_tableIdentifier: {
@@ -301,13 +382,11 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
tableIdentifier,
},
},
update: {
segmentId,
},
update: updateData,
create: {
userId,
tableIdentifier,
segmentId,
...updateData,
},
});
@@ -1,6 +1,5 @@
import { z } from "zod";
import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types";
import {
ZActiveFilters,
ZSortingState,
@@ -8,11 +7,6 @@ import {
ZColumnVisibility,
} from "@calcom/features/data-table/lib/types";
export type FilterSegmentsListResponse = {
segments: FilterSegmentOutput[];
preferredSegmentId: number | null;
};
export const ZListFilterSegmentsInputSchema = z.object({
tableIdentifier: z.string(),
});
@@ -58,9 +52,14 @@ export const ZCreateFilterSegmentInputSchema = z.discriminatedUnion("scope", [
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({
tableIdentifier: z.string(),
segmentId: z.number().nullable(),
segmentId: ZSegmentIdentifier.nullable(),
});
export type TSetFilterSegmentPreferenceInputSchema = z.infer<typeof ZSetFilterSegmentPreferenceInputSchema>;
@@ -298,8 +298,16 @@ export class InsightsBookingBaseService {
return null;
}
if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`;
if (id === "eventTypeId" && isMultiSelectFilterValue(value)) {
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") {

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