Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea6e105e6 | ||
|
|
a082cf73a0 | ||
|
|
61268f36b9 | ||
|
|
99cd0c5c41 | ||
|
|
2266301469 | ||
|
|
30b0012e6a | ||
|
|
77d1958c42 | ||
|
|
88f347f18c | ||
|
|
09cf8885cf | ||
|
|
f3d4eab951 | ||
|
|
866b6cc233 | ||
|
|
cafecad8ae | ||
|
|
0ea673e507 | ||
|
|
cf367c9340 | ||
|
|
d72e15649f | ||
|
|
89a3b77a1d | ||
|
|
38f6f71ae0 | ||
|
|
40caa82e55 | ||
|
|
b9aee3b18c |
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+76
-1
@@ -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: {
|
||||
|
||||
+10
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
+7
@@ -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,
|
||||
|
||||
+4
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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 }) => {
|
||||
|
||||
@@ -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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
"@daily-co/daily-js": "^0.76.0",
|
||||
"@evyweb/ioctopus": "^1.2.0",
|
||||
"@next/third-parties": "^14.2.5",
|
||||
"@unkey/ratelimit": "^2.1.2",
|
||||
"@vercel/functions": "^1.4.0",
|
||||
"city-timezones": "^1.2.1",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cach
|
||||
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
|
||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { uniqueBy } from "@calcom/lib/array";
|
||||
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||
@@ -85,10 +86,16 @@ export default class GoogleCalendarService implements Calendar {
|
||||
const selectedHostDestinationCalendar = event.destinationCalendar?.find(
|
||||
(cal) => cal.credentialId === this.credential.id
|
||||
);
|
||||
|
||||
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
|
||||
.filter((domain) => domain.trim() !== "")
|
||||
.some((domain) => event.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
|
||||
|
||||
const eventAttendees = event.attendees.map(({ id: _id, ...rest }) => ({
|
||||
...rest,
|
||||
responseStatus: "accepted",
|
||||
}));
|
||||
|
||||
const attendees: calendar_v3.Schema$EventAttendee[] = [
|
||||
{
|
||||
...event.organizer,
|
||||
@@ -100,7 +107,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
// We use || instead of ?? here to handle empty strings
|
||||
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
|
||||
},
|
||||
...eventAttendees,
|
||||
...(event.hideOrganizerEmail && !isOrganizerExempt ? [] : eventAttendees),
|
||||
];
|
||||
|
||||
if (event.team?.members) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* TODO: Consolidate this file with BookingLocationService and add tests
|
||||
*/
|
||||
import type { TFunction } from "i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -58,7 +61,7 @@ export type EventLocationTypeFromApp = Ensure<
|
||||
export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp;
|
||||
|
||||
export const DailyLocationType = "integrations:daily";
|
||||
|
||||
export const CalVideoLocationType = DailyLocationType;
|
||||
export const MeetLocationType = "integrations:google:meet";
|
||||
|
||||
export const MSTeamsLocationType = "integrations:office365_video";
|
||||
@@ -380,7 +383,7 @@ export const getLocationValueForDB = (
|
||||
eventLocations: LocationObject[]
|
||||
) => {
|
||||
let bookingLocation = bookingLocationTypeOrValue;
|
||||
let conferenceCredentialId = undefined;
|
||||
let conferenceCredentialId: number | undefined = undefined;
|
||||
|
||||
eventLocations.forEach((location) => {
|
||||
if (location.type === bookingLocationTypeOrValue) {
|
||||
|
||||
@@ -1,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: "",
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -86,6 +86,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
|
||||
startTime?: string | Date;
|
||||
roundRobinHideOrgAndTeam?: boolean;
|
||||
silentlyHandleCalendarFailures?: boolean;
|
||||
hideEventMetadata?: boolean;
|
||||
};
|
||||
|
||||
type VIEW_TYPE = keyof typeof BookerLayouts;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
@@ -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])
|
||||
|
||||
+10
-1
@@ -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
@@ -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
Reference in New Issue
Block a user