Compare commits

...
Author SHA1 Message Date
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
92 changed files with 2928 additions and 2944 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": patch
---
This PR fixes `onBookerStateChange` prop not working
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
feat: add send & verify code flow to booker atom when email verification is turned on
+5
View File
@@ -0,0 +1,5 @@
---
"@calcom/atoms": minor
---
feat: added hideEventMetadata prop to the booker atom 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.
+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": "*",
@@ -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,
});
}
}
@@ -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;
}
+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 });
@@ -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}>
+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]");
+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,6 @@
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"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) {
@@ -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>;
+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,
-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[] = [];
+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,
}),
};
@@ -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") {
@@ -12,6 +12,7 @@ import {
} from "@calcom/features/data-table/lib/utils";
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
import type { readonlyPrisma } from "@calcom/prisma";
import type { BookingStatus } from "@calcom/prisma/enums";
import { MembershipRole } from "@calcom/prisma/enums";
import { MembershipRepository } from "../repository/membership";
@@ -51,8 +52,81 @@ export type InsightsRoutingServiceFilterOptions = {
columnFilters?: TypedColumnFilter<ColumnFilterType>[];
};
export type InsightsRoutingTableItem = {
id: number;
uuid: string | null;
formId: string;
formName: string;
formTeamId: number | null;
formUserId: number;
bookingUid: string | null;
bookingId: number | null;
bookingStatus: BookingStatus | null;
bookingStatusOrder: number | null;
bookingCreatedAt: Date | null;
bookingUserId: number | null;
bookingUserName: string | null;
bookingUserEmail: string | null;
bookingUserAvatarUrl: string | null;
bookingAssignmentReason: string | null;
bookingStartTime: Date | null;
bookingEndTime: Date | null;
eventTypeId: number | null;
eventTypeParentId: number | null;
eventTypeSchedulingType: string | null;
createdAt: Date;
utm_source: string | null;
utm_medium: string | null;
utm_campaign: string | null;
utm_term: string | null;
utm_content: string | null;
bookingAttendees: Array<{
name: string;
timeZone: string;
email: string;
phoneNumber: string | null;
}>;
fields: Array<{
fieldId: string | null;
valueString: string | null;
valueNumber: number | null;
valueStringArray: string[] | null;
}>;
};
const NOTHING_CONDITION = Prisma.sql`1=0`;
// Define allowed column names for sorting to prevent SQL injection
const ALLOWED_SORT_COLUMNS = new Set([
"id",
"uuid",
"formId",
"formName",
"formTeamId",
"formUserId",
"bookingUid",
"bookingId",
"bookingStatus",
"bookingStatusOrder",
"bookingCreatedAt",
"bookingUserId",
"bookingUserName",
"bookingUserEmail",
"bookingUserAvatarUrl",
"bookingAssignmentReason",
"bookingStartTime",
"bookingEndTime",
"eventTypeId",
"eventTypeParentId",
"eventTypeSchedulingType",
"createdAt",
"utm_source",
"utm_medium",
"utm_campaign",
"utm_term",
"utm_content",
]);
export class InsightsRoutingBaseService {
private prisma: typeof readonlyPrisma;
private options: InsightsRoutingServiceOptions | null;
@@ -118,7 +192,7 @@ export class InsightsRoutingBaseService {
END as "dateRange",
"bookingUid",
"bookingStatus"
FROM "RoutingFormResponseDenormalized"
FROM "RoutingFormResponseDenormalized" rfrd
WHERE ${baseConditions}
)
SELECT
@@ -164,6 +238,166 @@ export class InsightsRoutingBaseService {
});
}
/**
* Returns paginated table data for routing form responses.
* @param sorting Array of sorting objects with id and desc properties
* @param limit Number of records to return
* @param offset Number of records to skip
*/
async getTableData({
sorting,
limit,
offset,
}: {
sorting?: Array<{ id: string; desc: boolean }>;
limit: number;
offset: number;
}) {
const baseConditions = await this.getBaseConditions();
// Build ORDER BY clause
const orderByClause = this.buildOrderByClause(sorting);
// Get total count
const totalCountResult = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*) as count
FROM "RoutingFormResponseDenormalized" rfrd
WHERE ${baseConditions}
`;
const totalCount = Number(totalCountResult[0]?.count || 0);
// Get paginated data with JSON aggregation for attendees and fields
const data = await this.prisma.$queryRaw<Array<InsightsRoutingTableItem>>`
SELECT
rfrd."id",
rfrd."uuid",
rfrd."formId",
rfrd."formName",
rfrd."formTeamId",
rfrd."formUserId",
rfrd."bookingUid",
rfrd."bookingId",
UPPER(rfrd."bookingStatus"::text) as "bookingStatus",
rfrd."bookingStatusOrder",
rfrd."bookingCreatedAt",
rfrd."bookingUserId",
rfrd."bookingUserName",
rfrd."bookingUserEmail",
rfrd."bookingUserAvatarUrl",
rfrd."bookingAssignmentReason",
rfrd."bookingStartTime",
rfrd."bookingEndTime",
rfrd."eventTypeId",
rfrd."eventTypeParentId",
rfrd."eventTypeSchedulingType",
rfrd."createdAt",
rfrd."utm_source",
rfrd."utm_medium",
rfrd."utm_campaign",
rfrd."utm_term",
rfrd."utm_content",
(
SELECT COALESCE(
json_agg(
json_build_object(
'name', a."name",
'timeZone', a."timeZone",
'email', a."email",
'phoneNumber', a."phoneNumber"
)
) FILTER (WHERE a."id" IS NOT NULL),
'[]'::json
)
FROM "Booking" b2
LEFT JOIN "Attendee" a ON a."bookingId" = b2."id"
WHERE b2."uid" = rfrd."bookingUid"
) as "bookingAttendees",
(
SELECT COALESCE(
json_agg(
json_build_object(
'fieldId', f."fieldId",
'valueString', f."valueString",
'valueNumber', f."valueNumber",
'valueStringArray', f."valueStringArray"
)
) FILTER (WHERE f."fieldId" IS NOT NULL),
'[]'::json
)
FROM "RoutingFormResponseField" f
WHERE f."responseId" = rfrd."id"
) as "fields"
FROM "RoutingFormResponseDenormalized" rfrd
WHERE ${baseConditions}
${orderByClause}
LIMIT ${limit}
OFFSET ${offset}
`;
return {
total: totalCount,
data,
};
}
async getRoutingFormStats() {
const baseConditions = await this.getBaseConditions();
// Check if bookingUid filter is applied - if so, return null as metrics don't provide value
const bookingUid = this.filters.columnFilters?.find((filter) => filter.id === "bookingUid");
if (bookingUid && isTextFilterValue(bookingUid.value) && bookingUid.value.data) {
// If bookingUid filter is applied, total count should be either 0 or 1.
// So this metrics doesn't provide any value.
return null;
}
// Get total count
const totalResult = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*) as count
FROM "RoutingFormResponseDenormalized" rfrd
WHERE ${baseConditions}
`;
// Get total without booking count
const totalWithoutBookingResult = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*) as count
FROM "RoutingFormResponseDenormalized" rfrd
WHERE (${baseConditions}) AND ("bookingUid" IS NULL)
`;
const total = Number(totalResult[0]?.count || 0);
const totalWithoutBooking = Number(totalWithoutBookingResult[0]?.count || 0);
return {
total,
totalWithoutBooking,
totalWithBooking: total - totalWithoutBooking,
};
}
private buildOrderByClause(sorting?: Array<{ id: string; desc: boolean }>): Prisma.Sql {
if (!sorting || sorting.length === 0) {
return Prisma.sql`ORDER BY "createdAt" DESC`;
}
const orderByParts = sorting
.filter((sort) => ALLOWED_SORT_COLUMNS.has(sort.id))
.map((sort) => {
const direction = sort.desc ? Prisma.sql`DESC` : Prisma.sql`ASC`;
return Prisma.sql`"${Prisma.raw(sort.id)}" ${direction}`;
});
if (orderByParts.length === 0) {
return Prisma.sql`ORDER BY "createdAt" DESC`;
}
return Prisma.sql`ORDER BY ${orderByParts.reduce((acc, part, index) => {
if (index === 0) return part;
return Prisma.sql`${acc}, ${part}`;
})}`;
}
async getBaseConditions(): Promise<Prisma.Sql> {
const authConditions = await this.getAuthorizationConditions();
const filterConditions = await this.getFilterConditions();
@@ -238,12 +472,30 @@ export class InsightsRoutingBaseService {
}
}
// Extract booking attendees filter
const bookingAttendees = filtersMap["bookingAttendees"];
if (bookingAttendees && isTextFilterValue(bookingAttendees.value)) {
const attendeesCondition = this.buildAttendeeSqlCondition(bookingAttendees.value);
if (attendeesCondition) {
conditions.push(attendeesCondition);
// Extract attendee name filter
const attendeeName = filtersMap["attendeeName"];
if (attendeeName && isTextFilterValue(attendeeName.value)) {
const nameCondition = this.buildAttendeeSqlCondition(attendeeName.value, "name");
if (nameCondition) {
conditions.push(nameCondition);
}
}
// Extract attendee email filter
const attendeeEmail = filtersMap["attendeeEmail"];
if (attendeeEmail && isTextFilterValue(attendeeEmail.value)) {
const emailCondition = this.buildAttendeeSqlCondition(attendeeEmail.value, "email");
if (emailCondition) {
conditions.push(emailCondition);
}
}
// Extract attendee phone filter
const attendeePhone = filtersMap["attendeePhone"];
if (attendeePhone && isTextFilterValue(attendeePhone.value)) {
const phoneCondition = this.buildAttendeeSqlCondition(attendeePhone.value, "phone");
if (phoneCondition) {
conditions.push(phoneCondition);
}
}
@@ -262,18 +514,9 @@ export class InsightsRoutingBaseService {
}
}
// Extract form field filters (exclude the system filters we already processed)
const alreadyHandledFilters = [
"bookingStatusOrder",
"bookingAssignmentReason",
"bookingUid",
"bookingAttendees",
"bookingUserId",
"formId",
];
const fieldIdSchema = z.string().uuid();
const fieldFilters = (this.filters.columnFilters || []).filter(
(filter) => !alreadyHandledFilters.includes(filter.id)
(filter) => fieldIdSchema.safeParse(filter.id).success
);
if (fieldFilters.length > 0) {
@@ -376,14 +619,14 @@ export class InsightsRoutingBaseService {
// For multi-select fields, check if the field contains any of the selected values
return Prisma.sql`EXISTS (
SELECT 1 FROM "RoutingFormResponseField" rrf
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
WHERE rrf."responseId" = rfrd."id"
AND rrf."fieldId" = ${fieldId}
AND rrf."valueStringArray" @> ${filterValue.data.map(String)}
)`;
} else if (isSingleSelectFilterValue(filterValue)) {
return Prisma.sql`EXISTS (
SELECT 1 FROM "RoutingFormResponseField" rrf
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
WHERE rrf."responseId" = rfrd."id"
AND rrf."fieldId" = ${fieldId}
AND rrf."valueString" = ${filterValue.data}
)`;
@@ -394,7 +637,7 @@ export class InsightsRoutingBaseService {
}
return Prisma.sql`EXISTS (
SELECT 1 FROM "RoutingFormResponseField" rrf
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
WHERE rrf."responseId" = rfrd."id"
AND rrf."fieldId" = ${fieldId}
AND rrf."valueString" ${condition}
)`;
@@ -405,7 +648,7 @@ export class InsightsRoutingBaseService {
}
return Prisma.sql`EXISTS (
SELECT 1 FROM "RoutingFormResponseField" rrf
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
WHERE rrf."responseId" = rfrd."id"
AND rrf."fieldId" = ${fieldId}
AND rrf."valueNumber" ${condition}
)`;
@@ -414,27 +657,40 @@ export class InsightsRoutingBaseService {
}
}
private buildAttendeeSqlCondition(filterValue: TextFilterValue): Prisma.Sql | null {
private buildAttendeeSqlCondition(
filterValue: TextFilterValue,
attendeeColumn: "name" | "email" | "phone"
): Prisma.Sql | null {
if (!isTextFilterValue(filterValue)) {
return null;
}
const textCondition = makeSqlCondition(filterValue);
if (!textCondition) {
return null;
}
const nameCondition = Prisma.sql`a.name ${textCondition}`;
const emailCondition = Prisma.sql`a.email ${textCondition}`;
const combinedCondition = Prisma.sql`(${nameCondition}) OR (${emailCondition})`;
// Use switch-case to avoid Prisma.raw for column names
let columnCondition: Prisma.Sql;
switch (attendeeColumn) {
case "name":
columnCondition = Prisma.sql`a.name ${textCondition}`;
break;
case "email":
columnCondition = Prisma.sql`a.email ${textCondition}`;
break;
case "phone":
columnCondition = Prisma.sql`a."phoneNumber" ${textCondition}`;
break;
default:
return null;
}
return Prisma.sql`EXISTS (
SELECT 1 FROM "Booking" b
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
AND (${combinedCondition})
WHERE b."uid" = rfrd."bookingUid"
AND ${columnCondition}
)`;
}
}
@@ -381,8 +381,8 @@ describe("InsightsBookingService Integration Tests", () => {
{
id: "eventTypeId",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: testData.eventType.id,
type: ColumnFilterType.MULTI_SELECT,
data: [testData.eventType.id],
},
},
],
@@ -391,7 +391,9 @@ describe("InsightsBookingService Integration Tests", () => {
const conditions = await service.getFilterConditions();
expect(conditions).toEqual(
Prisma.sql`("eventTypeId" = ${testData.eventType.id}) OR ("eventParentId" = ${testData.eventType.id})`
Prisma.sql`("eventTypeId" IN (${Prisma.join([
testData.eventType.id,
])}) OR "eventParentId" IN (${Prisma.join([testData.eventType.id])}))`
);
await testData.cleanup();
@@ -441,8 +443,8 @@ describe("InsightsBookingService Integration Tests", () => {
{
id: "eventTypeId",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: testData.eventType.id,
type: ColumnFilterType.MULTI_SELECT,
data: [testData.eventType.id],
},
},
{
@@ -458,7 +460,11 @@ describe("InsightsBookingService Integration Tests", () => {
const conditions = await service.getFilterConditions();
expect(conditions).toEqual(
Prisma.sql`(("eventTypeId" = ${testData.eventType.id}) OR ("eventParentId" = ${testData.eventType.id})) AND ("userId" = ${testData.user.id})`
Prisma.sql`(("eventTypeId" IN (${Prisma.join([
testData.eventType.id,
])}) OR "eventParentId" IN (${Prisma.join([testData.eventType.id])}))) AND ("userId" = ${
testData.user.id
})`
);
await testData.cleanup();
@@ -542,8 +548,8 @@ describe("InsightsBookingService Integration Tests", () => {
{
id: "eventTypeId",
value: {
type: ColumnFilterType.SINGLE_SELECT,
data: userEventType.id,
type: ColumnFilterType.MULTI_SELECT,
data: [userEventType.id],
},
},
],
@@ -1,7 +1,8 @@
import type { Team, User, Membership } from "@prisma/client";
import { Prisma } from "@prisma/client";
import { randomUUID } from "crypto";
import { describe, expect, it } from "vitest";
import { v4 as uuid } from "uuid";
import { describe, expect, it, vi, beforeAll, afterAll } from "vitest";
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import prisma from "@calcom/prisma";
@@ -830,6 +831,14 @@ describe("InsightsRoutingService Integration Tests", () => {
});
describe("columnFilters", () => {
beforeAll(() => {
vi.useFakeTimers().setSystemTime(new Date("2025-08-20T00:00:00.000Z"));
});
afterAll(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("should handle empty columnFilters", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
@@ -1044,7 +1053,7 @@ describe("InsightsRoutingService Integration Tests", () => {
await testData.cleanup();
});
it("should filter by booking attendees (text)", async () => {
it("should filter by attendee name (text)", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
@@ -1063,10 +1072,10 @@ describe("InsightsRoutingService Integration Tests", () => {
...defaultFilters,
columnFilters: [
{
id: "bookingAttendees",
id: "attendeeName",
value: {
type: ColumnFilterType.TEXT,
data: { operator: "contains", operand: "john@example.com" },
data: { operator: "contains", operand: "john" },
},
},
],
@@ -1074,21 +1083,31 @@ describe("InsightsRoutingService Integration Tests", () => {
});
const filterConditions = await service.getFilterConditions();
expect(filterConditions).toEqual(
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
defaultFilters.endDate
}::timestamp) AND (EXISTS (
SELECT 1 FROM "Booking" b
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
AND (${Prisma.sql`(a.name ILIKE ${`%john@example.com%`}) OR (a.email ILIKE ${`%john@example.com%`})`})
))`
);
expect(filterConditions).toMatchInlineSnapshot(`
e {
"strings": [
"("createdAt" >= ",
"::timestamp AND "createdAt" <= ",
"::timestamp) AND (EXISTS (
SELECT 1 FROM "Booking" b
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
WHERE b."uid" = rfrd."bookingUid"
AND a.name ILIKE ",
"
))",
],
"values": [
"2025-08-18T00:00:00.000Z",
"2025-08-20T00:00:00.000Z",
"%john%",
],
}
`);
await testData.cleanup();
});
it("should filter by custom form fields (text)", async () => {
it("should filter by attendee email (text)", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
@@ -1107,7 +1126,117 @@ describe("InsightsRoutingService Integration Tests", () => {
...defaultFilters,
columnFilters: [
{
id: "custom-field-id",
id: "attendeeEmail",
value: {
type: ColumnFilterType.TEXT,
data: { operator: "endsWith", operand: "@gmail.com" },
},
},
],
},
});
const filterConditions = await service.getFilterConditions();
expect(filterConditions).toMatchInlineSnapshot(`
e {
"strings": [
"("createdAt" >= ",
"::timestamp AND "createdAt" <= ",
"::timestamp) AND (EXISTS (
SELECT 1 FROM "Booking" b
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
WHERE b."uid" = rfrd."bookingUid"
AND a.email ILIKE ",
"
))",
],
"values": [
"2025-08-18T00:00:00.000Z",
"2025-08-20T00:00:00.000Z",
"%@gmail.com",
],
}
`);
await testData.cleanup();
});
it("should filter by attendee phone number (text)", async () => {
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const defaultFilters = createDefaultFilters();
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
teamId: undefined,
},
filters: {
...defaultFilters,
columnFilters: [
{
id: "attendeePhone",
value: {
type: ColumnFilterType.TEXT,
data: { operator: "contains", operand: "000" },
},
},
],
},
});
const filterConditions = await service.getFilterConditions();
expect(filterConditions).toMatchInlineSnapshot(`
e {
"strings": [
"("createdAt" >= ",
"::timestamp AND "createdAt" <= ",
"::timestamp) AND (EXISTS (
SELECT 1 FROM "Booking" b
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
WHERE b."uid" = rfrd."bookingUid"
AND a."phoneNumber" ILIKE ",
"
))",
],
"values": [
"2025-08-18T00:00:00.000Z",
"2025-08-20T00:00:00.000Z",
"%000%",
],
}
`);
await testData.cleanup();
});
it("should filter by custom form fields (text)", async () => {
const customFieldId = uuid();
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
});
const defaultFilters = createDefaultFilters();
const service = new InsightsRoutingService({
prisma,
options: {
scope: "user",
userId: testData.user.id,
orgId: testData.org.id,
teamId: undefined,
},
filters: {
...defaultFilters,
columnFilters: [
{
id: customFieldId,
value: {
type: ColumnFilterType.TEXT,
data: { operator: "equals", operand: "test value" },
@@ -1123,8 +1252,8 @@ describe("InsightsRoutingService Integration Tests", () => {
defaultFilters.endDate
}::timestamp) AND (EXISTS (
SELECT 1 FROM "RoutingFormResponseField" rrf
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
AND rrf."fieldId" = ${"custom-field-id"}
WHERE rrf."responseId" = rfrd."id"
AND rrf."fieldId" = ${customFieldId}
AND rrf."valueString" = ${"test value"}
))`
);
@@ -1138,6 +1267,7 @@ describe("InsightsRoutingService Integration Tests", () => {
orgRole: MembershipRole.OWNER,
});
const customFieldId = uuid();
const defaultFilters = createDefaultFilters();
const service = new InsightsRoutingService({
prisma,
@@ -1151,7 +1281,7 @@ describe("InsightsRoutingService Integration Tests", () => {
...defaultFilters,
columnFilters: [
{
id: "custom-multi-field-id",
id: customFieldId,
value: {
type: ColumnFilterType.MULTI_SELECT,
data: ["option1", "option2"],
@@ -1167,8 +1297,8 @@ describe("InsightsRoutingService Integration Tests", () => {
defaultFilters.endDate
}::timestamp) AND (EXISTS (
SELECT 1 FROM "RoutingFormResponseField" rrf
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
AND rrf."fieldId" = ${"custom-multi-field-id"}
WHERE rrf."responseId" = rfrd."id"
AND rrf."fieldId" = ${customFieldId}
AND rrf."valueStringArray" @> ${["option1", "option2"]}
))`
);
@@ -1177,6 +1307,7 @@ describe("InsightsRoutingService Integration Tests", () => {
});
it("should combine multiple filters with AND", async () => {
const customFieldId = uuid();
const testData = await createTestData({
teamRole: MembershipRole.OWNER,
orgRole: MembershipRole.OWNER,
@@ -1209,7 +1340,7 @@ describe("InsightsRoutingService Integration Tests", () => {
},
},
{
id: "custom-field-id",
id: customFieldId,
value: {
type: ColumnFilterType.TEXT,
data: { operator: "equals", operand: "test" },
@@ -1227,8 +1358,8 @@ describe("InsightsRoutingService Integration Tests", () => {
"pending",
]}))) AND ("bookingAssignmentReason" ILIKE ${`%manual%`})) AND (EXISTS (
SELECT 1 FROM "RoutingFormResponseField" rrf
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
AND rrf."fieldId" = ${"custom-field-id"}
WHERE rrf."responseId" = rfrd."id"
AND rrf."fieldId" = ${customFieldId}
AND rrf."valueString" = ${"test"}
))`
);
@@ -1330,6 +1461,8 @@ describe("InsightsRoutingService Integration Tests", () => {
orgRole: MembershipRole.OWNER,
});
const customFieldId = uuid();
const defaultFilters = createDefaultFilters();
const service = new InsightsRoutingService({
prisma,
@@ -1350,7 +1483,7 @@ describe("InsightsRoutingService Integration Tests", () => {
},
},
{
id: "custom-field-id", // Custom field filter
id: customFieldId, // Custom field filter
value: {
type: ColumnFilterType.TEXT,
data: { operator: "equals", operand: "test" },
@@ -1366,8 +1499,8 @@ describe("InsightsRoutingService Integration Tests", () => {
defaultFilters.endDate
}::timestamp) AND ("bookingStatusOrder" = ANY(${["pending"]}))) AND (EXISTS (
SELECT 1 FROM "RoutingFormResponseField" rrf
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
AND rrf."fieldId" = ${"custom-field-id"}
WHERE rrf."responseId" = rfrd."id"
AND rrf."fieldId" = ${customFieldId}
AND rrf."valueString" = ${"test"}
))`
);
@@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
// eslint-disable-next-line no-restricted-imports
import debounce from "lodash/debounce";
import { useMemo, useEffect, useCallback, useState, useRef } from "react";
import { useMemo, useEffect, useCallback, useState, useRef, useContext } from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
@@ -10,11 +10,12 @@ import {
BookerStoreProvider,
useInitializeBookerStoreContext,
useBookerStoreContext,
BookerStoreContext,
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/useLocalSet";
import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
@@ -38,6 +39,8 @@ import { useCalendarsBusyTimes } from "../hooks/useCalendarsBusyTimes";
import { useConnectedCalendars } from "../hooks/useConnectedCalendars";
import { useMe } from "../hooks/useMe";
import { useSlots } from "../hooks/useSlots";
import { useVerifyCode } from "../hooks/useVerifyCode";
import { useVerifyEmail } from "../hooks/useVerifyEmail";
import { AtomsWrapper } from "../src/components/atoms-wrapper";
import type {
BookerPlatformWrapperAtomPropsForIndividual,
@@ -65,6 +68,7 @@ const BookerPlatformWrapperComponent = (
startTime: customStartTime,
showNoAvailabilityDialog,
silentlyHandleCalendarFailures = false,
hideEventMetadata = false,
} = props;
const layout = BookerLayouts[view];
@@ -87,14 +91,12 @@ const BookerPlatformWrapperComponent = (
Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault"))
);
const prevStateRef = useRef<BookerStoreValues | null>(null);
const getStateValues = useCallback(
(state: ReturnType<typeof useBookerStore.getState>): BookerStoreValues => {
return Object.fromEntries(
Object.entries(state).filter(([_, value]) => typeof value !== "function")
) as BookerStoreValues;
},
[]
);
const bookerStoreContext = useContext(BookerStoreContext);
const getStateValues = useCallback((state: any): BookerStoreValues => {
return Object.fromEntries(
Object.entries(state).filter(([_, value]) => typeof value !== "function")
) as BookerStoreValues;
}, []);
const debouncedStateChange = useMemo(() => {
return debounce(
(currentStateValues: BookerStoreValues, callback: (values: BookerStoreValues) => void) => {
@@ -111,15 +113,15 @@ const BookerPlatformWrapperComponent = (
}, []);
useEffect(() => {
if (!onBookerStateChange) return;
if (!onBookerStateChange || !bookerStoreContext) return;
const unsubscribe = useBookerStore.subscribe((state) => {
const unsubscribe = bookerStoreContext.subscribe((state) => {
const currentStateValues = getStateValues(state);
debouncedStateChange(currentStateValues, onBookerStateChange);
});
// Initial call with current state
const initialState = getStateValues(useBookerStore.getState());
const initialState = getStateValues(bookerStoreContext.getState());
onBookerStateChange(initialState);
prevStateRef.current = initialState;
@@ -127,7 +129,7 @@ const BookerPlatformWrapperComponent = (
unsubscribe();
debouncedStateChange.cancel();
};
}, [onBookerStateChange, getStateValues, debouncedStateChange]);
}, [onBookerStateChange, getStateValues, debouncedStateChange, bookerStoreContext]);
useGetBookingForReschedule({
uid: props.rescheduleUid ?? props.bookingUid ?? "",
@@ -395,6 +397,23 @@ const BookerPlatformWrapperComponent = (
handleSlotReservation,
});
const verifyEmail = useVerifyEmail({
email: bookerForm.formEmail,
name: bookerForm.formName,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
onVerifyEmail: bookerForm.beforeVerifyEmail,
});
const verifyCode = useVerifyCode({
onSuccess: () => {
if (!bookerForm.formEmail) return;
verifyEmail.setVerifiedEmail(bookerForm.formEmail);
verifyEmail.setEmailVerificationModalVisible(false);
handleBookEvent();
},
});
const { data: connectedCalendars, isPending: fetchingConnectedCalendars } = useConnectedCalendars({
enabled: hasSession,
});
@@ -553,26 +572,13 @@ const BookerPlatformWrapperComponent = (
return;
},
}}
verifyEmail={{
isEmailVerificationModalVisible: false,
setEmailVerificationModalVisible: () => {
return;
},
setVerifiedEmail: () => {
return;
},
handleVerifyEmail: () => {
return;
},
renderConfirmNotVerifyEmailButtonCond: true,
isVerificationCodeSending: false,
}}
verifyEmail={verifyEmail}
bookerForm={bookerForm}
event={event}
schedule={schedule}
orgBannerUrl={bannerUrl ?? event.data?.bannerUrl}
bookerLayout={bookerLayout}
verifyCode={undefined}
bookerLayout={{ ...bookerLayout, hideEventTypeDetails: hideEventMetadata }}
verifyCode={verifyCode}
isPlatform
hasValidLicense={true}
isBookingDryRun={isBookingDryRun ?? routingParams?.isBookingDryRun}
+1
View File
@@ -86,6 +86,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
startTime?: string | Date;
roundRobinHideOrgAndTeam?: boolean;
silentlyHandleCalendarFailures?: boolean;
hideEventMetadata?: boolean;
};
type VIEW_TYPE = keyof typeof BookerLayouts;
+1 -1
View File
@@ -11,7 +11,7 @@ export const QUERY_KEY = "get-me";
* Access Token must be provided to CalProvider in order to use this hook
* @returns The result of the query containing the user's profile.
*/
export const useMe = (isEmbed: boolean = false) => {
export const useMe = (isEmbed = false) => {
const pathname = `/${V2_ENDPOINTS.me}`;
const me = useQuery({
queryKey: [QUERY_KEY],
@@ -0,0 +1,115 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, ApiErrorResponse, ApiSuccessResponse } from "@calcom/platform-types";
import http from "../lib/http";
export type UseVerifyCodeReturnType = ReturnType<typeof useVerifyCode>;
type UseVerifyCodeProps = {
onSuccess: (isVerified: boolean) => void;
};
interface VerifyEmailInput {
email: string;
code: string;
}
interface VerifyEmailResponse {
verified: boolean;
}
export const useVerifyCode = ({ onSuccess }: UseVerifyCodeProps) => {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState("");
const [value, setValue] = useState("");
const [hasVerified, setHasVerified] = useState(false);
const verifyCodeWithSessionRequiredMutation = useMutation<
ApiSuccessResponse<VerifyEmailResponse>,
ApiErrorResponse,
VerifyEmailInput
>({
mutationFn: (props: VerifyEmailInput) => {
return http
.post<ApiResponse<{ verified: boolean }>>(
"/atoms/verification/email/verify-code-authenticated",
props
)
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error?.message || "Invalid verification code");
});
},
onSuccess: (data) => {
setIsPending(false);
setHasVerified(data.data.verified);
onSuccess(data.data.verified);
},
onError: (err) => {
setIsPending(false);
setHasVerified(false);
if (err.error?.message?.includes("invalid") || err.error?.message?.includes("Invalid")) {
setError("Code provided is invalid");
} else {
setError(err.error?.message || "Verification failed");
}
},
});
const verifyCodeWithSessionNotRequiredMutation = useMutation<
ApiSuccessResponse<VerifyEmailResponse>,
ApiErrorResponse,
VerifyEmailInput
>({
mutationFn: (props: VerifyEmailInput) => {
return http
.post<ApiResponse<{ verified: boolean }>>("/atoms/verification/email/verify-code", props)
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error?.message || "Invalid verification code");
});
},
onSuccess: (data) => {
setIsPending(false);
setHasVerified(data.data.verified);
onSuccess(data.data.verified);
},
onError: (err) => {
setIsPending(false);
setHasVerified(false);
if (err.error?.message?.includes("invalid") || err.error?.message?.includes("Invalid")) {
setError("Code provided is invalid");
} else {
setError(err.error?.message || "Verification failed");
}
},
});
const verifyCodeWithSessionRequired = (code: string, email: string) => {
verifyCodeWithSessionRequiredMutation.mutate({ code, email });
};
const verifyCodeWithSessionNotRequired = (code: string, email: string) => {
verifyCodeWithSessionNotRequiredMutation.mutate({ code, email });
};
return {
verifyCodeWithSessionRequired,
verifyCodeWithSessionNotRequired,
isPending,
setIsPending,
error,
value,
hasVerified,
setValue,
setHasVerified,
resetErrors: () => setError(""),
};
};
@@ -0,0 +1,104 @@
import { useMutation } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useBookerStore } from "@calcom/features/bookings/Booker/store";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import type { ApiResponse, ApiErrorResponse, ApiSuccessResponseWithoutData } from "@calcom/platform-types";
import { useMe } from "../hooks/useMe";
import http from "../lib/http";
export interface IUseVerifyEmailProps {
email: string;
onVerifyEmail?: () => void;
name?: string | { firstName: string; lastname?: string };
requiresBookerEmailVerification?: boolean;
}
export type UseVerifyEmailReturnType = ReturnType<typeof useVerifyEmail>;
interface RequestEmailVerificationInput {
email: string;
username?: string;
}
export const useVerifyEmail = ({
email,
name,
requiresBookerEmailVerification,
onVerifyEmail,
}: IUseVerifyEmailProps) => {
const [isEmailVerificationModalVisible, setEmailVerificationModalVisible] = useState(false);
const verifiedEmail = useBookerStore((state) => state.verifiedEmail);
const setVerifiedEmail = useBookerStore((state) => state.setVerifiedEmail);
const debouncedEmail = useDebounce(email, 600);
const { data: user } = useMe();
const { data: isEmailVerificationRequired } = useQuery<boolean>({
queryKey: ["isEmailVerificationRequired", debouncedEmail],
queryFn: () => {
return http
?.get<ApiResponse<boolean>>("/atoms/verification/email/check", {
params: {
email: debouncedEmail,
userSessionEmail: user?.data?.email || "",
},
})
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data.data;
}
throw new Error(res.data.error.message);
});
},
enabled: !!debouncedEmail,
});
const sendEmailVerificationMutation = useMutation<
ApiSuccessResponseWithoutData,
ApiErrorResponse,
RequestEmailVerificationInput
>({
mutationFn: (props: RequestEmailVerificationInput) => {
return http
.post<ApiResponse<{ sent: boolean }>>("/atoms/verification/email/send-code", props)
.then((res) => {
if (res.data.status === SUCCESS_STATUS) {
return res.data;
}
throw new Error(res.data.error?.message || "Failed to send verification email");
});
},
onSuccess: () => {
setEmailVerificationModalVisible(true);
},
onError: (err) => {
console.error("Failed to send verification email:", err);
},
});
const handleVerifyEmail = () => {
onVerifyEmail?.();
sendEmailVerificationMutation.mutate({
email,
username: typeof name === "string" ? name : name?.firstName,
});
};
const isVerificationCodeSending = sendEmailVerificationMutation.isPending;
const renderConfirmNotVerifyEmailButtonCond =
(!requiresBookerEmailVerification && !isEmailVerificationRequired) ||
(email && verifiedEmail && verifiedEmail === email);
return {
handleVerifyEmail,
isEmailVerificationModalVisible,
setEmailVerificationModalVisible,
setVerifiedEmail,
renderConfirmNotVerifyEmailButtonCond: Boolean(renderConfirmNotVerifyEmailButtonCond),
isVerificationCodeSending,
};
};
@@ -137,6 +137,7 @@ export default function Bookings(props: { calUsername: string; calEmail: string
hostsLimit={3}
allowUpdatingUrlParams={true}
silentlyHandleCalendarFailures={false}
// hideEventMetadata={true}
/>
</>
)}
+8
View File
@@ -120,5 +120,13 @@ export { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
export { verifyPhoneNumber, sendVerificationCode };
export { verifyCodeUnAuthenticated } from "@calcom/trpc/server/routers/viewer/auth/verifyCodeUnAuthenticated.handler";
export { verifyCode as verifyCodeAuthenticated } from "@calcom/trpc/server/routers/viewer/organizations/verifyCode.handler";
export { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail";
export { checkEmailVerificationRequired } from "@calcom/trpc/server/routers/publicViewer/checkIfUserEmailVerificationRequired.handler";
export { TeamService } from "@calcom/lib/server/service/teamService";
export { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
Binary file not shown.
+1
View File
@@ -10,6 +10,7 @@ export const availabilityUserSelect = {
endTime: true,
timeFormat: true,
defaultScheduleId: true,
isPlatformManaged: true,
// Relationships
schedules: {
select: {
-1
View File
@@ -17,7 +17,6 @@ export const ENDPOINTS = [
"dsync",
"eventTypes",
"features",
"highPerf",
"i18n",
"insights",
"me",
@@ -6,7 +6,16 @@ import type { TUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVer
const log = logger.getSubLogger({ prefix: ["checkIfUserEmailVerificationRequired"] });
export const userWithEmailHandler = async ({ input }: { input: TUserEmailVerificationRequiredSchema }) => {
const { userSessionEmail, email } = input;
return checkEmailVerificationRequired(input);
};
export const checkEmailVerificationRequired = async ({
userSessionEmail,
email,
}: {
userSessionEmail?: string;
email: string;
}) => {
const baseEmail = extractBaseEmail(email);
const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS
@@ -25,7 +25,6 @@ import { dsyncRouter } from "./dsync/_router";
import { eventTypesRouter } from "./eventTypes/_router";
import { filterSegmentsRouter } from "./filterSegments/_router";
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
import { highPerfRouter } from "./highPerf/_router";
import { i18nRouter } from "./i18n/_router";
import { meRouter } from "./me/_router";
import { oAuthRouter } from "./oAuth/_router";
@@ -79,7 +78,6 @@ export const viewerRouter = router({
googleWorkspace: googleWorkspaceRouter,
admin: adminRouter,
attributes: attributesRouter,
highPerf: highPerfRouter,
routingForms: routingFormsRouter,
credits: creditsRouter,
ooo: oooRouter,
@@ -3,10 +3,10 @@ import type { NextApiRequest } from "next";
import { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import getIP from "@calcom/lib/getIP";
import { hashEmail, piiHasher } from "@calcom/lib/server/PiiHasher";
import type { TRPCContext } from "../../../createContext";
import type { TSendVerifyEmailCodeSchema } from "./sendVerifyEmailCode.schema";
import { hashEmail, piiHasher } from "@calcom/lib/server/PiiHasher";
type SendVerifyEmailCode = {
input: TSendVerifyEmailCodeSchema;
@@ -15,18 +15,25 @@ type SendVerifyEmailCode = {
export const sendVerifyEmailCodeHandler = async ({ input, req }: SendVerifyEmailCode) => {
const identifier = req ? piiHasher.hash(getIP(req as NextApiRequest)) : hashEmail(input.email);
return sendVerifyEmailCode({ input, identifier });
};
export const sendVerifyEmailCode = async ({
input,
identifier,
}: {
input: TSendVerifyEmailCodeSchema;
identifier: string;
}) => {
await checkRateLimitAndThrowError({
rateLimitingType: "core",
identifier: `emailVerifyByCode.${identifier}`,
});
const email = await sendEmailVerificationByCode({
return await sendEmailVerificationByCode({
email: input.email,
username: input.username,
language: input.language,
isVerifyingEmail: input.isVerifyingEmail,
});
return email;
};
@@ -10,6 +10,10 @@ type VerifyTokenOptions = {
};
export const verifyCodeUnAuthenticatedHandler = async ({ input }: VerifyTokenOptions) => {
return verifyCodeUnAuthenticated(input);
};
export const verifyCodeUnAuthenticated = async (input: ZVerifyCodeInputSchema) => {
const { email, code } = input;
if (!email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
@@ -212,6 +212,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
id: booking.user?.id,
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
name: booking.user?.name || "Nameless",
username: booking.user?.username || undefined,
@@ -1,19 +0,0 @@
import publicProcedure from "../../../procedures/publicProcedure";
import { router } from "../../../trpc";
import { ZGetTeamScheduleInputSchema } from "./getTeamSchedule.schema";
type SlotsRouterHandlerCache = {
getTeamSchedule?: typeof import("./getTeamSchedule.handler").getTeamScheduleHandler;
};
/** This should be called getAvailableSlots */
export const highPerfRouter = router({
getTeamSchedule: publicProcedure.input(ZGetTeamScheduleInputSchema).query(async ({ input, ctx }) => {
const { getTeamScheduleHandler } = await import("./getTeamSchedule.handler");
return getTeamScheduleHandler({
ctx,
input,
});
}),
});
@@ -1,19 +0,0 @@
import type { IncomingMessage } from "http";
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
import type { TGetTeamScheduleInputSchema } from "./getTeamSchedule.schema";
export type GetTeamScheduleOptions = {
ctx?: ContextForGetSchedule;
input: TGetTeamScheduleInputSchema;
};
interface ContextForGetSchedule extends Record<string, unknown> {
req?: (IncomingMessage & { cookies: Partial<{ [key: string]: string }> }) | undefined;
}
export const getTeamScheduleHandler = async ({ ctx, input }: GetTeamScheduleOptions) => {
const availableSlotsService = getAvailableSlotsService();
return await availableSlotsService.getAvailableSlots({ ctx, input });
};
@@ -1,7 +0,0 @@
import type { z } from "zod";
import { getScheduleSchema } from "../slots/types";
export const ZGetTeamScheduleInputSchema = getScheduleSchema;
export type TGetTeamScheduleInputSchema = z.infer<typeof ZGetTeamScheduleInputSchema>;
@@ -3,12 +3,12 @@ import { createHash } from "crypto";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
import { totpRawCheck } from "@calcom/lib/totp";
import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import { hashEmail } from "@calcom/lib/server/PiiHasher";
type VerifyCodeOptions = {
ctx: {
@@ -18,9 +18,22 @@ type VerifyCodeOptions = {
};
export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => {
const { email, code } = input;
const { user } = ctx;
return verifyCode({
user: ctx.user,
email: input.email,
code: input.code,
});
};
export const verifyCode = async ({
user,
email,
code,
}: {
user?: Pick<NonNullable<TrpcSessionUser>, "role">;
email?: string;
code?: string;
}) => {
if (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
if (!IS_PRODUCTION || process.env.NEXT_PUBLIC_IS_E2E) {
@@ -165,7 +165,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
"w-full min-w-0 truncate border-0 bg-transparent focus:outline-none focus:ring-0",
"text-default rounded-lg text-sm font-medium leading-none",
"placeholder:text-muted disabled:cursor-not-allowed disabled:bg-transparent",
addOnLeading && "pl-0.5 pr-0",
addOnLeading && "rounded-none pl-0.5 pr-0",
addOnSuffix && "pl-0",
className
)}
+2 -1
View File
@@ -249,7 +249,8 @@
"CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY",
"CAL_VIDEO_RECORDING_TOKEN_SECRET",
"ORGANIZER_EMAIL_EXEMPT_DOMAINS",
"SLOTS_CACHE_TTL"
"SLOTS_CACHE_TTL",
"CSP_POLICY"
],
"tasks": {
"@calcom/prisma#build": {
+727 -737
View File
File diff suppressed because it is too large Load Diff