Compare commits

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

* fix tests

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

* fix type errors

* Improve check

---------

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

* replaced the hardcoded OWNER string with the enum value

* replaced enum with hard coded OWNER value for safty

* added enum usage

* added admin lookout if owner validation fails

* chore

* chore

* fixed tests

* chore

* chore

* chore

* chore

---------

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

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

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

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

* chore: update ESLint dependencies to resolve configuration issues

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

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

* revert eslint change

* some changes

* refactor: implement discriminated union for segment types

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

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

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

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

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

* fix: correct SegmentIdentifier type to exclude undefined

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

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

* remove unused segment

* a little update

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

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

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

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

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

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

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

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

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

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

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

* remove unused segment

* some fixes

* revert schema change

* rename defaultSegmentId to systemSegmentId

* renaming

* fix save button

* remove as any

* clean up prisma migrations

* type fixes

* many fixes

* remove icon property

* fix infinite rendering

* fix race condition

* re-visiting useSegments implementation WIP

* extract useElementByClassName

* add e2e tests

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

* add useSegments to /insights and fix e2e test

* fix type error

* fix type error

* apply feedback

---------

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

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

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

* Update rateLimit.ts

* chore: Bump @unkey/ratelimit version

* Add required props to rateLimiter onError response

---------

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

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

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

* feat: update platform hooks to use v2 verification endpoints

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

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

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

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

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

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

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

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

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

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

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

* extract logic into seperate functions

* Update index.ts

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

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

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

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

* fix: add missing type imports to verification service

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

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

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

* added Throttle

* added chagelog

* chore: update platform libraries to 0.0.317

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

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

* Update verifyCode.handler.ts

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

---------

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

* added tests

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

* Fixing multi select query generation

* Added null check for eventType filters

* Updated test to support multiselect

* Type fix

---------

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

* export getOrgContext

* update RSC

* refactor

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

* fix tests

* fix type error

* update service usage

* fix attendee filters

* display attendee phone number

* use useInsightsRoutingParameters()

* remove unnecessary type check

* clean up

* fix type error

* fix wrong import

* fix integration tests

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

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

---------

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

* added changelog

* udpate

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

* add changesets

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

* Clean up
2025-08-21 02:27:52 +00:00
101 changed files with 3884 additions and 1148 deletions
+3
View File
@@ -0,0 +1,3 @@
undecided:
- calcom-monorepo
- "@calcom/prisma"
+1 -1
View File
@@ -38,7 +38,7 @@
"@axiomhq/winston": "^1.2.0",
"@calcom/platform-constants": "*",
"@calcom/platform-enums": "*",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.318",
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320",
"@calcom/platform-types": "*",
"@calcom/platform-utils": "*",
"@calcom/prisma": "*",
+10
View File
@@ -9,6 +9,7 @@ import { TestingModule } from "@nestjs/testing";
import { Test } from "@nestjs/testing";
import * as request from "supertest";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -33,6 +34,7 @@ describe("AppController", () => {
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let apiKeyString: string;
@@ -108,6 +110,14 @@ describe("AppController", () => {
organization: { connect: { id: organization.id } },
});
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
app = moduleRef.createNestApplication();
await app.init();
});
@@ -18,7 +18,6 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
import { randomString } from "test/utils/randomString";
@@ -27,7 +26,6 @@ import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@cal
import {
CancelBookingInput_2024_08_13,
CancelSeatedBookingInput_2024_08_13,
CreateRecurringSeatedBookingOutput_2024_08_13,
CreateSeatedBookingOutput_2024_08_13,
GetBookingOutput_2024_08_13,
GetBookingsOutput_2024_08_13,
@@ -35,11 +33,9 @@ import {
RescheduleSeatedBookingInput_2024_08_13,
} from "@calcom/platform-types";
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
import { Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
describe("Seated bookings", () => {
let app: INestApplication;
let organization: Team;
@@ -48,7 +44,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let bookingsRepositoryFixture: BookingsRepositoryFixture;
let schedulesService: SchedulesService_2024_04_15;
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
let teamRepositoryFixture: TeamRepositoryFixture;
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
@@ -58,7 +53,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
let apiKeyString: string;
let seatedEventTypeId: number;
const maxRecurrenceCount = 3;
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
@@ -83,7 +77,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
@@ -190,6 +183,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -264,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid: createdSeatedBooking.seatUid,
bookingFieldsResponses: {
guests: [],
name: createdSeatedBooking.attendees[0].name,
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
},
@@ -278,6 +273,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -517,6 +513,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
absent: false,
seatUid,
bookingFieldsResponses: {
guests: [],
name: body.attendee.name,
...body.bookingFieldsResponses,
},
@@ -21,7 +21,12 @@ import { randomString } from "test/utils/randomString";
import { withApiAuth } from "test/utils/withApiAuth";
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
import {
CreateBookingInput_2024_08_13,
BookingOutput_2024_08_13,
CreateRecurringBookingInput_2024_08_13,
RecurringBookingOutput_2024_08_13,
} from "@calcom/platform-types";
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
describe("Bookings Endpoints 2024-08-13", () => {
@@ -44,6 +49,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let normalEventType: EventType;
const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
let variableLengthRecurringEventType: EventType;
const VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG = `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`;
beforeAll(async () => {
const moduleRef = await withApiAuth(
@@ -105,6 +112,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
user.id
);
variableLengthRecurringEventType = await eventTypesRepositoryFixture.create(
{
title: `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`,
slug: VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG,
length: 15,
metadata: { multipleDuration: [15, 30, 60] },
recurringEvent: { freq: 2, count: 3, interval: 1 }, // Weekly, 3 times, every 1 week
},
user.id
);
app = moduleRef.createNestApplication();
bootstrap(app as NestExpressApplication);
@@ -246,6 +264,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
});
});
describe("create recurring bookings", () => {
it("should create recurring bookings with custom lengthInMinutes", async () => {
const lengthInMinutes = 30;
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 1, 1, 14, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes,
recurrenceCount: 2,
attendee: {
name: "Mr Recurring",
email: "mr_recurring@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(201)
.then(async (response) => {
const responseBody: { status: string; data: RecurringBookingOutput_2024_08_13[] } = response.body;
expect(responseBody.status).toEqual(SUCCESS_STATUS);
expect(responseBody.data).toBeDefined();
expect(Array.isArray(responseBody.data)).toBe(true);
expect(responseBody.data).toHaveLength(2);
const firstBooking = responseBody.data[0];
const secondBooking = responseBody.data[1];
expect(firstBooking.duration).toEqual(lengthInMinutes);
expect(secondBooking.duration).toEqual(lengthInMinutes);
});
});
it("should reject recurring booking with invalid lengthInMinutes", async () => {
const body: CreateRecurringBookingInput_2024_08_13 = {
start: new Date(Date.UTC(2030, 3, 1, 10, 0, 0)).toISOString(),
eventTypeId: variableLengthRecurringEventType.id,
lengthInMinutes: 45, // Not in allowed [15, 30, 60]
recurrenceCount: 2,
attendee: {
name: "Mr Invalid",
email: "mr_invalid@gmail.com",
timeZone: "Europe/Rome",
language: "it",
},
};
return request(app.getHttpServer())
.post("/v2/bookings")
.send(body)
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
.expect(400);
});
});
afterAll(async () => {
await oauthClientRepositoryFixture.delete(oAuthClient.id);
await teamRepositoryFixture.delete(organization.id);
@@ -420,6 +420,9 @@ export class InputBookingsService_2024_08_13 {
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
}
this.validateBookingLengthInMinutes(inputBooking, eventType);
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
const repeatsEvery = occurrence.interval;
@@ -456,7 +459,7 @@ export class InputBookingsService_2024_08_13 {
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
for (let i = 0; i < repeatsTimes; i++) {
const endTime = startTime.plus({ minutes: eventType.length });
const endTime = startTime.plus({ minutes: lengthInMinutes });
events.push({
start: startTime.toISO(),
@@ -1,6 +1,5 @@
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
import { PrismaModule } from "@/modules/prisma/prisma.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { Module } from "@nestjs/common";
@@ -4,10 +4,12 @@ import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/ato
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
import { AtomsVerificationController } from "@/modules/atoms/controllers/atoms.verification.controller";
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
@@ -32,6 +34,7 @@ import { Module } from "@nestjs/common";
AtomsRepository,
UsersService,
SchedulesAtomsService,
VerificationAtomsService,
RedisService,
],
exports: [EventTypesAtomService],
@@ -40,6 +43,7 @@ import { Module } from "@nestjs/common";
AtomsEventTypesController,
AtomsConferencingAppsController,
AtomsSchedulesController,
AtomsVerificationController,
],
})
export class AtomsModule {}
@@ -0,0 +1,108 @@
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import { Throttle } from "@/lib/endpoint-throttler-decorator";
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { UserWithProfile } from "@/modules/users/users.repository";
import {
Controller,
Post,
Body,
UseGuards,
Version,
VERSION_NEUTRAL,
HttpCode,
HttpStatus,
Get,
Query,
} from "@nestjs/common";
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { ApiResponse } from "@calcom/platform-types";
@Controller({
path: "/v2/atoms",
version: API_VERSIONS_VALUES,
})
@DocsTags("Atoms - verification endpoints for atoms")
@DocsExcludeController(true)
export class AtomsVerificationController {
constructor(private readonly verificationService: VerificationAtomsService) {}
@Post("/verification/email/send-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "atoms_verification_email_send_code" })
async sendEmailVerificationCode(
@Body() body: SendVerificationEmailInput
): Promise<SendVerificationEmailOutput> {
const result = await this.verificationService.sendEmailVerificationCode({
email: body.email,
username: body.username,
language: body.language,
isVerifyingEmail: body.isVerifyingEmail,
});
return {
data: { sent: result.ok && !result.skipped },
status: SUCCESS_STATUS,
};
}
@Get("/verification/email/check")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async checkEmailVerificationRequired(
@Query() query: CheckEmailVerificationRequiredParams
): Promise<ApiResponse<boolean>> {
const required = await this.verificationService.checkEmailVerificationRequired({
email: query.email,
userSessionEmail: query.userSessionEmail,
});
return {
data: required,
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code")
@Version(VERSION_NEUTRAL)
@HttpCode(HttpStatus.OK)
async verifyEmailCode(@Body() body: VerifyEmailCodeInput): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeUnAuthenticated({
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
@Post("/verification/email/verify-code-authenticated")
@Version(VERSION_NEUTRAL)
@UseGuards(ApiAuthGuard)
@HttpCode(HttpStatus.OK)
async verifyEmailCodeAuthenticated(
@Body() body: VerifyEmailCodeInput,
@GetUser() user: UserWithProfile
): Promise<VerifyEmailCodeOutput> {
const verified = await this.verificationService.verifyEmailCodeAuthenticated(user, {
email: body.email,
code: body.code,
});
return {
data: { verified },
status: SUCCESS_STATUS,
};
}
}
@@ -0,0 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString } from "class-validator";
export class CheckEmailVerificationRequiredParams {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "user@example.com" })
@IsOptional()
@IsString()
userSessionEmail?: string;
}
@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsOptional, IsString, IsBoolean } from "class-validator";
export class SendVerificationEmailInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiPropertyOptional({ example: "johndoe" })
@IsOptional()
@IsString()
username?: string;
@ApiPropertyOptional({ example: "en" })
@IsOptional()
@IsString()
language?: string;
@ApiPropertyOptional({ example: true })
@IsOptional()
@IsBoolean()
isVerifyingEmail?: boolean;
}
@@ -0,0 +1,12 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsEmail, IsString } from "class-validator";
export class VerifyEmailCodeInput {
@ApiProperty({ example: "user@example.com" })
@IsEmail()
email!: string;
@ApiProperty({ example: "123456" })
@IsString()
code!: string;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class SendVerificationEmailData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly sent!: boolean;
}
export class SendVerificationEmailOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => SendVerificationEmailData)
@Expose()
@ApiProperty({ type: SendVerificationEmailData })
readonly data!: SendVerificationEmailData;
}
@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { Expose, Type } from "class-transformer";
import { IsString, ValidateNested, IsBoolean } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class VerifyEmailCodeData {
@ApiProperty({ example: true })
@IsBoolean()
@Expose()
readonly verified!: boolean;
}
export class VerifyEmailCodeOutput {
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
@IsString()
@Expose()
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ValidateNested()
@Type(() => VerifyEmailCodeData)
@Expose()
@ApiProperty({ type: VerifyEmailCodeData })
readonly data!: VerifyEmailCodeData;
}
@@ -0,0 +1,64 @@
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
import { UserWithProfile } from "@/modules/users/users.repository";
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
import {
verifyCodeUnAuthenticated,
verifyCodeAuthenticated,
sendEmailVerificationByCode,
checkEmailVerificationRequired,
} from "@calcom/platform-libraries";
@Injectable()
export class VerificationAtomsService {
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
return await checkEmailVerificationRequired(input);
}
async verifyEmailCodeUnAuthenticated(input: VerifyEmailCodeInput) {
try {
return await verifyCodeUnAuthenticated(input);
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email and code are required");
}
}
throw new BadRequestException("Verification failed");
}
}
async verifyEmailCodeAuthenticated(user: UserWithProfile, input: VerifyEmailCodeInput) {
try {
return await verifyCodeAuthenticated({
user,
email: input.email,
code: input.code,
});
} catch (error) {
if (error instanceof Error) {
if (error.message === "invalid_code") {
throw new BadRequestException("Invalid verification code");
}
if (error.message === "BAD_REQUEST") {
throw new BadRequestException("Email, code, and user ID are required");
}
}
throw new UnauthorizedException("Verification failed");
}
}
async sendEmailVerificationCode(input: SendVerificationEmailInput) {
return await sendEmailVerificationByCode({
email: input.email,
username: input.username,
language: input.language,
isVerifyingEmail: input.isVerifyingEmail,
});
}
}
@@ -6,7 +6,6 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesModule } from "@/modules/profiles/profiles.module";
import { RedisModule } from "@/modules/redis/redis.module";
import { TokensModule } from "@/modules/tokens/tokens.module";
import { UsersModule } from "@/modules/users/users.module";
@@ -22,7 +21,6 @@ import { PassportModule } from "@nestjs/passport";
MembershipsModule,
TokensModule,
DeploymentsModule,
ProfilesModule,
],
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
exports: [NextAuthGuard, ApiAuthGuard],
@@ -4,6 +4,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { JwtService } from "@/modules/jwt/jwt.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
@@ -20,6 +21,7 @@ import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import { createRequest } from "node-mocks-http";
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
@@ -45,6 +47,7 @@ describe("ApiAuthStrategy", () => {
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
@@ -65,6 +68,7 @@ describe("ApiAuthStrategy", () => {
}),
ProfilesModule,
TokensModule,
MembershipsModule,
],
providers: [
MockedRedisService,
@@ -91,6 +95,7 @@ describe("ApiAuthStrategy", () => {
teamRepositoryFixture = new TeamRepositoryFixture(module);
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
membershipRepositoryFixture = new MembershipRepositoryFixture(module);
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
organizationTwo = await teamRepositoryFixture.create({
name: `api-auth-organization-2-${randomString()}`,
@@ -121,6 +126,20 @@ describe("ApiAuthStrategy", () => {
organization: { connect: { id: organizationTwo.id } },
});
await membershipRepositoryFixture.create({
user: { connect: { id: validOAuthUser.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
await membershipRepositoryFixture.create({
user: { connect: { id: validOAuthUser.id } },
team: { connect: { id: organizationTwo.id } },
role: "OWNER",
accepted: true,
});
const data = {
logo: "logo-url",
name: "name",
@@ -4,9 +4,9 @@ import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
import { BaseStrategy } from "@/lib/passport/strategies/types";
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
import { DeploymentsService } from "@/modules/deployments/deployments.service";
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
import { TokensRepository } from "@/modules/tokens/tokens.repository";
import { TokensService } from "@/modules/tokens/tokens.service";
import { UsersService } from "@/modules/users/services/users.service";
@@ -45,8 +45,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
private readonly userRepository: UsersRepository,
private readonly apiKeyRepository: ApiKeysRepository,
private readonly oauthRepository: OAuthClientRepository,
private readonly profilesRepository: ProfilesRepository,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly membershipsRepository: MembershipsRepository
) {
super();
}
@@ -172,7 +172,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
}
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
const platformCreatorId =
(await this.membershipsRepository.findPlatformOwnerUserId(client.organizationId)) ||
(await this.membershipsRepository.findPlatformAdminUserId(client.organizationId));
if (!platformCreatorId) {
throw new UnauthorizedException(
@@ -20,6 +20,36 @@ export class MembershipsRepository {
return membership;
}
async findPlatformOwnerUserId(organizationId: number): Promise<number | undefined> {
const owner = await this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
role: "OWNER",
accepted: true,
},
select: {
userId: true,
},
});
return owner?.userId ?? undefined;
}
async findPlatformAdminUserId(organizationId: number): Promise<number | undefined> {
const admin = await this.dbRead.prisma.membership.findFirst({
where: {
teamId: organizationId,
role: "ADMIN",
accepted: true,
},
select: {
userId: true,
},
});
return admin?.userId ?? undefined;
}
async findMembershipByTeamId(teamId: number, userId: number) {
const membership = await this.dbRead.prisma.membership.findUnique({
where: {
@@ -15,6 +15,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
import { Test, TestingModule } from "@nestjs/testing";
import { PlatformOAuthClient, Team, User } from "@prisma/client";
import * as request from "supertest";
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
@@ -63,6 +64,7 @@ describe("OAuthFlow Endpoints", () => {
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
let profilesRepositoryFixture: ProfileRepositoryFixture;
let membershipRepositoryFixture: MembershipRepositoryFixture;
let user: User;
let organization: Team;
@@ -89,6 +91,7 @@ describe("OAuthFlow Endpoints", () => {
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
user = await usersRepositoryFixtures.create({
email: userEmail,
@@ -104,6 +107,13 @@ describe("OAuthFlow Endpoints", () => {
movedFromUser: { connect: { id: user.id } },
organization: { connect: { id: organization.id } },
});
await membershipRepositoryFixture.create({
user: { connect: { id: user.id } },
team: { connect: { id: organization.id } },
role: "OWNER",
accepted: true,
});
oAuthClient = await createOAuthClient(organization.id);
});
@@ -39,6 +39,7 @@ import {
HttpStatus,
NotFoundException,
Query,
Logger,
} from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
@@ -66,6 +67,8 @@ export type EventTypeHandlerResponse = {
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsEventTypesController {
private readonly logger = new Logger("OrganizationsEventTypesController");
constructor(
private readonly organizationsEventTypesService: OrganizationsEventTypesService,
private readonly inputService: InputOrganizationsEventTypesService,
@@ -88,6 +91,10 @@ export class OrganizationsEventTypesController {
teamId,
bodyEventType
);
this.logger.debug(
"nl debug - create org team event type - transformedBody",
JSON.stringify(transformedBody, null, 2)
);
const eventType = await this.organizationsEventTypesService.createOrganizationTeamEventType(
user,
@@ -47,6 +47,10 @@ export class OrganizationsEventTypesService {
prisma: this.dbWrite.prisma,
},
});
this.logger.debug(
"nl debug - create org team event type - eventTypeCreated",
JSON.stringify(eventTypeCreated, null, 2)
);
return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user, true);
}
@@ -133,6 +133,7 @@ export class TeamsEventTypesService {
});
const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId);
this.logger.debug("nl debug - update team event type - eventType", JSON.stringify(eventType, null, 2));
if (!eventType) {
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
@@ -1,6 +1,6 @@
import { CustomI18nProvider } from "app/CustomI18nProvider";
import { withAppDirSsr } from "app/WithAppDirSsr";
import type { PageProps, SearchParams } from "app/_types";
import type { PageProps, Params, SearchParams } from "app/_types";
import { generateMeetingMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
@@ -15,20 +15,34 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
import LegacyPage from "~/team/type-view";
import type { PageProps as LegacyPageProps } from "~/team/type-view";
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
import CachedTeamBooker, {
generateMetadata as generateCachedMetadata,
getOrgContext,
} from "./pageWithCachedData";
import { getTeamId } from "./queries";
async function isCachedTeamBookingEnabled(params: Params, searchParams: SearchParams): Promise<boolean> {
if (searchParams.experimentalTeamBookingPageCache !== "true") return false;
const { teamSlug, currentOrgDomain, isValidOrgDomain } = await getOrgContext(params);
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
const teamId = await getTeamId(teamSlug, orgSlug);
if (!teamId) return false;
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
const featuresRepository = new FeaturesRepository(prisma);
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
const isTeamFeatureEnabled = await featuresRepository.checkIfTeamHasFeature(
teamId,
"team-booking-page-cache"
);
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true";
return isTeamFeatureEnabled;
}
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await generateCachedMetadata({ params, searchParams });
}
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
const props = await getData(legacyCtx);
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
@@ -68,7 +82,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
const ServerPage = async ({ params, searchParams }: PageProps) => {
if (await isCachedTeamBookingEnabled(await searchParams)) {
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
return await CachedTeamBooker({ params, searchParams });
}
@@ -41,7 +41,7 @@ const _getTeamMetadataForBooking = (teamData: NonNullable<TeamData>, eventTypeId
};
};
async function _getOrgContext(params: Params) {
export async function getOrgContext(params: Params) {
const result = paramsSchema.safeParse({
slug: params?.slug,
type: params?.type,
@@ -74,7 +74,7 @@ const _getMultipleDurationValue = (
};
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
if (!teamData) return {}; // should never happen
@@ -123,7 +123,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
};
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
// Handle org redirects
@@ -13,6 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { TeamRepository } from "@calcom/lib/server/repository/team";
import { UserRepository } from "@calcom/lib/server/repository/user";
import { prisma } from "@calcom/prisma";
import type { SchedulingType } from "@calcom/prisma/enums";
@@ -158,3 +159,14 @@ export async function getCRMData(
crmRecordId,
};
}
export async function getTeamId(teamSlug: string, orgSlug: string | null): Promise<number | null> {
const teamRepo = new TeamRepository(prisma);
const team = await teamRepo.findFirstBySlugAndParentSlug({
slug: teamSlug,
parentSlug: orgSlug,
select: { id: true },
});
return team?.id ?? null;
}
@@ -1,9 +1,14 @@
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
import type { PageProps } from "app/_types";
import { _generateMetadata, getTranslate } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { validStatuses } from "~/bookings/lib/validStatuses";
import BookingsList from "~/bookings/views/bookings-listing-view";
@@ -26,10 +31,11 @@ const Page = async ({ params }: PageProps) => {
redirect("/bookings/upcoming");
}
const t = await getTranslate();
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
return (
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
<BookingsList status={parsed.data.status} />
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
</ShellMainAppDir>
);
};
+1 -1
View File
@@ -910,7 +910,7 @@ export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: Rerout
return (
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
<DialogContent enableOverflow preventCloseOnOutsideClick>
<DialogContent preventCloseOnOutsideClick>
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
<RerouteDialogContentAndFooter
booking={teamEventTypeBooking}
-16
View File
@@ -60,22 +60,6 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
if (!url.pathname.startsWith("/api")) {
//
// NOTE: When tRPC hits an error a 500 is returned, when this is received
// by the application the user is automatically redirected to /auth/login.
//
// - For this reason our matchers are sufficient for an app-wide maintenance page.
//
// Check whether the maintenance page should be shown
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
// If is in maintenance mode, point the url pathname to the maintenance page
if (isInMaintenanceMode) {
reqWithEnrichedHeaders.nextUrl.pathname = `/maintenance`;
return NextResponse.rewrite(reqWithEnrichedHeaders.nextUrl);
}
}
const routingFormRewriteResponse = routingForms.handleRewrite(url);
if (routingFormRewriteResponse) {
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
@@ -1,7 +1,7 @@
"use client";
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
import { useSearchParams } from "next/navigation";
import { useSearchParams, usePathname } from "next/navigation";
import { useMemo, useRef } from "react";
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
@@ -17,6 +17,7 @@ import {
ZMultiSelectFilterValue,
ZDateRangeFilterValue,
ZTextFilterValue,
type SystemFilterSegment,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import { useLocale } from "@calcom/lib/hooks/useLocale";
@@ -55,11 +56,45 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
type BookingsProps = {
status: (typeof validStatuses)[number];
userId?: number;
};
function useSystemSegments(userId?: number) {
const { t } = useLocale();
const systemSegments: SystemFilterSegment[] = useMemo(() => {
if (!userId) return [];
return [
{
id: "my_bookings",
name: t("my_bookings"),
type: "system",
activeFilters: [
{
f: "userId",
v: {
type: ColumnFilterType.MULTI_SELECT,
data: [userId],
},
},
],
perPage: 10,
},
];
}, [userId, t]);
return systemSegments;
}
export default function Bookings(props: BookingsProps) {
const pathname = usePathname();
const systemSegments = useSystemSegments(props.userId);
return (
<DataTableProvider useSegments={useSegments}>
<DataTableProvider
useSegments={useSegments}
systemSegments={systemSegments}
tableIdentifier={pathname || undefined}>
<BookingsContent {...props} />
</DataTableProvider>
);
+2 -1
View File
@@ -7,6 +7,7 @@ import {
ColumnFilterType,
type FilterableColumn,
} from "@calcom/features/data-table";
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
import {
AverageEventDurationChart,
BookingKPICards,
@@ -31,7 +32,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
export default function InsightsPage({ timeZone }: { timeZone: string }) {
return (
<DataTableProvider timeZone={timeZone}>
<DataTableProvider useSegments={useSegments} timeZone={timeZone}>
<InsightsOrgTeamsProvider>
<InsightsPageContent />
</InsightsOrgTeamsProvider>
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.6.3",
"version": "5.6.4",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@@ -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",
+69
View File
@@ -31,6 +31,7 @@ export async function applySelectFilter(page: Page, columnId: string, value: str
}
export async function selectOptionValue(page: Page, columnId: string, value: string) {
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).waitFor();
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click();
}
@@ -176,3 +177,71 @@ export async function listSegments(page: Page): Promise<string[]> {
export function locateSelectedSegmentName(page: Page, expectedName: string) {
return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName });
}
/**
* Check if a system segment is visible in the dropdown
*/
export async function expectSystemSegmentVisible(page: Page, segmentName: string) {
await page.getByTestId("filter-segment-select").click();
await expect(
page.locator('[data-testid="filter-segment-select-content"]').getByText("Default")
).toBeVisible();
await expect(
page
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
.filter({ hasText: segmentName })
).toBeVisible();
await page.keyboard.press("Escape");
}
/**
* Check that no segment is currently selected
*/
export async function expectSegmentCleared(page: Page) {
// Check that no segment is selected (button shows default text)
const segmentSelect = page.getByTestId("filter-segment-select");
const buttonText = await segmentSelect.textContent();
expect(buttonText?.trim()).toEqual("Segment");
}
/**
* Check that a specific segment is currently selected
*/
export async function expectSegmentSelected(page: Page, segmentName: string) {
await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible();
}
/**
* Duplicate an existing segment
*/
export async function duplicateSegment(page: Page, originalName: string, newName: string) {
await openSegmentSubmenu(page, originalName);
await page.getByTestId("filter-segment-select-submenu-content").getByText("Duplicate").click();
await page.getByTestId("duplicate-segment-name").fill(newName);
await page.getByRole("button", { name: "Duplicate" }).click();
await expect(page.getByText("Filter segment duplicated")).toBeVisible();
await page.keyboard.press("Escape");
}
/**
* Rename an existing segment
*/
export async function renameSegment(page: Page, originalName: string, newName: string) {
await openSegmentSubmenu(page, originalName);
await page.getByTestId("filter-segment-select-submenu-content").getByText("Rename").click();
await page.getByTestId("rename-segment-name").fill(newName);
await page.getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Filter segment updated")).toBeVisible();
await page.keyboard.press("Escape");
}
/**
* Check if a segment group (like "Default" or "Personal") is visible
*/
export async function expectSegmentGroupVisible(page: Page, groupName: string) {
await page.getByTestId("filter-segment-select").click();
await expect(
page.locator('[data-testid="filter-segment-select-content"]').getByText(groupName)
).toBeVisible();
await page.keyboard.press("Escape");
}
+3 -17
View File
@@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
import { randomString } from "@calcom/lib/random";
import prisma from "@calcom/prisma";
import { addFilter, openFilter, clearFilters } from "./filter-helpers";
import { clearFilters, applySelectFilter } from "./filter-helpers";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
@@ -202,25 +202,11 @@ test.describe("Insights", async () => {
await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click();
await page.keyboard.press("Escape");
// Choose User filter item from dropdown
await addFilter(page, "userId");
// Wait for the URL to include userId
await page.waitForURL((url) => url.toString().includes("userId"));
// Click User filter to see a user list
await openFilter(page, "userId");
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
// press escape button to close the filter
await page.keyboard.press("Escape");
await applySelectFilter(page, "userId", member.username || "");
await clearFilters(page);
await expect(page.url()).not.toContain("userId");
await expect(page).not.toHaveURL(/[?&]userId=/);
});
test("should test download button", async ({ page, users }) => {
+120
View File
@@ -0,0 +1,120 @@
import { expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { addFilter, selectSegment, expectSegmentCleared, expectSegmentSelected } from "./filter-helpers";
import { test } from "./lib/fixtures";
test.describe.configure({ mode: "parallel" });
test.afterEach(async ({ users }) => {
await users.deleteAll();
});
/**
* Navigate to bookings page with specific status
*/
async function navigateToBookings(page: Page, status = "upcoming") {
// Wait for the bookings API response like existing tests do
const bookingsGetResponse = page.waitForResponse((response) =>
/\/api\/trpc\/bookings\/get.*/.test(response.url())
);
await page.goto(`/bookings/${status}`, { waitUntil: "domcontentloaded" });
await bookingsGetResponse;
}
test.describe("System Segments", () => {
test.describe("Core Functionality", () => {
test("My Bookings system segment filters to current user's bookings only", async ({ page, users }) => {
const user1 = await users.create({ username: "user1" });
// Create some bookings for user (this would need booking setup)
// For now, we'll test the filter application
await user1.apiLogin();
await navigateToBookings(page);
await selectSegment(page, "My Bookings");
await expectSegmentSelected(page, "My Bookings");
// Verify the userId filter is applied (check URL or filter indicators)
const url = page.url();
expect(url).toContain("activeFilters");
});
test("System segments show only Duplicate option in submenu", async ({ page, users }) => {
const user = await users.create();
await user.apiLogin();
await navigateToBookings(page);
await page.getByTestId("filter-segment-select").click();
await page
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
.filter({ hasText: "My Bookings" })
.locator('[data-testid="filter-segment-select-submenu-button"]')
.click();
const submenu = page.getByTestId("filter-segment-select-submenu-content");
await expect(submenu.getByText("Duplicate")).toBeVisible();
await expect(submenu.getByText("Rename")).toBeHidden();
await expect(submenu.getByText("Delete")).toBeHidden();
});
});
test.describe("State Persistence", () => {
test("System segment selection persists across page visits", async ({ page, users }) => {
const owner = await users.create(undefined, { hasTeam: true });
await owner.apiLogin();
// Visit bookings page and select system segment
await navigateToBookings(page);
await selectSegment(page, "My Bookings");
await expectSegmentSelected(page, "My Bookings");
// Revisit the page - should preserve selection
await navigateToBookings(page);
await expectSegmentSelected(page, "My Bookings");
});
test("Cleared system segment state persists (no default selection after manual clear)", async ({
page,
users,
}) => {
const owner = await users.create(undefined, { hasTeam: true });
await owner.apiLogin();
await navigateToBookings(page);
// Select system segment
await selectSegment(page, "My Bookings");
await expectSegmentSelected(page, "My Bookings");
// Unselect system segment
await selectSegment(page, "My Bookings");
// Revisit the page - should NOT have any segment selected
await navigateToBookings(page);
await expectSegmentCleared(page);
});
});
test.describe("Auto-clear Behavior", () => {
test("Manual filter clears system segment selection", async ({ page, users }) => {
const user = await users.create(undefined, { hasTeam: true });
await user.apiLogin();
await navigateToBookings(page);
// Select system segment
await selectSegment(page, "My Bookings");
await expectSegmentSelected(page, "My Bookings");
// Apply manual filter - this should clear system segment
await addFilter(page, "eventTypeId");
await page.keyboard.press("Escape");
await expectSegmentCleared(page);
// Verify filter is still applied
await expect(page.getByTestId("filter-popover-trigger-eventTypeId")).toBeVisible();
});
});
});
@@ -3472,5 +3472,7 @@
"webhook_metadata": "Metadata",
"stats": "Stats",
"booking_status": "Booking status",
"my_bookings": "My Bookings",
"phone": "Phone",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}
+1
View File
@@ -129,6 +129,7 @@ Below is a list of props that can be passed to the booker atom.
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
| hideEventMetadata | No | Boolean that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information. Defaults to `false`. |
## Styling
-1
View File
@@ -118,7 +118,6 @@
"@daily-co/daily-js": "^0.76.0",
"@evyweb/ioctopus": "^1.2.0",
"@next/third-parties": "^14.2.5",
"@unkey/ratelimit": "^2.1.2",
"@vercel/functions": "^1.4.0",
"city-timezones": "^1.2.1",
"date-fns-tz": "^3.2.0",
@@ -10,6 +10,7 @@ import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cach
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
import { uniqueBy } from "@calcom/lib/array";
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
@@ -85,10 +86,16 @@ export default class GoogleCalendarService implements Calendar {
const selectedHostDestinationCalendar = event.destinationCalendar?.find(
(cal) => cal.credentialId === this.credential.id
);
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
.filter((domain) => domain.trim() !== "")
.some((domain) => event.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
const eventAttendees = event.attendees.map(({ id: _id, ...rest }) => ({
...rest,
responseStatus: "accepted",
}));
const attendees: calendar_v3.Schema$EventAttendee[] = [
{
...event.organizer,
@@ -100,7 +107,7 @@ export default class GoogleCalendarService implements Calendar {
// We use || instead of ?? here to handle empty strings
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
},
...eventAttendees,
...(event.hideOrganizerEmail && !isOrganizerExempt ? [] : eventAttendees),
];
if (event.team?.members) {
+5 -2
View File
@@ -1,3 +1,6 @@
/**
* TODO: Consolidate this file with BookingLocationService and add tests
*/
import type { TFunction } from "i18next";
import { z } from "zod";
@@ -58,7 +61,7 @@ export type EventLocationTypeFromApp = Ensure<
export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp;
export const DailyLocationType = "integrations:daily";
export const CalVideoLocationType = DailyLocationType;
export const MeetLocationType = "integrations:google:meet";
export const MSTeamsLocationType = "integrations:office365_video";
@@ -380,7 +383,7 @@ export const getLocationValueForDB = (
eventLocations: LocationObject[]
) => {
let bookingLocation = bookingLocationTypeOrValue;
let conferenceCredentialId = undefined;
let conferenceCredentialId: number | undefined = undefined;
eventLocations.forEach((location) => {
if (location.type === bookingLocationTypeOrValue) {
+2 -2
View File
@@ -1,7 +1,7 @@
import type { NextApiRequest } from "next";
import { stringify } from "querystring";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
const params = {
response_type: "code",
client_id,
redirect_uri: `${WEBAPP_URL}/api/integrations/${config.slug}/callback`,
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`,
scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration"
state: "",
};
+2 -2
View File
@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import prisma from "@calcom/prisma";
@@ -17,7 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/${config.slug}/callback`);
const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`);
const params = new URLSearchParams([
["grant_type", "authorization_code"],
["client_id", client_id],
+19 -17
View File
@@ -404,23 +404,25 @@ const BookerComponent = ({
src={orgBannerUrl}
/>
)}
<EventMeta
selectedTimeslot={selectedTimeslot}
classNames={{
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
eventMetaTimezoneSelect:
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
}}
event={event.data}
isPending={event.isPending}
isPlatform={isPlatform}
isPrivateLink={!!hashedLink}
locale={userLocale}
timeZones={timeZones}
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
{eventMetaChildren}
</EventMeta>
{!hideEventTypeDetails && (
<EventMeta
selectedTimeslot={selectedTimeslot}
classNames={{
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
eventMetaTimezoneSelect:
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
}}
event={event.data}
isPending={event.isPending}
isPlatform={isPlatform}
isPrivateLink={!!hashedLink}
locale={userLocale}
timeZones={timeZones}
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
{eventMetaChildren}
</EventMeta>
)}
{layout !== BookerLayouts.MONTH_VIEW &&
!(layout === "mobile" && bookerState === "booking") && (
<div className="mt-auto px-5 py-3">
@@ -13,6 +13,7 @@ export const getCalEventResponses = ({
bookingFields,
booking,
responses,
seatsEnabled,
}: {
// If the eventType has been deleted and a booking is Accepted later on, then bookingFields will be null and we can't know the label of fields. So, we should store the label as well in the DB
// Also, it is no longer straightforward to identify if a field is system field or not
@@ -32,6 +33,7 @@ export const getCalEventResponses = ({
};
}>;
responses?: z.infer<typeof bookingResponsesDbSchema>;
seatsEnabled?: boolean;
}) => {
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
@@ -61,8 +63,7 @@ export const getCalEventResponses = ({
throw new Error(`Missing label for booking field "${field.name}"`);
}
// If the guests field is hidden (disableGuests is set on the event type) don't try and infer guests from attendees list
if (field.name == "guests" && field.hidden) {
if (field.name == "guests" && !!seatsEnabled) {
backwardCompatibleResponses[field.name] = [];
}
@@ -64,6 +64,7 @@ const _getBookingData = async <T extends z.ZodType>({
getCalEventResponses({
bookingFields: eventType.bookingFields,
responses,
seatsEnabled: !!eventType.seatsPerTimeSlot,
});
return {
...parsedBody,
@@ -3,9 +3,13 @@
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import debounce from "lodash/debounce";
// eslint-disable-next-line no-restricted-imports
import isEqual from "lodash/isEqual";
import { usePathname } from "next/navigation";
import { useQueryState } from "nuqs";
import { createContext, useCallback, useEffect, useRef, useMemo } from "react";
import { useState, createContext, useCallback, useEffect, useRef, useMemo } from "react";
import { useElementByClassName } from "@calcom/lib/hooks/useElementByClassName";
import { useSegmentsNoop } from "./hooks/useSegmentsNoop";
import {
@@ -22,8 +26,12 @@ import {
import {
type FilterValue,
type FilterSegmentOutput,
type SystemFilterSegment,
type CombinedFilterSegment,
type SegmentIdentifier,
type ActiveFilters,
type UseSegments,
SYSTEM_SEGMENT_PREFIX,
} from "./lib/types";
import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils";
@@ -55,10 +63,10 @@ export type DataTableContextType = {
offset: number;
limit: number;
segments: FilterSegmentOutput[];
selectedSegment: FilterSegmentOutput | undefined;
segmentId: number | undefined;
setSegmentId: (id: number | null) => void;
segments: CombinedFilterSegment[];
selectedSegment: CombinedFilterSegment | undefined;
segmentId: SegmentIdentifier | null;
setSegmentId: (id: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => void;
canSaveSegment: boolean;
isSegmentEnabled: boolean;
@@ -78,7 +86,8 @@ interface DataTableProviderProps {
defaultPageSize?: number;
segments?: FilterSegmentOutput[];
timeZone?: string;
preferredSegmentId?: number | null;
preferredSegmentId?: SegmentIdentifier | null;
systemSegments?: SystemFilterSegment[];
}
export function DataTableProvider({
@@ -90,8 +99,18 @@ export function DataTableProvider({
segments: providedSegments,
timeZone,
preferredSegmentId,
systemSegments,
}: DataTableProviderProps) {
const pathname = usePathname() as string | null;
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
if (!tableIdentifier) {
throw new Error("tableIdentifier is required");
}
const filterToOpen = useRef<string | undefined>(undefined);
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
const [sorting, setSorting] = useQueryState("sorting", sortingParser);
const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>(
@@ -99,34 +118,172 @@ export function DataTableProvider({
columnVisibilityParser
);
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
const [segmentId, setSegmentId] = useQueryState(
"segment",
segmentIdParser.withDefault(preferredSegmentId ?? -1)
const initialSegmentId = useMemo(
() => (preferredSegmentId ? String(preferredSegmentId.id) : null),
[preferredSegmentId]
);
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
const [segmentId, _setSegmentId] = useQueryState(
"segment",
initialSegmentId ? segmentIdParser.withDefault(initialSegmentId) : segmentIdParser
);
const {
segments,
preferredSegmentId: fetchedPreferredSegmentId,
setPreference: setSegmentPreference,
isSegmentEnabled,
isSuccess: isSegmentFetchedSuccessfully,
} = useSegments({
tableIdentifier,
providedSegments,
systemSegments,
});
const findSelectedSegment = useCallback(
(segmentId: string) => {
return segments.find((segment) => {
if (
segment.type === "system" &&
segmentId &&
segmentId.startsWith(SYSTEM_SEGMENT_PREFIX) &&
segment.id === segmentId
) {
return true;
} else if (segment.type === "user") {
const segmentIdNumber = parseInt(segmentId, 10);
return segment.id === segmentIdNumber;
}
});
},
[segments]
);
const segmentIdObject = useMemo(() => {
if (segmentId && segmentId.startsWith(SYSTEM_SEGMENT_PREFIX)) {
return {
id: segmentId,
type: "system" as const,
};
} else {
const segmentIdNumber = parseInt(segmentId, 10);
if (!Number.isNaN(segmentIdNumber)) {
return {
id: segmentIdNumber,
type: "user" as const,
};
}
}
return null;
}, [segmentId]);
const [selectedSegment, setSelectedSegment] = useState<CombinedFilterSegment | undefined>(
findSelectedSegment(segmentId)
);
const setSegmentId = useCallback(
(segmentId: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => {
if (!segmentId) {
_setSegmentId(null);
setSelectedSegment(undefined);
setSegmentPreference({
tableIdentifier,
segmentId: null,
});
return;
}
const segment = providedSegment || findSelectedSegment(String(segmentId.id));
if (!segment) {
// If segmentId is invalid (or not found), clear the segmentId from the query params,
// but we still keep all the other states like activeFilters, etc.
// This is useful when someone shares a URL that is inaccessible to someone else.
_setSegmentId(null);
setSelectedSegment(undefined);
setSegmentPreference({
tableIdentifier,
segmentId: null,
});
return;
}
_setSegmentId(String(segmentId.id));
setSelectedSegment(segment);
setSegmentPreference({
tableIdentifier,
segmentId,
});
// apply the segment
setActiveFilters(segment.activeFilters);
if (segment.sorting) {
setSorting(segment.sorting);
}
if (segment.columnVisibility) {
setColumnVisibility(segment.columnVisibility);
}
if (segment.columnSizing) {
setColumnSizing(segment.columnSizing);
}
if (segment.perPage !== undefined) {
setPageSize(segment.perPage);
}
if (segment.searchTerm !== undefined) {
setSearchTerm(segment.searchTerm);
}
setPageIndex(0);
},
[
_setSegmentId,
setSelectedSegment,
setSegmentPreference,
tableIdentifier,
findSelectedSegment,
setActiveFilters,
setSorting,
setColumnVisibility,
setColumnSizing,
setPageSize,
setSearchTerm,
setPageIndex,
]
);
useEffect(() => {
if (!isSegmentFetchedSuccessfully) {
return;
}
// If the preferred segment id has been fetched
// and no segment id has been selected yet,
// then we set it.
if (fetchedPreferredSegmentId && !segmentId) {
setSegmentId(fetchedPreferredSegmentId);
} else if (segmentId) {
setSelectedSegment(findSelectedSegment(segmentId));
}
// We intentionally have only `isSegmentFetchedSuccessfully`
// in the dependency array.
}, [isSegmentFetchedSuccessfully]);
const clearSystemSegmentSelectionIfExists = useCallback(() => {
if (selectedSegment?.type === "system") {
setSegmentId(null);
}
}, [selectedSegment, setSegmentId]);
const setDebouncedSearchTerm = useMemo(
() => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500),
[setSearchTerm]
);
const pathname = usePathname() as string | null;
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
if (!tableIdentifier) {
throw new Error("tableIdentifier is required");
}
const addFilter = useCallback(
(columnId: string) => {
if (!activeFilters?.some((filter) => filter.f === columnId)) {
// do not reset the page to 0 here,
// because we don't have the filter value yet (`v: undefined`)
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
clearSystemSegmentSelectionIfExists();
}
},
[activeFilters, setActiveFilters]
[activeFilters, setActiveFilters, clearSystemSegmentSelectionIfExists]
);
const setPageIndexWrapper = useCallback(
@@ -151,8 +308,9 @@ export function DataTableProvider({
}
return newFilters;
});
clearSystemSegmentSelectionIfExists();
},
[setActiveFilters, setPageIndex]
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
);
const removeFilter = useCallback(
@@ -162,61 +320,72 @@ export function DataTableProvider({
const remainingFilters = prev.filter((filter) => filter.f !== columnId);
return remainingFilters.length === 0 ? null : remainingFilters;
});
clearSystemSegmentSelectionIfExists();
},
[setActiveFilters, setPageIndex]
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
);
const setPageSizeAndGoToFirstPage = useCallback(
(newPageSize: number | null) => {
setPageSize(newPageSize === defaultPageSize ? null : newPageSize);
setPageIndex(null);
clearSystemSegmentSelectionIfExists();
},
[setPageSize, setPageIndex, defaultPageSize]
);
const { segments, selectedSegment, canSaveSegment, setAndPersistSegmentId, isSegmentEnabled } = useSegments(
{
tableIdentifier,
activeFilters,
sorting,
columnVisibility,
columnSizing,
pageSize,
searchTerm,
defaultPageSize,
segmentId,
setSegmentId,
setActiveFilters,
setSorting,
setColumnVisibility,
setColumnSizing,
setPageSize,
setPageIndex,
setSearchTerm,
segments: providedSegments,
preferredSegmentId,
}
[setPageSize, setPageIndex, defaultPageSize, clearSystemSegmentSelectionIfExists]
);
const clearAll = useCallback(
(exclude?: string[]) => {
setAndPersistSegmentId(null);
setSegmentId(null);
setPageIndex(null);
setActiveFilters((prev) => {
const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f));
return remainingFilters.length === 0 ? null : remainingFilters;
});
},
[setActiveFilters, setPageIndex, setAndPersistSegmentId]
[setActiveFilters, setPageIndex, setSegmentId]
);
const ctaContainerRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (ctaContainerClassName) {
const element = document.getElementsByClassName(ctaContainerClassName)[0] as HTMLDivElement;
ctaContainerRef.current = element;
// Check if current state differs from selected segment
const hasStateChanged = useMemo(() => {
if (!selectedSegment) return false;
return (
!isEqual(activeFilters, selectedSegment.activeFilters) ||
!isEqual(sorting, selectedSegment.sorting) ||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
!isEqual(pageSize, selectedSegment.perPage) ||
!isEqual(searchTerm || "", selectedSegment.searchTerm || "")
);
}, [selectedSegment, activeFilters, sorting, columnVisibility, columnSizing, pageSize, searchTerm]);
const canSaveSegment = useMemo(() => {
if (selectedSegment) {
return hasStateChanged;
}
}, [ctaContainerClassName]);
// if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
return (
activeFilters.length > 0 ||
sorting.length > 0 ||
Object.keys(columnVisibility).length > 0 ||
Object.keys(columnSizing).length > 0 ||
pageSize !== defaultPageSize ||
searchTerm?.length > 0
);
}, [
selectedSegment,
activeFilters,
sorting,
columnVisibility,
columnSizing,
pageSize,
searchTerm,
defaultPageSize,
hasStateChanged,
]);
const ctaContainerRef = useElementByClassName<HTMLDivElement>(ctaContainerClassName);
return (
<DataTableContext.Provider
@@ -243,8 +412,8 @@ export function DataTableProvider({
offset: pageIndex * pageSize,
segments,
selectedSegment,
segmentId: segmentId || undefined,
setSegmentId: setAndPersistSegmentId,
segmentId: segmentIdObject,
setSegmentId,
canSaveSegment,
isSegmentEnabled,
searchTerm,
@@ -21,7 +21,7 @@ export function DeleteSegmentDialog({
onSuccess: ({ id }) => {
utils.viewer.filterSegments.list.invalidate();
showToast(t("filter_segment_deleted"), "success");
if (segmentId === id) {
if (segmentId && segmentId.type === "user" && segmentId.id === id) {
setSegmentId(null);
}
onClose();
@@ -10,7 +10,7 @@ import { Form, TextField } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { useDataTable } from "../../hooks";
import type { FilterSegmentOutput } from "../../lib/types";
import type { CombinedFilterSegment } from "../../lib/types";
type FormValues = {
name: string;
@@ -20,7 +20,7 @@ export function DuplicateSegmentDialog({
segment,
onClose,
}: {
segment: FilterSegmentOutput;
segment: CombinedFilterSegment;
onClose: () => void;
}) {
const { t } = useLocale();
@@ -38,7 +38,7 @@ export function DuplicateSegmentDialog({
onSuccess: ({ id }) => {
utils.viewer.filterSegments.list.invalidate();
showToast(t("filter_segment_duplicated"), "success");
setSegmentId(id);
setSegmentId({ id, type: "user" });
onClose();
},
onError: () => {
@@ -50,16 +50,24 @@ export function DuplicateSegmentDialog({
if (!segment) {
return;
}
const { id: _id, name: _name, team: _team, teamId, ...rest } = segment;
if (segment.scope === "TEAM" && isAdminOrOwner) {
createSegment({
...rest,
teamId: teamId ?? 0,
scope: "TEAM",
name: data.name,
});
} else {
if (segment.type === "user") {
const { type: _type, id: _id, name: _name, team: _team, teamId, ...rest } = segment;
if (segment.scope === "TEAM" && isAdminOrOwner) {
createSegment({
...rest,
teamId: teamId ?? 0,
scope: "TEAM",
name: data.name,
});
} else {
createSegment({
...rest,
scope: "USER",
name: data.name,
});
}
} else if (segment.type === "system") {
const { type: _type, ...rest } = segment;
createSegment({
...rest,
scope: "USER",
@@ -80,7 +88,13 @@ export function DuplicateSegmentDialog({
<DialogHeader title={t("duplicate_segment")} />
<Form form={form} handleSubmit={handleSubmit}>
<div className="space-y-4">
<TextField required type="text" label={t("name")} {...form.register("name")} />
<TextField
required
data-testid="duplicate-segment-name"
type="text"
label={t("name")}
{...form.register("name")}
/>
<DialogFooter>
<Button color="minimal" onClick={onClose}>
{t("cancel")}
@@ -16,7 +16,12 @@ import {
import { Icon, type IconName } from "@calcom/ui/components/icon";
import { useDataTable } from "../../hooks";
import type { FilterSegmentOutput } from "../../lib/types";
import type {
FilterSegmentOutput,
CombinedFilterSegment,
SystemFilterSegmentInternal,
UserFilterSegment,
} from "../../lib/types";
import { DeleteSegmentDialog } from "./DeleteSegmentDialog";
import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog";
import { RenameSegmentDialog } from "./RenameSegmentDialog";
@@ -24,23 +29,33 @@ import { RenameSegmentDialog } from "./RenameSegmentDialog";
type SubmenuItem = {
iconName: IconName;
labelKey: string;
onClick: (segment: FilterSegmentOutput) => void;
onClick: (segment: CombinedFilterSegment) => void;
isDestructive?: boolean;
adminOnly: boolean;
enabledForSystemSegment?: boolean;
};
export function FilterSegmentSelect() {
const { t } = useLocale();
const session = useSession();
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
const { segments, selectedSegment, segmentId, setSegmentId, isSegmentEnabled } = useDataTable();
const [segmentToRename, setSegmentToRename] = useState<FilterSegmentOutput | undefined>();
const [segmentToDuplicate, setSegmentToDuplicate] = useState<FilterSegmentOutput | undefined>();
const [segmentToDuplicate, setSegmentToDuplicate] = useState<CombinedFilterSegment | undefined>();
const [segmentToDelete, setSegmentToDelete] = useState<FilterSegmentOutput | undefined>();
const submenuItems: SubmenuItem[] = [
{
iconName: "square-pen",
labelKey: "rename",
onClick: (segment) => setSegmentToRename(segment),
onClick: (segment) => {
if (segment.type === "system") {
setSegmentToRename(undefined);
} else {
const { type: _type, ...rest } = segment;
setSegmentToRename(rest);
}
},
adminOnly: true,
},
{
@@ -48,55 +63,79 @@ export function FilterSegmentSelect() {
labelKey: "duplicate",
onClick: (segment) => setSegmentToDuplicate(segment),
adminOnly: false,
enabledForSystemSegment: true,
},
{
iconName: "trash-2",
labelKey: "delete",
onClick: (segment) => setSegmentToDelete(segment),
onClick: (segment) => {
if (segment.type === "system") {
setSegmentToDelete(undefined);
} else {
const { type: _type, ...rest } = segment;
setSegmentToDelete(rest);
}
},
isDestructive: true,
adminOnly: true,
},
];
const segmentGroups = useMemo(() => {
const sortFn = (a: FilterSegmentOutput, b: FilterSegmentOutput) => a.name.localeCompare(b.name);
const sortFn = (a: CombinedFilterSegment, b: CombinedFilterSegment) => a.name.localeCompare(b.name);
const personalSegments = segments?.filter((segment) => !segment.team) || [];
const systemSegments =
segments?.filter((s): s is SystemFilterSegmentInternal => s.type === "system") || [];
const personalSegments =
segments?.filter((s): s is UserFilterSegment => s.type === "user" && !s.team) || [];
const teamSegments =
segments?.filter(
(segment): segment is FilterSegmentOutput & { team: NonNullable<FilterSegmentOutput["team"]> } =>
segment.team !== null
(s): s is UserFilterSegment & { team: NonNullable<FilterSegmentOutput["team"]> } =>
s.type === "user" && s.team !== null
) || [];
// Group team segments by team name
const teamSegmentsByTeam = teamSegments.reduce<{ [teamName: string]: FilterSegmentOutput[] }>(
(acc, segment) => {
const teamName = segment.team.name;
if (!acc[teamName]) {
acc[teamName] = [];
}
acc[teamName].push(segment);
return acc;
},
{}
);
const groups = [];
return [
...(personalSegments.length > 0
? [
{
label: t("personal"),
segments: personalSegments.sort(sortFn),
},
]
: []),
if (systemSegments.length > 0) {
groups.push({
label: t("default"),
segments: systemSegments.sort(sortFn),
isSystem: true,
});
}
// Personal segments
if (personalSegments.length > 0) {
groups.push({
label: t("personal"),
segments: personalSegments.sort(sortFn),
isSystem: false,
});
}
// Team segments (existing grouping logic)
const teamSegmentsByTeam = teamSegments.reduce<{
[teamName: string]: UserFilterSegment[];
}>((acc, segment) => {
const teamName = segment.team!.name;
if (!acc[teamName]) {
acc[teamName] = [];
}
acc[teamName].push(segment);
return acc;
}, {});
groups.push(
...Object.entries(teamSegmentsByTeam)
.map(([teamName, segments]) => ({
label: teamName,
segments: segments.sort(sortFn),
isSystem: false,
}))
.sort((a, b) => a.label.localeCompare(b.label)),
];
.sort((a, b) => a.label.localeCompare(b.label))
);
return groups;
}, [segments, t]);
if (!isSegmentEnabled) {
@@ -128,22 +167,43 @@ export function FilterSegmentSelect() {
{group.label && (
<DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel>
)}
{group.segments.map((segment) => (
<DropdownItemWithSubmenu
key={segment.id}
submenuItems={submenuItems}
segment={segment}
onSelect={() => {
if (segmentId === segment.id) {
setSegmentId(null);
} else {
setSegmentId(segment.id);
}
}}>
{segment.id === segmentId && <Icon name="check" className="ml-3 h-4 w-4" />}
<span className="ml-3">{segment.name}</span>
</DropdownItemWithSubmenu>
))}
{group.segments.map((segment) => {
let items: SubmenuItem[] = [];
if (segment.type === "system") {
items = submenuItems.filter((item) => item.enabledForSystemSegment);
} else if (segment.type === "user" && segment.scope === "USER") {
items = submenuItems;
} else if (segment.type === "user" && segment.scope === "TEAM") {
items = submenuItems.filter(
(item) =>
// Team segments: show if not admin-only or if user is admin/owner
isAdminOrOwner || !item.adminOnly
);
}
return (
<DropdownItemWithSubmenu
key={segment.id}
submenuItems={items}
segment={segment}
onSelect={() => {
if (segmentId && segmentId.type === segment.type && segmentId.id === segment.id) {
setSegmentId(null);
} else {
if (segment.type === "system") {
setSegmentId({ id: segment.id, type: "system" });
} else {
setSegmentId({ id: segment.id, type: "user" });
}
}
}}>
{segmentId && segmentId.type === segment.type && segmentId.id === segment.id && (
<Icon name="check" className="ml-3 h-4 w-4" />
)}
<span className="ml-3">{segment.name}</span>
</DropdownItemWithSubmenu>
);
})}
</div>
))}
</DropdownMenuContent>
@@ -175,25 +235,12 @@ function DropdownItemWithSubmenu({
children,
}: {
onSelect: () => void;
segment: FilterSegmentOutput;
segment: CombinedFilterSegment;
submenuItems: SubmenuItem[];
children: React.ReactNode;
}) {
const { t } = useLocale();
const [isOpen, setIsOpen] = useState(false);
const session = useSession();
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
// Filter submenu items based on segment type and user role
const filteredSubmenuItems = submenuItems.filter((item) => {
if (!segment.team) {
// Personal segments: show all actions
return true;
}
// Team segments: show if not admin-only or if user is admin/owner
return !item.adminOnly || isAdminOrOwner;
});
return (
<DropdownMenuItem className="cursor-pointer" onSelect={onSelect}>
@@ -220,7 +267,7 @@ function DropdownItemWithSubmenu({
side="right"
sideOffset={8}
data-testid="filter-segment-select-submenu-content">
{filteredSubmenuItems.map((item, index) => (
{submenuItems.map((item, index) => (
<DropdownMenuItem key={index}>
<DropdownItem
color={item.isDestructive ? "destructive" : undefined}
@@ -71,7 +71,13 @@ export function RenameSegmentDialog({
<DialogHeader title={t("rename_segment")} />
<Form form={form} handleSubmit={handleSubmit}>
<div className="space-y-4">
<TextField required type="text" label={t("name")} {...form.register("name")} />
<TextField
required
data-testid="rename-segment-name"
type="text"
label={t("name")}
{...form.register("name")}
/>
<DialogFooter>
<Button type="submit" loading={isPending}>
{t("save")}
@@ -57,7 +57,7 @@ export function SaveFilterSegmentButton() {
} = useDataTable();
const [saveMode, setSaveMode] = useState<"create" | "update">(() =>
selectedSegment ? "update" : "create"
selectedSegment && selectedSegment.type === "user" ? "update" : "create"
);
// When the dialog is not open,
@@ -66,16 +66,16 @@ export function SaveFilterSegmentButton() {
if (isOpen) {
return;
}
setSaveMode(selectedSegment ? "update" : "create");
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
}, [selectedSegment, isOpen]);
const { data: teams } = trpc.viewer.teams.list.useQuery();
const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({
onSuccess: ({ id }) => {
onSuccess: (segment) => {
utils.viewer.filterSegments.list.invalidate();
showToast(t("filter_segment_saved"), "success");
setSegmentId(id);
setSegmentId({ id: segment.id, type: "user" }, { type: "user", ...segment });
setIsOpen(false);
},
onError: () => {
@@ -110,12 +110,7 @@ export function SaveFilterSegmentButton() {
searchTerm,
};
if (saveMode === "update") {
if (!selectedSegment) {
// theoretically this should never happen
showToast(t("segment_not_found"), "error");
return;
}
if (saveMode === "update" && selectedSegment && selectedSegment.type === "user") {
const scope = selectedSegment.scope;
if (scope === "TEAM") {
updateSegment({
@@ -156,7 +151,7 @@ export function SaveFilterSegmentButton() {
// Reset form state when dialog closes
setIsTeamSegment(false);
setSelectedTeamId(undefined);
setSaveMode(selectedSegment ? "update" : "create");
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
form.reset();
}
setIsOpen(open);
@@ -184,7 +179,7 @@ export function SaveFilterSegmentButton() {
<DialogContent data-testid="save-filter-segment-dialog">
<DialogHeader title={t("save_segment")} />
<Form form={form} handleSubmit={onSubmit}>
{selectedSegment ? (
{selectedSegment && selectedSegment.type === "user" ? (
<div className="mb-4">
<RadioGroup
defaultValue="update"
+32 -128
View File
@@ -1,35 +1,13 @@
// eslint-disable-next-line no-restricted-imports
import { isEqual } from "lodash";
import { useCallback, useMemo, useEffect } from "react";
import { useMemo } from "react";
import { trpc } from "@calcom/trpc/react";
import { recalculateDateRange } from "../lib/dateRange";
import { type UseSegments } from "../lib/types";
import { type UseSegments, SYSTEM_SEGMENT_PREFIX } from "../lib/types";
import { isDateRangeFilterValue } from "../lib/utils";
export const useSegments: UseSegments = ({
tableIdentifier,
activeFilters,
sorting,
columnVisibility,
columnSizing,
pageSize,
searchTerm,
defaultPageSize,
segmentId,
setSegmentId,
setActiveFilters,
setSorting,
setColumnVisibility,
setColumnSizing,
setPageSize,
setPageIndex,
setSearchTerm,
segments: providedSegments,
preferredSegmentId,
}) => {
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery(
export const useSegments: UseSegments = ({ tableIdentifier, providedSegments, systemSegments }) => {
const { data: rawSegments, isSuccess } = trpc.viewer.filterSegments.list.useQuery(
{
tableIdentifier,
},
@@ -37,16 +15,19 @@ export const useSegments: UseSegments = ({
enabled: !providedSegments, // Only fetch if segments are not provided
}
);
const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation();
// Recalculate date ranges based on the current timestamp
const segments = useMemo(() => {
const segmentsSource = providedSegments || rawSegments?.segments;
if (!segmentsSource) return [];
const preferredSegmentId = useMemo(() => rawSegments?.preferredSegmentId || null, [rawSegments]);
return segmentsSource.map((segment) => ({
const segments = useMemo(() => {
const userSegments = providedSegments || rawSegments?.segments || [];
const processedSystemSegments = (systemSegments || []).map((segment) => ({
perPage: 10,
tableIdentifier,
...segment,
id: `${SYSTEM_SEGMENT_PREFIX}${segment.id}`,
type: "system" as const,
activeFilters: segment.activeFilters.map((filter) => {
if (isDateRangeFilterValue(filter.v)) {
return {
@@ -57,106 +38,29 @@ export const useSegments: UseSegments = ({
return filter;
}),
}));
}, [rawSegments, providedSegments]);
const selectedSegment = useMemo(() => {
return segments?.find((segment) => segment.id === segmentId);
}, [segments, segmentId]);
const processedUserSegments = userSegments.map((segment) => ({
...segment,
type: "user" as const,
activeFilters: segment.activeFilters.map((filter) => {
if (isDateRangeFilterValue(filter.v)) {
return {
...filter,
v: recalculateDateRange(filter.v),
};
}
return filter;
}),
}));
useEffect(() => {
if (segments && segmentId > 0 && !isFetchingSegments) {
const segment = segments.find((segment) => segment.id === segmentId);
if (!segment) {
// If segmentId is invalid (or not found), clear the segmentId from the query params,
// but we still keep all the other states like activeFilters, etc.
// This is useful when someone shares a URL that is inaccessible to someone else.
setSegmentId(null);
}
}
}, [segments, segmentId, setSegmentId, isFetchingSegments]);
const memoizedPreferredSegmentId = useMemo(
() => preferredSegmentId ?? rawSegments?.preferredSegmentId,
[preferredSegmentId, rawSegments]
);
useEffect(() => {
if (memoizedPreferredSegmentId) {
setSegmentId(memoizedPreferredSegmentId);
}
}, [memoizedPreferredSegmentId, setSegmentId]);
useEffect(() => {
if (selectedSegment) {
// segment is selected, so we apply the filters, sorting, etc. from the segment
setActiveFilters(selectedSegment.activeFilters);
setSorting(selectedSegment.sorting);
setColumnVisibility(selectedSegment.columnVisibility);
setColumnSizing(selectedSegment.columnSizing);
setPageSize(selectedSegment.perPage);
setSearchTerm(selectedSegment.searchTerm);
setPageIndex(0);
}
}, [
selectedSegment,
setActiveFilters,
setSorting,
setColumnVisibility,
setColumnSizing,
setPageSize,
setPageIndex,
setSearchTerm,
]);
const canSaveSegment = useMemo(() => {
if (!selectedSegment) {
// if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
return (
activeFilters.length > 0 ||
sorting.length > 0 ||
Object.keys(columnVisibility).length > 0 ||
Object.keys(columnSizing).length > 0 ||
pageSize !== defaultPageSize ||
searchTerm?.length > 0
);
} else {
// if a segment is selected, we can save the segment if the active filters, sorting, etc. are different from the segment
return (
!isEqual(activeFilters, selectedSegment.activeFilters) ||
!isEqual(sorting, selectedSegment.sorting) ||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
!isEqual(pageSize, selectedSegment.perPage) ||
!isEqual(searchTerm, selectedSegment.searchTerm)
);
}
}, [
selectedSegment,
activeFilters,
sorting,
columnVisibility,
columnSizing,
pageSize,
searchTerm,
defaultPageSize,
]);
const setAndPersistSegmentId = useCallback(
(segmentId: number | null) => {
setSegmentId(segmentId);
setPreference({
tableIdentifier,
segmentId,
});
},
[tableIdentifier, setSegmentId, setPreference]
);
return [...processedSystemSegments, ...processedUserSegments];
}, [rawSegments, providedSegments, systemSegments, tableIdentifier]);
return {
segments: segments ?? [],
selectedSegment,
canSaveSegment,
setAndPersistSegmentId,
segments,
preferredSegmentId,
isSuccess: Boolean(providedSegments) || isSuccess,
setPreference,
isSegmentEnabled: true,
};
};
@@ -6,9 +6,9 @@ import { type UseSegments } from "../lib/types";
export const useSegmentsNoop: UseSegments = ({}) => {
return {
segments: [],
selectedSegment: undefined,
canSaveSegment: false,
setAndPersistSegmentId: noop,
preferredSegmentId: null,
isSuccess: false,
setPreference: noop,
isSegmentEnabled: false,
};
};
+1 -1
View File
@@ -18,7 +18,7 @@ export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withD
DEFAULT_COLUMN_VISIBILITY
);
export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING);
export const segmentIdParser = parseAsInteger.withDefault(-1);
export const segmentIdParser = parseAsString.withDefault("");
export const pageIndexParser = parseAsInteger.withDefault(0);
export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE);
export const searchTermParser = parseAsString.withDefault("");
+38 -26
View File
@@ -1,10 +1,12 @@
import type { SortingState, ColumnSort, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
import type { SortingState, ColumnSort } from "@tanstack/react-table";
import { z } from "zod";
import type { IconName } from "@calcom/ui/components/icon";
export type { SortingState } from "@tanstack/react-table";
export const SYSTEM_SEGMENT_PREFIX = "system_";
export enum ColumnFilterType {
SINGLE_SELECT = "ss",
MULTI_SELECT = "ms",
@@ -255,11 +257,31 @@ export type FilterSegmentOutput = {
team: { id: number; name: string } | null;
};
export type FilterSegmentsListResponse = {
segments: FilterSegmentOutput[];
preferredSegmentId: number | null;
export type SystemFilterSegment = {
id: string;
name: string;
activeFilters: ActiveFilters;
sorting?: SortingState;
columnVisibility?: Record<string, boolean>;
columnSizing?: Record<string, number>;
perPage?: number;
searchTerm?: string | null;
type: "system";
};
export type SystemFilterSegmentInternal = Omit<SystemFilterSegment, "perPage"> & {
tableIdentifier: string;
perPage: number;
};
export type UserFilterSegment = FilterSegmentOutput & {
type: "user";
};
export type CombinedFilterSegment = SystemFilterSegmentInternal | UserFilterSegment;
export type SegmentIdentifier = { id: string; type: "system" } | { id: number; type: "user" };
export type SegmentStorage = {
[tableIdentifier: string]: {
segmentId: number;
@@ -277,30 +299,20 @@ export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn;
export type UseSegmentsProps = {
tableIdentifier: string;
activeFilters: ActiveFilters;
sorting: SortingState;
columnVisibility: VisibilityState;
columnSizing: ColumnSizingState;
pageSize: number;
searchTerm: string;
defaultPageSize: number;
segmentId: number;
setSegmentId: (segmentId: number | null) => void;
setActiveFilters: (activeFilters: ActiveFilters) => void;
setSorting: (sorting: SortingState) => void;
setColumnVisibility: (columnVisibility: VisibilityState) => void;
setColumnSizing: (columnSizing: ColumnSizingState) => void;
setPageSize: (pageSize: number) => void;
setPageIndex: (pageIndex: number) => void;
setSearchTerm: (searchTerm: string | null) => void;
segments?: FilterSegmentOutput[];
preferredSegmentId?: number | null;
providedSegments?: FilterSegmentOutput[];
systemSegments?: SystemFilterSegment[];
};
export type UseSegmentsReturn = {
segments: FilterSegmentOutput[];
selectedSegment: FilterSegmentOutput | undefined;
canSaveSegment: boolean;
setAndPersistSegmentId: (segmentId: number | null) => void;
segments: CombinedFilterSegment[];
preferredSegmentId: SegmentIdentifier | null;
isSuccess: boolean;
setPreference: ({
tableIdentifier,
segmentId,
}: {
tableIdentifier: string;
segmentId: SegmentIdentifier | null;
}) => void;
isSegmentEnabled: boolean;
};
@@ -73,6 +73,17 @@ export const handleRescheduleEventManager = async ({
const results = updateManager.results ?? [];
const calVideoResult = results.find((result) => result.type === "daily_video");
// Check if Cal Video Creation Failed - That is the fallback for Cal.com and is expected to always work
if (calVideoResult && !calVideoResult.success) {
handleRescheduleEventManager.error("Cal Video creation failed", {
error: calVideoResult.error,
bookingLocation,
});
// This happens only when Cal Video is down
throw new Error("Failed to set video conferencing link, but the meeting has been rescheduled");
}
const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
results: results,
});
@@ -80,7 +91,6 @@ export const handleRescheduleEventManager = async ({
let videoCallUrl = _videoCallUrl;
let metadata: AdditionalInformation = {};
metadata = videoMetadata;
if (results.length) {
// Handle Google Meet results
if (bookingLocation === MeetLocationType) {
@@ -154,6 +164,7 @@ export const handleRescheduleEventManager = async ({
? calendarResult?.updatedEvent[0]?.iCalUID
: calendarResult?.updatedEvent?.iCalUID || undefined;
}
const newReferencesToCreate = structuredClone(updateManager.referencesToCreate);
await BookingReferenceRepository.replaceBookingReferences({
@@ -17,65 +17,238 @@ import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAn
import { describe, vi, expect } from "vitest";
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { BookingRepository } from "@calcom/lib/server/repository/booking";
import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
vi.mock("@calcom/lib/EventManager");
vi.mock("@calcom/app-store/utils", () => ({
getAppFromSlug: vi.fn(),
}));
const testDestinationCalendar = {
integration: "test-calendar",
externalId: "test-calendar",
// Type definitions
type TestUserMetadata = {
defaultConferencingApp?: {
appSlug: string;
appLink?: string;
};
};
const testUsers = [
{
id: 1,
name: "user-1",
timeZone: "Asia/Kolkata",
username: "host-1",
email: "host1@test.com",
schedules: [TestData.schedules.IstWorkHours],
destinationCalendar: testDestinationCalendar,
},
{
id: 2,
name: "user-2",
timeZone: "Asia/Kolkata",
username: "host-2",
email: "host2@test.com",
schedules: [TestData.schedules.IstWorkHours],
},
{
id: 3,
name: "user-3",
timeZone: "Asia/Kolkata",
username: "host-3",
email: "host3@test.com",
schedules: [TestData.schedules.IstWorkHours],
},
];
type TestUser = {
id: number;
name: string;
timeZone: string;
username: string;
email: string;
schedules: (typeof TestData.schedules.IstWorkHours)[];
destinationCalendar?: { integration: string; externalId: string };
metadata?: TestUserMetadata;
};
type MockBookingAttendee = ReturnType<typeof getMockBookingAttendee>;
// Test Data Builders
const createTestUser = (overrides?: {
id?: number;
name?: string;
username?: string;
email?: string;
destinationCalendar?: { integration: string; externalId: string };
metadata?: TestUserMetadata;
}) => ({
id: overrides?.id ?? 1,
name: overrides?.name ?? `user-${overrides?.id ?? 1}`,
timeZone: "Asia/Kolkata",
username: overrides?.username ?? `host-${overrides?.id ?? 1}`,
email: overrides?.email ?? `host${overrides?.id ?? 1}@test.com`,
schedules: [TestData.schedules.IstWorkHours],
destinationCalendar: overrides?.destinationCalendar,
metadata: overrides?.metadata,
defaultScheduleId: 1,
credentials: [],
selectedCalendars: [],
weekStart: "Monday" as const,
teams: [],
availability: [],
profiles: [],
outOfOffice: undefined,
smsLockState: undefined,
completedOnboarding: true,
locked: false,
organizationId: null,
});
const createTestDestinationCalendar = (overrides?: { integration?: string; externalId?: string }) => ({
integration: overrides?.integration ?? "test-calendar",
externalId: overrides?.externalId ?? "test-calendar",
});
const createTestBooking = async (params: {
eventTypeId: number;
userId: number;
bookingId: number;
bookingUid: string;
attendees?: MockBookingAttendee[];
location?: string;
}) => {
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
return {
id: params.bookingId,
eventTypeId: params.eventTypeId,
userId: params.userId,
uid: params.bookingUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
location: params.location ?? null,
attendees: params.attendees ?? [
getMockBookingAttendee({
id: 2,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
],
};
};
const createRoundRobinEventType = (params: {
id: number;
slug: string;
users: TestUser[];
hosts?: { userId: number; isFixed: boolean }[];
locations?: { type: string; credentialId?: number }[];
teamId?: number;
schedulingType?: SchedulingType;
parentId?: number;
}) => ({
id: params.id,
slug: params.slug,
schedulingType: params.schedulingType ?? SchedulingType.ROUND_ROBIN,
length: 45,
users: params.users.map((user) => ({ id: user.id })),
hosts: params.hosts ?? params.users.map((user) => ({ userId: user.id, isFixed: false })),
locations: params.locations,
teamId: params.teamId,
parentId: params.parentId,
});
// Assertion Helpers
const expectEventManagerCalledWith = (
spy: ReturnType<typeof vi.spyOn>,
expectedParams: {
location?: string;
uid: string;
changedOrganizer: boolean;
destinationCalendars?: Array<{ integration: string; externalId: string }>;
}
) => {
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith(
expectedParams.location
? expect.objectContaining({ location: expectedParams.location })
: expect.any(Object),
expectedParams.uid,
undefined,
expectedParams.changedOrganizer,
expectedParams.destinationCalendars ?? expect.any(Array)
);
};
// Mock Helpers
type MockEventManagerConfig = {
failConferencing?: boolean;
};
type ConferenceResult = {
type: string;
success: boolean;
uid: string;
originalEvent: Record<string, unknown>;
error?: string;
updatedEvent?: {
url: string;
};
};
const mockEventManagerReschedule = async (config?: MockEventManagerConfig) => {
const EventManager = (await import("@calcom/lib/EventManager")).default;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const spy = vi.spyOn(EventManager.prototype as any, "reschedule");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
spy.mockImplementation(async (event: any) => {
const location = event.location;
const results: ConferenceResult[] = [];
// Check if location is a static link (starts with http:// or https://)
const isStaticLink = location?.startsWith("http://") || location?.startsWith("https://");
if (isStaticLink) {
// For static links, no conferencing result is generated
return { referencesToCreate: [] };
}
// Handle conferencing based on location type
if (location?.includes("integrations:")) {
const conferenceType = location.split(":")[1];
if (config?.failConferencing) {
results.push({
type: `${conferenceType}_video`,
success: false,
error: `Failed to create ${conferenceType} room`,
uid: event.uid || "test-uid",
originalEvent: {},
});
} else {
results.push({
type: `${conferenceType}_video`,
success: true,
uid: event.uid || "test-uid",
originalEvent: {},
updatedEvent: {
url:
conferenceType === "zoom"
? "https://zoom.us/j/123456789"
: conferenceType === "google"
? "https://meet.google.com/test-meeting"
: `https://${conferenceType}.co/test-room`,
},
});
}
}
return { results, referencesToCreate: [] };
});
return spy;
};
const mockGetAppFromSlug = async () => {
const { getAppFromSlug } = await import("@calcom/app-store/utils");
return vi.mocked(getAppFromSlug);
};
describe("roundRobinManualReassignment test", () => {
setupAndTeardown();
test("manually reassign round robin organizer", async ({ emails }) => {
test("should reassign round robin booking to new host and update workflows", async ({ emails }) => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
const testDestinationCalendar = createTestDestinationCalendar();
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
const newHost = createTestUser({ id: 2 });
const users = [originalHost, newHost, createTestUser({ id: 3 })];
const users = testUsers;
const originalHost = users[0];
const newHost = users[1];
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 });
const bookingToReassignUid = "booking-to-reassign";
const bookingData = await createBookingScenario(
await createBookingScenario(
getScenarioData({
workflows: [
{
@@ -83,48 +256,34 @@ describe("roundRobinManualReassignment test", () => {
trigger: "NEW_EVENT",
action: "EMAIL_HOST",
template: "REMINDER",
activeEventTypeId: 1,
activeOn: [1],
},
],
eventTypes: [
{
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
},
users,
}),
],
bookings: [
{
id: 123,
await createTestBooking({
eventTypeId: 1,
userId: originalHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
attendees: [
getMockBookingAttendee({
id: 2,
name: "attendee",
email: "attendee@test.com",
locale: "en",
timeZone: "Asia/Kolkata",
}),
],
},
bookingId: 123,
bookingUid: bookingToReassignUid,
}),
],
organizer: originalHost,
usersApartFromOrganizer: users.slice(1),
})
);
await addWorkflowReminders([
{
bookingUid: bookingToReassignUid,
method: WorkflowMethods.EMAIL,
scheduledDate: dateStringPlusTwo,
scheduledDate: new Date(dateStringPlusTwo),
scheduled: true,
workflowStepId: 1,
workflowId: 1,
@@ -135,16 +294,14 @@ describe("roundRobinManualReassignment test", () => {
bookingId: 123,
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
});
expect(eventManagerSpy).toBeCalledTimes(1);
expect(eventManagerSpy).toHaveBeenCalledWith(
expect.any(Object),
bookingToReassignUid,
undefined,
true,
expect.arrayContaining([expect.objectContaining(testDestinationCalendar)])
);
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
uid: bookingToReassignUid,
changedOrganizer: true,
destinationCalendars: expect.arrayContaining([expect.objectContaining(testDestinationCalendar)]),
});
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
@@ -160,21 +317,73 @@ describe("roundRobinManualReassignment test", () => {
expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails });
});
test("Manually reassign round robin host with fixed host as organizer", async () => {
test("should include conferenceCredentialId when reassigning booking with with fixed host as organizer", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
const testDestinationCalendar = createTestDestinationCalendar();
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
const newHost = createTestUser({ id: 2 });
const users = [originalHost, newHost];
const users = testUsers;
const bookingToReassignUid = "booking-with-conference-credential";
const conferenceCredentialId = 999;
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
users,
locations: [
{
type: "integrations:zoom",
credentialId: conferenceCredentialId,
},
],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 1,
userId: originalHost.id,
bookingId: 123,
bookingUid: bookingToReassignUid,
location: "integrations:zoom",
}),
],
organizer: originalHost,
usersApartFromOrganizer: users.slice(1),
})
);
await roundRobinManualReassignment({
bookingId: 123,
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
});
// Verify that EventManager.reschedule was called with an event containing conferenceCredentialId
expect(eventManagerRescheduleSpy).toHaveBeenCalledTimes(1);
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((calledEvent as any).conferenceCredentialId).toBe(conferenceCredentialId);
});
test("should reassign round robin attendee while keeping fixed host as organizer", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
const testDestinationCalendar = createTestDestinationCalendar();
const fixedHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
const currentRRHost = createTestUser({ id: 2 });
const newHost = createTestUser({ id: 3 });
const users = [fixedHost, currentRRHost, newHost];
const bookingToReassignUid = "booking-to-reassign";
const fixedHost = users[0];
const currentRRHost = users[1];
const newHost = users[2];
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
workflows: [
@@ -186,27 +395,22 @@ describe("roundRobinManualReassignment test", () => {
},
],
eventTypes: [
{
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: users.map((user) => ({ id: user.id })),
users,
hosts: users.map((user) => ({
userId: user.id,
isFixed: user.id === fixedHost.id,
})),
},
}),
],
bookings: [
{
id: 123,
await createTestBooking({
eventTypeId: 1,
userId: fixedHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
bookingId: 123,
bookingUid: bookingToReassignUid,
attendees: [
getMockBookingAttendee({
id: 1,
@@ -223,7 +427,7 @@ describe("roundRobinManualReassignment test", () => {
timeZone: currentRRHost.timeZone,
}),
],
},
}),
],
organizer: fixedHost,
usersApartFromOrganizer: users.slice(1),
@@ -234,65 +438,59 @@ describe("roundRobinManualReassignment test", () => {
bookingId: 123,
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
});
expect(eventManagerSpy).toBeCalledTimes(1);
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
uid: bookingToReassignUid,
changedOrganizer: false,
});
// Ensure organizer stays the same
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
userId: fixedHost.id,
});
const bookingRepo = new BookingRepository(prismaMock);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bookingRepo = new BookingRepository(prismaMock as any);
const attendees = await bookingRepo.getBookingAttendees(123);
expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true);
});
test("sends cancellation email to previous RR host when reassigning", async ({ emails }) => {
test("should send cancellation email to previous round robin attendee when reassigning", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const EventManager = (await import("@calcom/lib/EventManager")).default;
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
await mockEventManagerReschedule();
const sendRoundRobinCancelledEmailsAndSMSSpy = vi.spyOn(
await import("@calcom/emails"),
"sendRoundRobinCancelledEmailsAndSMS"
);
const users = testUsers;
const originalHost = users[0];
const previousRRHost = users[1];
const newHost = users[2];
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
const testDestinationCalendar = createTestDestinationCalendar();
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
const previousRRHost = createTestUser({ id: 2 });
const newHost = createTestUser({ id: 3 });
const users = [originalHost, previousRRHost, newHost];
const bookingToReassignUid = "booking-to-reassign-with-previous-rr-host";
await createBookingScenario(
getScenarioData({
eventTypes: [
{
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
schedulingType: SchedulingType.ROUND_ROBIN,
length: 45,
users: users.map((user) => ({ id: user.id })),
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
},
users,
}),
],
bookings: [
{
id: 124,
await createTestBooking({
eventTypeId: 1,
userId: originalHost.id,
uid: bookingToReassignUid,
status: BookingStatus.ACCEPTED,
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
bookingId: 124,
bookingUid: bookingToReassignUid,
attendees: [
getMockBookingAttendee({
id: 2,
@@ -309,7 +507,7 @@ describe("roundRobinManualReassignment test", () => {
timeZone: previousRRHost.timeZone,
}),
],
},
}),
],
organizer: originalHost,
usersApartFromOrganizer: users.slice(1),
@@ -320,8 +518,358 @@ describe("roundRobinManualReassignment test", () => {
bookingId: 124,
newUserId: newHost.id,
orgId: null,
reassignedById: 1,
});
expect(sendRoundRobinCancelledEmailsAndSMSSpy).toHaveBeenCalledTimes(1);
});
});
describe("roundRobinManualReassignment - Location Changes", () => {
setupAndTeardown();
const createConferencingUsers = () => {
return {
googleMeetHost: createTestUser({
id: 1,
destinationCalendar: {
integration: "google_calendar",
externalId: "test-calendar",
},
metadata: {
defaultConferencingApp: {
appSlug: "google-meet",
},
},
}),
zoomHost: createTestUser({
id: 2,
metadata: {
defaultConferencingApp: {
appSlug: "zoom",
},
},
}),
noDefaultConferencingAppHost: createTestUser({
id: 3,
metadata: {},
}),
staticVideoLinkHost: createTestUser({
id: 4,
metadata: {
defaultConferencingApp: {
appSlug: "custom-video",
appLink: "https://custom-video.com/room123",
},
},
}),
};
};
const mockAppStore = async (config?: { googleMeet?: boolean; zoom?: boolean; customVideo?: boolean }) => {
const getAppFromSlug = await mockGetAppFromSlug();
getAppFromSlug.mockImplementation((slug?: string) => {
if (slug === "google-meet" && config?.googleMeet !== false) {
return {
appData: {
location: {
type: "integrations:google:meet",
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
if (slug === "zoom" && config?.zoom !== false) {
return {
appData: {
location: {
type: "integrations:zoom",
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
if (slug === "custom-video" && config?.customVideo !== false) {
return {
appData: {
location: {
type: "integrations:custom-video",
},
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
}
return undefined;
});
return getAppFromSlug;
};
describe("when event has Organizer's Default Conferencing App location", () => {
test("should update location from Google Meet to Zoom when reassigning between hosts with different default conferencing apps", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
await mockAppStore({ googleMeet: true, zoom: true });
const users = createConferencingUsers();
const googleMeetHost = users.googleMeetHost;
const zoomHost = users.zoomHost;
const bookingToReassignUid = "booking-location-change-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 1,
slug: "round-robin-event",
teamId: 1,
users: Object.values(users),
locations: [{ type: OrganizerDefaultConferencingAppType }, { type: "integrations:daily" }],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 1,
userId: googleMeetHost.id,
bookingId: 125,
bookingUid: bookingToReassignUid,
location: "integrations:google:meet",
}),
],
organizer: googleMeetHost,
usersApartFromOrganizer: Object.values(users).slice(1),
})
);
await roundRobinManualReassignment({
bookingId: 125,
newUserId: zoomHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
});
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
location: "integrations:zoom",
uid: bookingToReassignUid,
changedOrganizer: true,
destinationCalendars: expect.arrayContaining([
expect.objectContaining({
integration: "google_calendar",
externalId: "test-calendar",
}),
]),
});
expectBookingToBeInDatabase({
uid: bookingToReassignUid,
userId: zoomHost.id,
});
});
test("should fallback to first available event location in Event Type when new host has no default conferencing app", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
// (await mockGetAppFromSlug()).mockReturnValue(undefined);
const users = createConferencingUsers();
const googleMeetHost = users.googleMeetHost;
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
const bookingToReassignUid = "booking-fallback-location-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 2,
slug: "round-robin-event-fallback",
users: Object.values(users),
locations: [
{ type: OrganizerDefaultConferencingAppType },
{ type: "integrations:first-available" },
],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 2,
userId: googleMeetHost.id,
bookingId: 126,
bookingUid: bookingToReassignUid,
location: "integrations:google:meet",
}),
],
organizer: googleMeetHost,
usersApartFromOrganizer: Object.values(users).slice(1),
})
);
await roundRobinManualReassignment({
bookingId: 126,
newUserId: noDefaultConferencingAppHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
});
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((calledEvent as any).location).toBe("integrations:first-available");
});
test("should update location from Google Meet to static video link when new host has static video link app", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
await mockAppStore({ customVideo: true });
const users = createConferencingUsers();
const googleMeetHost = users.googleMeetHost;
const staticVideoLinkHost = users.staticVideoLinkHost;
const bookingToReassignUid = "booking-static-link-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 4,
slug: "managed-event",
// This is a managed event type child which allows Organizer's Default Conferencing App
parentId: 1,
users: Object.values(users),
locations: [{ type: OrganizerDefaultConferencingAppType }],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 4,
userId: googleMeetHost.id,
bookingId: 128,
bookingUid: bookingToReassignUid,
location: "integrations:google:meet",
}),
],
organizer: googleMeetHost,
usersApartFromOrganizer: Object.values(users).slice(1),
})
);
await roundRobinManualReassignment({
bookingId: 128,
newUserId: staticVideoLinkHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
});
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((calledEvent as any).location).toBe("https://custom-video.com/room123");
});
test("should update location from Static Video Link to Google Meet when new host has Google Meet app", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
await mockAppStore({ googleMeet: true });
const users = createConferencingUsers();
const staticVideoLinkHost = users.staticVideoLinkHost;
const googleMeetHost = users.googleMeetHost;
const bookingToReassignUid = "booking-static-link-to-google-meet-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 3,
slug: "round-robin-event-error",
users: Object.values(users),
teamId: 1,
locations: [{ type: OrganizerDefaultConferencingAppType }],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 3,
userId: staticVideoLinkHost.id,
bookingId: 127,
bookingUid: bookingToReassignUid,
location: "https://custom-video.com/room123",
}),
],
organizer: staticVideoLinkHost,
usersApartFromOrganizer: Object.values(users).filter((user) => user.id !== staticVideoLinkHost.id),
})
);
await roundRobinManualReassignment({
bookingId: 127,
newUserId: googleMeetHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
});
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((calledEvent as any).location).toBe("integrations:google:meet");
await expectBookingToBeInDatabase({
uid: bookingToReassignUid,
userId: googleMeetHost.id,
location: "integrations:google:meet",
});
});
});
test("should throw error when Cal Video fallback fails", async () => {
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
await mockEventManagerReschedule({ failConferencing: true });
const users = createConferencingUsers();
const googleMeetHost = users.googleMeetHost;
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
const bookingToReassignUid = "booking-calvideo-error-test";
await createBookingScenario(
getScenarioData({
eventTypes: [
createRoundRobinEventType({
id: 3,
slug: "round-robin-event-error",
users: Object.values(users),
locations: [{ type: OrganizerDefaultConferencingAppType }],
}),
],
bookings: [
await createTestBooking({
eventTypeId: 3,
userId: googleMeetHost.id,
bookingId: 127,
bookingUid: bookingToReassignUid,
location: "integrations:google:meet",
}),
],
organizer: googleMeetHost,
usersApartFromOrganizer: Object.values(users).slice(1),
})
);
await expect(
roundRobinManualReassignment({
bookingId: 127,
newUserId: noDefaultConferencingAppHost.id,
orgId: null,
reassignReason: "Host unavailable",
reassignedById: 999,
})
).rejects.toThrow("Failed to set video conferencing link, but the meeting has been rescheduled");
});
});
@@ -1,7 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { cloneDeep } from "lodash";
import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations";
import dayjs from "@calcom/dayjs";
import {
sendRoundRobinCancelledEmailsAndSMS,
@@ -28,10 +27,10 @@ import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKey
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
import logger from "@calcom/lib/logger";
import { getTranslation } from "@calcom/lib/server/i18n";
import { BookingLocationService } from "@calcom/lib/server/service/bookingLocationService";
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
import { prisma } from "@calcom/prisma";
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
import type { CalendarEvent } from "@calcom/types/Calendar";
@@ -126,7 +125,6 @@ export const roundRobinManualReassignment = async ({
const originalOrganizer = booking.user;
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
const newUser = newUserHost.user;
const newUserT = await getTranslation(newUser.locale || "en", "common");
const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common");
@@ -149,7 +147,7 @@ export const roundRobinManualReassignment = async ({
const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common");
let bookingLocation = booking.location;
let conferenceCredentialId: number | null = null;
if (hasOrganizerChanged) {
const bookingResponses = booking.responses;
const responseSchema = getBookingResponsesSchema({
@@ -159,15 +157,20 @@ export const roundRobinManualReassignment = async ({
const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
if (eventType.locations.some((location) => location.type === OrganizerDefaultConferencingAppType)) {
const newUserMetadataSafeParse = userMetadataSchema.safeParse(newUser.metadata);
const defaultLocationUrl = newUserMetadataSafeParse.success
? newUserMetadataSafeParse?.data?.defaultConferencingApp?.appLink
: undefined;
const currentBookingLocation = booking.location || "integrations:daily";
bookingLocation =
defaultLocationUrl ||
getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation;
// Determine the location for the new host
const isManagedEventType = !!eventType.parentId;
const isTeamEventType = !!eventType.teamId;
const locationResult = BookingLocationService.getLocationForHost({
hostMetadata: newUser.metadata,
eventTypeLocations: eventType.locations,
isManagedEventType,
isTeamEventType,
});
bookingLocation = locationResult.bookingLocation;
if (locationResult.requiresActualLink) {
conferenceCredentialId = locationResult.conferenceCredentialId;
}
const newBookingTitle = getEventName({
@@ -219,6 +222,18 @@ export const roundRobinManualReassignment = async ({
});
}
// When organizer hasn't changed, still extract conferenceCredentialId from event type locations
if (!hasOrganizerChanged && bookingLocation) {
const { conferenceCredentialId: extractedCredentialId } =
BookingLocationService.getLocationDetailsFromType({
locationType: bookingLocation,
eventTypeLocations: eventType.locations || [],
});
if (extractedCredentialId) {
conferenceCredentialId = extractedCredentialId;
}
}
const destinationCalendar = await getDestinationCalendar({
eventType,
booking,
@@ -290,6 +305,7 @@ export const roundRobinManualReassignment = async ({
customReplyToEmail: eventType?.customReplyToEmail,
location: bookingLocation,
...(platformClientParams ? platformClientParams : {}),
conferenceCredentialId: conferenceCredentialId ?? undefined,
};
const credentials = await prisma.credential.findMany({
@@ -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
+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 = [];
}
@@ -0,0 +1,18 @@
import { useEffect, useRef } from "react";
export function useElementByClassName<T extends HTMLElement = HTMLDivElement>(
className?: string
): React.RefObject<T> {
const elementRef = useRef<T | null>(null);
useEffect(() => {
if (className) {
const element = document.getElementsByClassName(className)[0] as T;
elementRef.current = element || null;
} else {
elementRef.current = null;
}
}, [className]);
return elementRef;
}
+1
View File
@@ -15,6 +15,7 @@
"@formbricks/api": "^1.7.0",
"@formbricks/js": "^2.1.0",
"@sendgrid/client": "^7.7.0",
"@unkey/ratelimit": "^2.1.3",
"@vercel/og": "^0.6.3",
"city-timezones": "^1.2.1",
"i18next": "^23.2.3",
+17
View File
@@ -30,6 +30,16 @@ export function rateLimiter() {
ms: 5000,
};
const onError = (err: Error, identifier: string) => {
log.error("Unkey rate limiter encountered unknown error", {
error: err.message,
stack: err.stack,
identifier,
timestamp: new Date().toISOString(),
});
return { success: true, limit: 10, remaining: 999, reset: 0 };
};
const limiter = {
core: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -37,6 +47,7 @@ export function rateLimiter() {
limit: 10,
duration: "60s",
timeout,
onError,
}),
common: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -44,6 +55,7 @@ export function rateLimiter() {
limit: 200,
duration: "60s",
timeout,
onError,
}),
forcedSlowMode: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -51,6 +63,7 @@ export function rateLimiter() {
limit: 1,
duration: "30s",
timeout,
onError,
}),
api: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -58,6 +71,7 @@ export function rateLimiter() {
limit: API_KEY_RATE_LIMIT,
duration: "60s",
timeout,
onError,
}),
ai: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -65,6 +79,7 @@ export function rateLimiter() {
limit: 20,
duration: "1d",
timeout,
onError,
}),
sms: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -72,6 +87,7 @@ export function rateLimiter() {
limit: 50,
duration: "5m",
timeout,
onError,
}),
smsMonth: new Ratelimit({
rootKey: UNKEY_ROOT_KEY,
@@ -79,6 +95,7 @@ export function rateLimiter() {
limit: 250,
duration: "30d",
timeout,
onError,
}),
};
@@ -296,7 +296,7 @@ describe("FilterSegmentRepository.get()", () => {
});
expect(result.segments).toHaveLength(1);
expect(result.preferredSegmentId).toBe(segment.id);
expect(result.preferredSegmentId).toEqual({ id: segment.id, type: "user" });
expect(result.segments[0].name).toBe("Important Contacts");
});
+93 -14
View File
@@ -1,4 +1,4 @@
import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types";
import type { FilterSegmentOutput, SegmentIdentifier } from "@calcom/features/data-table/lib/types";
import {
ZActiveFilters,
ZSortingState,
@@ -6,14 +6,14 @@ import {
ZColumnVisibility,
} from "@calcom/features/data-table/lib/types";
import { prisma } from "@calcom/prisma";
import type { FilterSegment, UserFilterSegmentPreference } from "@calcom/prisma/client";
import type { UserFilterSegmentPreference } from "@calcom/prisma/client";
import type { TCreateFilterSegmentInputSchema, TUpdateFilterSegmentInputSchema } from "./filterSegment.type";
export interface IFilterSegmentRepository {
get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }): Promise<{
segments: FilterSegmentOutput[];
preferredSegmentId: number | null;
preferredSegmentId: SegmentIdentifier | null;
}>;
create({
@@ -22,7 +22,7 @@ export interface IFilterSegmentRepository {
}: {
userId: number;
input: TCreateFilterSegmentInputSchema;
}): Promise<FilterSegment>;
}): Promise<FilterSegmentOutput>;
update({
userId,
@@ -30,7 +30,7 @@ export interface IFilterSegmentRepository {
}: {
userId: number;
input: TUpdateFilterSegmentInputSchema;
}): Promise<FilterSegment>;
}): Promise<FilterSegmentOutput>;
delete({ userId, id }: { userId: number; id: number }): Promise<void>;
@@ -41,7 +41,7 @@ export interface IFilterSegmentRepository {
}: {
userId: number;
tableIdentifier: string;
segmentId: number | null;
segmentId: SegmentIdentifier | null;
}): Promise<UserFilterSegmentPreference | null>;
}
@@ -124,12 +124,19 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
},
select: {
segmentId: true,
systemSegmentId: true,
},
});
const preferredSegmentId = preference?.segmentId
? { id: preference.segmentId, type: "user" as const }
: preference?.systemSegmentId
? { id: preference.systemSegmentId, type: "system" as const }
: null;
return {
segments: parsedSegments,
preferredSegmentId: preference?.segmentId || null,
preferredSegmentId,
};
}
@@ -174,9 +181,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
...(scope === "TEAM" ? { teamId } : {}),
userId,
},
select: {
id: true,
name: true,
tableIdentifier: true,
scope: true,
activeFilters: true,
sorting: true,
columnVisibility: true,
columnSizing: true,
perPage: true,
searchTerm: true,
createdAt: true,
updatedAt: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
name: true,
},
},
},
});
return filterSegment;
// Parse the segment with Zod schemas
const parsedSegment: FilterSegmentOutput = {
...filterSegment,
activeFilters: ZActiveFilters.catch([]).parse(filterSegment.activeFilters),
sorting: ZSortingState.catch([]).parse(filterSegment.sorting),
columnVisibility: ZColumnVisibility.catch({}).parse(filterSegment.columnVisibility),
columnSizing: ZColumnSizing.catch({}).parse(filterSegment.columnSizing),
};
return parsedSegment;
}
async update({ userId, input }: { userId: number; input: TUpdateFilterSegmentInputSchema }) {
@@ -228,9 +266,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
perPage,
searchTerm,
},
select: {
id: true,
name: true,
tableIdentifier: true,
scope: true,
activeFilters: true,
sorting: true,
columnVisibility: true,
columnSizing: true,
perPage: true,
searchTerm: true,
createdAt: true,
updatedAt: true,
userId: true,
teamId: true,
team: {
select: {
id: true,
name: true,
},
},
},
});
return updatedSegment;
// Parse the segment with Zod schemas
const parsedSegment: FilterSegmentOutput = {
...updatedSegment,
activeFilters: ZActiveFilters.catch([]).parse(updatedSegment.activeFilters),
sorting: ZSortingState.catch([]).parse(updatedSegment.sorting),
columnVisibility: ZColumnVisibility.catch({}).parse(updatedSegment.columnVisibility),
columnSizing: ZColumnSizing.catch({}).parse(updatedSegment.columnSizing),
};
return parsedSegment;
}
async delete({ userId, id }: { userId: number; id: number }) {
@@ -282,7 +351,7 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
}: {
userId: number;
tableIdentifier: string;
segmentId: number | null;
segmentId: SegmentIdentifier | null;
}) {
if (segmentId === null) {
await prisma.userFilterSegmentPreference.deleteMany({
@@ -294,6 +363,18 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
return null;
}
const isSystemSegment = segmentId.type === "system";
const updateData = isSystemSegment
? {
segmentId: null,
systemSegmentId: segmentId.id,
}
: {
segmentId: segmentId.id,
systemSegmentId: null,
};
const preference = await prisma.userFilterSegmentPreference.upsert({
where: {
userId_tableIdentifier: {
@@ -301,13 +382,11 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
tableIdentifier,
},
},
update: {
segmentId,
},
update: updateData,
create: {
userId,
tableIdentifier,
segmentId,
...updateData,
},
});
@@ -1,6 +1,5 @@
import { z } from "zod";
import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types";
import {
ZActiveFilters,
ZSortingState,
@@ -8,11 +7,6 @@ import {
ZColumnVisibility,
} from "@calcom/features/data-table/lib/types";
export type FilterSegmentsListResponse = {
segments: FilterSegmentOutput[];
preferredSegmentId: number | null;
};
export const ZListFilterSegmentsInputSchema = z.object({
tableIdentifier: z.string(),
});
@@ -58,9 +52,14 @@ export const ZCreateFilterSegmentInputSchema = z.discriminatedUnion("scope", [
export type TCreateFilterSegmentInputSchema = z.infer<typeof ZCreateFilterSegmentInputSchema>;
const ZSegmentIdentifier = z.discriminatedUnion("type", [
z.object({ id: z.string(), type: z.literal("system") }),
z.object({ id: z.number(), type: z.literal("user") }),
]);
export const ZSetFilterSegmentPreferenceInputSchema = z.object({
tableIdentifier: z.string(),
segmentId: z.number().nullable(),
segmentId: ZSegmentIdentifier.nullable(),
});
export type TSetFilterSegmentPreferenceInputSchema = z.infer<typeof ZSetFilterSegmentPreferenceInputSchema>;
@@ -298,8 +298,16 @@ export class InsightsBookingBaseService {
return null;
}
if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`;
if (id === "eventTypeId" && isMultiSelectFilterValue(value)) {
const eventTypeIds = value.data.map((id) => Number(id));
if (eventTypeIds.length === 0) {
return null;
}
return Prisma.sql`("eventTypeId" IN (${Prisma.join(eventTypeIds)}) OR "eventParentId" IN (${Prisma.join(
eventTypeIds
)}))`;
}
if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
@@ -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"}
))`
);
@@ -0,0 +1,558 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { BookingLocationService } from "./bookingLocationService";
vi.mock("@calcom/app-store/utils", () => ({
getAppFromSlug: vi.fn(),
}));
vi.mock("@calcom/app-store/locations", async () => {
const actual = await vi.importActual("@calcom/app-store/locations");
return {
...actual,
OrganizerDefaultConferencingAppType: "conferencing",
CalVideoLocationType: "integrations:daily-video",
};
});
const { getAppFromSlug } = await import("@calcom/app-store/utils");
describe("BookingLocationService", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getOrganizerDefaultConferencingAppLocation", () => {
describe("Dynamic conferencing apps", () => {
it("should return the app location type for a dynamic app like Google Meet", () => {
const mockMetadata = {
defaultConferencingApp: {
appSlug: "google-meet",
},
};
vi.mocked(getAppFromSlug).mockReturnValue({
appData: {
location: {
type: "integrations:google:meet",
},
},
} as any);
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: mockMetadata,
});
expect(result).toEqual({
conferencingAppLocationType: "integrations:google:meet",
isStaticLinkApp: false,
});
expect(getAppFromSlug).toHaveBeenCalledWith("google-meet");
});
it("should return the app location type for Zoom", () => {
const mockMetadata = {
defaultConferencingApp: {
appSlug: "zoom",
},
};
vi.mocked(getAppFromSlug).mockReturnValue({
appData: {
location: {
type: "integrations:zoom",
},
},
} as any);
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: mockMetadata,
});
expect(result).toEqual({
conferencingAppLocationType: "integrations:zoom",
isStaticLinkApp: false,
});
});
it("should return null when app is not found", () => {
const mockMetadata = {
defaultConferencingApp: {
appSlug: "unknown-app",
},
};
vi.mocked(getAppFromSlug).mockReturnValue(null);
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: mockMetadata,
});
expect(result).toBeNull();
});
});
describe("Static link apps", () => {
it("should return static link for apps with appLink", () => {
const mockMetadata = {
defaultConferencingApp: {
appSlug: "custom-video",
appLink: "https://custom-video.com/room123",
},
};
vi.mocked(getAppFromSlug).mockReturnValue({
appData: {
location: {
type: "integrations:custom-video",
},
},
} as any);
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: mockMetadata,
});
expect(result).toEqual({
conferencingAppLocationType: "integrations:custom-video",
isStaticLinkApp: true,
staticLink: "https://custom-video.com/room123",
});
});
});
describe("Fallback scenarios", () => {
it("should return null when no conferencing app is set", () => {
const mockMetadata = {};
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: mockMetadata,
});
expect(result).toBeNull();
expect(getAppFromSlug).not.toHaveBeenCalled();
});
it("should handle null metadata gracefully", () => {
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: null,
});
expect(result).toBeNull();
});
it("should handle undefined metadata gracefully", () => {
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: undefined,
});
expect(result).toBeNull();
});
});
describe("Invalid metadata parsing", () => {
it("should handle invalid metadata structure", () => {
const invalidMetadata = {
defaultConferencingApp: "not-an-object",
};
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: invalidMetadata,
});
expect(result).toBeNull();
});
it("should handle metadata with missing appSlug", () => {
const mockMetadata = {
defaultConferencingApp: {
appLink: "https://some-link.com",
},
};
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: mockMetadata,
});
expect(result).toBeNull();
});
});
describe("Edge cases", () => {
it("should handle app with no appData", () => {
const mockMetadata = {
defaultConferencingApp: {
appSlug: "app-without-data",
},
};
vi.mocked(getAppFromSlug).mockReturnValue({} as any);
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: mockMetadata,
});
expect(result).toBeNull();
});
it("should handle app with no location type", () => {
const mockMetadata = {
defaultConferencingApp: {
appSlug: "app-without-location-type",
},
};
vi.mocked(getAppFromSlug).mockReturnValue({
appData: {
location: {},
},
} as any);
const result = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: mockMetadata,
});
expect(result).toBeNull();
});
});
});
describe("getLocationForHost", () => {
it("should use organizer default when allowed and available", () => {
const mockHostMetadata = {
defaultConferencingApp: {
appSlug: "zoom",
},
};
vi.mocked(getAppFromSlug).mockReturnValue({
appData: {
location: {
type: "integrations:zoom",
},
},
} as any);
const result = BookingLocationService.getLocationForHost({
hostMetadata: mockHostMetadata,
eventTypeLocations: [{ type: "conferencing" }],
isTeamEventType: true,
});
expect(result).toEqual({
bookingLocation: "integrations:zoom",
requiresActualLink: true,
conferenceCredentialId: null,
});
});
it("should use static link when organizer default is a static link app", () => {
const mockHostMetadata = {
defaultConferencingApp: {
appSlug: "custom-video",
appLink: "https://custom-video.com/room123",
},
};
vi.mocked(getAppFromSlug).mockReturnValue({
appData: {
location: {
type: "integrations:custom-video",
},
},
} as any);
const result = BookingLocationService.getLocationForHost({
hostMetadata: mockHostMetadata,
eventTypeLocations: [{ type: "conferencing" }],
isTeamEventType: true,
});
expect(result).toEqual({
bookingLocation: "https://custom-video.com/room123",
requiresActualLink: false,
});
});
it("should fallback to first real location when organizer default not available", () => {
const mockHostMetadata = {};
const result = BookingLocationService.getLocationForHost({
hostMetadata: mockHostMetadata,
eventTypeLocations: [{ type: "conferencing" }, { type: "integrations:google:meet" }],
isTeamEventType: true,
});
expect(result).toEqual({
bookingLocation: "integrations:google:meet",
requiresActualLink: true,
conferenceCredentialId: null,
});
});
it("should default to Cal Video when no locations configured", () => {
const mockHostMetadata = {};
const result = BookingLocationService.getLocationForHost({
hostMetadata: mockHostMetadata,
eventTypeLocations: [{ type: "conferencing" }],
isTeamEventType: true,
});
expect(result).toEqual({
bookingLocation: "integrations:daily-video",
requiresActualLink: true,
conferenceCredentialId: null,
});
});
it("should not use organizer default for non-team and non-managed events", () => {
const mockHostMetadata = {
defaultConferencingApp: {
appSlug: "zoom",
},
};
vi.mocked(getAppFromSlug).mockReturnValue({
appData: {
location: {
type: "integrations:zoom",
},
},
} as any);
const result = BookingLocationService.getLocationForHost({
hostMetadata: mockHostMetadata,
eventTypeLocations: [{ type: "conferencing" }, { type: "integrations:google:meet" }],
isTeamEventType: false,
isManagedEventType: false,
});
expect(result).toEqual({
bookingLocation: "integrations:google:meet",
requiresActualLink: true,
conferenceCredentialId: null,
});
});
});
describe("getLocationDetailsFromType", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Dynamic location types", () => {
it("should return location type with credential ID when present", () => {
const locationType = "integrations:zoom";
const eventTypeLocations = [
{ type: "integrations:zoom", credentialId: 123 },
{ type: "integrations:google:meet" },
];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "integrations:zoom",
conferenceCredentialId: 123,
});
});
it("should return location type without credential ID when not present", () => {
const locationType = "integrations:google:meet";
const eventTypeLocations = [{ type: "integrations:google:meet" }, { type: "integrations:zoom" }];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "integrations:google:meet",
conferenceCredentialId: null,
});
});
it("should handle Cal Video location type", () => {
const locationType = "integrations:daily-video";
const eventTypeLocations = [{ type: "integrations:daily-video" }];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "integrations:daily-video",
conferenceCredentialId: null,
});
});
});
describe("Static link values", () => {
it("should return static URL as-is without credential ID", () => {
const locationType = "https://zoom.us/j/123456789";
const eventTypeLocations = [{ type: "integrations:zoom", link: "https://zoom.us/j/123456789" }];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "https://zoom.us/j/123456789",
conferenceCredentialId: null,
});
});
it("should handle custom static links", () => {
const locationType = "https://custom-video.com/room123";
const eventTypeLocations = [
{ type: "integrations:custom", link: "https://custom-video.com/room123" },
];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "https://custom-video.com/room123",
conferenceCredentialId: null,
});
});
});
describe("Edge cases", () => {
it("should handle empty event type locations", () => {
const locationType = "integrations:zoom";
const eventTypeLocations: any[] = [];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "integrations:zoom",
conferenceCredentialId: null,
});
});
it("should handle location type not in event type locations", () => {
const locationType = "integrations:teams";
const eventTypeLocations = [{ type: "integrations:zoom" }, { type: "integrations:google:meet" }];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "integrations:teams",
conferenceCredentialId: null,
});
});
it("should handle multiple locations with same type but different credential IDs", () => {
const locationType = "integrations:zoom";
const eventTypeLocations = [
{ type: "integrations:zoom", credentialId: 123 },
{ type: "integrations:zoom", credentialId: 456 },
];
// getLocationValueForDB would pick the last matching one due to forEach
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "integrations:zoom",
conferenceCredentialId: 456,
});
});
it("should handle location with 0 as credential ID", () => {
const locationType = "integrations:zoom";
const eventTypeLocations = [{ type: "integrations:zoom", credentialId: 0 }];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "integrations:zoom",
conferenceCredentialId: 0,
});
});
});
describe("Special location types", () => {
it("should handle phone location type", () => {
const locationType = "phone";
const eventTypeLocations = [{ type: "phone" }];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "phone",
conferenceCredentialId: null,
});
});
it("should handle in-person location type", () => {
const locationType = "inPerson";
const eventTypeLocations = [{ type: "inPerson", address: "123 Main St" }];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "123 Main St", // getLocationValueForDB returns the address for inPerson
conferenceCredentialId: null,
});
});
it("should handle attendee in-person location", () => {
const locationType = "attendeeInPerson";
const eventTypeLocations = [{ type: "attendeeInPerson" }];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "attendeeInPerson",
conferenceCredentialId: null,
});
});
});
describe("Conferencing app location", () => {
it("should handle conferencing location type (organizer default)", () => {
const locationType = "conferencing";
const eventTypeLocations = [{ type: "conferencing" }];
const result = BookingLocationService.getLocationDetailsFromType({
locationType,
eventTypeLocations,
});
expect(result).toEqual({
bookingLocation: "conferencing",
conferenceCredentialId: null,
});
});
});
});
});
@@ -0,0 +1,247 @@
import { getLocationValueForDB, OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
import { CalVideoLocationType, type LocationObject } from "@calcom/app-store/locations";
import { getAppFromSlug } from "@calcom/app-store/utils";
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
type GetOrganizerDefaultConferencingAppLocationParams = {
organizerMetadata: unknown;
};
type GetOrganizerDefaultConferencingAppLocationResult = {
/**
* The conferencing app location type.
* It starts with 'integrations:' e.g. 'integrations:zoom' and it is defined in the app's config/metadata.ts file
*/
conferencingAppLocationType: string;
} & (
| {
isStaticLinkApp: true;
staticLink: string;
}
| {
isStaticLinkApp: false;
}
);
type GetLocationForHostParams = {
/**
* The new host's metadata containing their default conferencing app preferences
*/
hostMetadata: unknown;
/**
* The event type's configured locations
*/
eventTypeLocations: LocationObject[];
/**
* Whether this is a managed event type
*/
isManagedEventType?: boolean;
/**
* Whether this is a team event type
*/
isTeamEventType?: boolean;
};
type GetLocationForHostResult = {
/**
* The location string to be stored in the booking
*/
bookingLocation: string;
} & (
| {
requiresActualLink: false;
}
| {
requiresActualLink: true;
// Credential ID for generating the conferencing link. Set when:
// 1. The app supports team installation (concurrentMeetings: true)
// 2. The app is installed at team level (credential has teamId)
// 3. The event type is a team event that can use team credentials
conferenceCredentialId: number | null;
}
);
type GetLocationDetailsFromTypeParams = {
/**
* The location type or value to resolve
* @example "integrations:zoom" - A dynamic location type
* @example "https://example.com/meeting" - A static link value
* @example "integrations:daily" - Cal Video location type
*/
locationType: string;
/**
* The event type's configured locations that may contain credential IDs
*/
eventTypeLocations: LocationObject[];
};
type GetLocationDetailsFromTypeResult = {
/**
* The resolved location value for the database
*/
bookingLocation: string;
/**
* The conference credential ID if present in the location configuration
*/
conferenceCredentialId: number | null;
};
export class BookingLocationService {
/**
* Determines the booking location based on the Organizer's Default Conferencing App.
*
* Note that Organizer's Default Conferencing App is applicable only to Team Events. It gets also applicable to Children Managed Event Types(which are technically User Event Types) as well because the location is configured at Parent Managed Event Type(which is a Team Event Type)
*
* This function handles two types of conferencing apps:
* 1. Dynamic link apps (e.g., Google Meet, Zoom) - Generate a new link for each booking
* - These apps only have `appSlug` in organizerMetadata
* - The location is determined by the app's metadata (e.g., "integrations:google:meet")
*
* 2. Static link apps (e.g., Campfire)
* - These apps have `appLink`, as well as `appSlug` in organizerMetadata
* - The `appLink` is only used for managed or team event types
*
* @returns The location configuration or null if no valid conferencing app is found.
* Callers should handle the null case with their own fallback logic.
*/
static getOrganizerDefaultConferencingAppLocation({
organizerMetadata,
}: GetOrganizerDefaultConferencingAppLocationParams): GetOrganizerDefaultConferencingAppLocationResult | null {
// Parse the user metadata to extract conferencing app settings
const metadataParseResult = userMetadataSchema.safeParse(organizerMetadata);
const parsedMetadata = metadataParseResult.success ? metadataParseResult.data : undefined;
if (!parsedMetadata?.defaultConferencingApp?.appSlug) {
return null;
}
// Retrieve the app configuration from the app store
const app = getAppFromSlug(parsedMetadata.defaultConferencingApp.appSlug);
// Extract the location type from the app's metadata (e.g., "integrations:zoom")
const conferencingAppLocationType = app?.appData?.location?.type || null;
// If the app doesn't have a valid location type, return null
if (!conferencingAppLocationType) {
return null;
}
if (parsedMetadata.defaultConferencingApp.appLink) {
return {
conferencingAppLocationType,
isStaticLinkApp: true,
staticLink: parsedMetadata.defaultConferencingApp.appLink,
};
}
return {
conferencingAppLocationType,
isStaticLinkApp: false,
};
}
/**
* Determines the appropriate booking location based on the chosen host
*
* Logic:
* 1. If the event type includes OrganizerDefaultConferencingAppType in its locations,
* and the new host has a default conferencing app configured, use that.
* 2. Otherwise, use the first available location from the event type's configured locations.
*
*
* @returns The location configuration for the new host
*/
static getLocationForHost({
hostMetadata,
eventTypeLocations,
isManagedEventType = false,
isTeamEventType = false,
}: GetLocationForHostParams): GetLocationForHostResult {
// Check if the event type allows using the organizer's default conferencing app
const eventAllowsOrganizerDefault = eventTypeLocations.some(
(location) => location.type === OrganizerDefaultConferencingAppType
);
const isOrganizerDefaultAllowed = isTeamEventType || isManagedEventType;
if (eventAllowsOrganizerDefault && isOrganizerDefaultAllowed) {
const organizerDefaultLocation = BookingLocationService.getOrganizerDefaultConferencingAppLocation({
organizerMetadata: hostMetadata,
});
if (organizerDefaultLocation) {
if (organizerDefaultLocation.isStaticLinkApp) {
return {
requiresActualLink: false,
bookingLocation: organizerDefaultLocation.staticLink,
};
}
return {
requiresActualLink: true,
conferenceCredentialId: null,
bookingLocation: organizerDefaultLocation.conferencingAppLocationType,
};
}
}
// Fallback: Find the first real location (not OrganizerDefaultConferencingAppType)
const firstRealLocation = eventTypeLocations.find(
(location) => location.type !== OrganizerDefaultConferencingAppType
);
if (!firstRealLocation) {
// If no real locations are configured, default to Cal Video
return {
bookingLocation: CalVideoLocationType,
requiresActualLink: true,
conferenceCredentialId: null,
};
}
// Process the first real location through getLocationValueForDB to handle static links properly
const { bookingLocation, conferenceCredentialId } = BookingLocationService.getLocationDetailsFromType({
locationType: firstRealLocation.type,
eventTypeLocations,
});
return {
bookingLocation,
requiresActualLink: true,
conferenceCredentialId,
};
}
/**
* Extracts location details including conferenceCredentialId from a location type or value
*
* @param params - The location type/value and event type locations
* @returns The resolved booking location and conference credential ID if present
*
* @example
* // Dynamic location type with credential ID
* getLocationDetailsFromType({
* locationType: "integrations:zoom",
* eventTypeLocations: [{ type: "integrations:zoom", credentialId: 123 }]
* })
* // Returns: { bookingLocation: "integrations:zoom", conferenceCredentialId: 123 }
*
* @example
* // Static link value (already resolved)
* getLocationDetailsFromType({
* locationType: "https://zoom.us/j/123456789",
* eventTypeLocations: [{ type: "integrations:zoom", link: "https://zoom.us/j/123456789" }]
* })
* // Returns: { bookingLocation: "https://zoom.us/j/123456789", conferenceCredentialId: null }
*/
static getLocationDetailsFromType({
locationType,
eventTypeLocations,
}: GetLocationDetailsFromTypeParams): GetLocationDetailsFromTypeResult {
const { bookingLocation, conferenceCredentialId } = getLocationValueForDB(
locationType,
eventTypeLocations
);
return { bookingLocation, conferenceCredentialId: conferenceCredentialId ?? null };
}
}
+12
View File
@@ -1,5 +1,17 @@
## 1.1.2
## 1.6.0
### Minor Changes
- [#23074](https://github.com/calcom/cal.com/pull/23074) [`09cf888`](https://github.com/calcom/cal.com/commit/09cf8885cf5e88fc0911edaf7e43f5c1c2d64923) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - feat: add send & verify code flow to booker atom when email verification is turned on
- [#23211](https://github.com/calcom/cal.com/pull/23211) [`89a3b77`](https://github.com/calcom/cal.com/commit/89a3b77a1dd0773e6b746b14e72f9a792ac1c43a) Thanks [@SomayChauhan](https://github.com/SomayChauhan)! - 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.
### Patch Changes
- [#23213](https://github.com/calcom/cal.com/pull/23213) [`40caa82`](https://github.com/calcom/cal.com/commit/40caa82e5592cc17d84dd5e73531d1b376ce187c) Thanks [@Ryukemeister](https://github.com/Ryukemeister)! - This PR fixes `onBookerStateChange` prop not working
## 1.5.0
### Minor Changes
@@ -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,
};
};
+1 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"description": "Customizable UI components to integrate scheduling into your product.",
"authors": "Cal.com, Inc.",
"version": "1.5.0",
"version": "1.6.0",
"scripts": {
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
@@ -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.
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "UserFilterSegmentPreference" ADD COLUMN "systemSegmentId" TEXT,
ALTER COLUMN "segmentId" DROP NOT NULL;
-- Add an XOR CHECK constraint between segmentId and systemSegmentId
ALTER TABLE "UserFilterSegmentPreference"
ADD CONSTRAINT "UserFilterSegmentPreference_segment_xor_system_chk"
CHECK (
(("segmentId" IS NOT NULL AND "systemSegmentId" IS NULL)
OR ("segmentId" IS NULL AND "systemSegmentId" IS NOT NULL))
),
ADD CONSTRAINT "UserFilterSegmentPreference_systemSegmentId_nonempty_chk"
CHECK ("systemSegmentId" IS NULL OR length("systemSegmentId") > 0);
+7 -7
View File
@@ -2356,15 +2356,15 @@ model FilterSegment {
}
model UserFilterSegmentPreference {
id Int @id @default(autoincrement())
id Int @id @default(autoincrement())
userId Int
tableIdentifier String
segmentId Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
segment FilterSegment @relation(fields: [segmentId], references: [id], onDelete: Cascade)
segmentId Int?
systemSegmentId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
segment FilterSegment? @relation(fields: [segmentId], references: [id], onDelete: Cascade)
@@unique([userId, tableIdentifier])
@@index([userId])
@@ -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
@@ -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,
@@ -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) {
+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": {

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