Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77d1958c42 | ||
|
|
88f347f18c | ||
|
|
09cf8885cf | ||
|
|
f3d4eab951 | ||
|
|
866b6cc233 | ||
|
|
cafecad8ae | ||
|
|
0ea673e507 | ||
|
|
cf367c9340 | ||
|
|
d72e15649f | ||
|
|
89a3b77a1d | ||
|
|
38f6f71ae0 | ||
|
|
40caa82e55 | ||
|
|
b9aee3b18c | ||
|
|
3f8d2f2b38 | ||
|
|
c3731d9f17 | ||
|
|
6e4b2d8886 | ||
|
|
cf13c06c57 | ||
|
|
e309e72be3 | ||
|
|
b4d804de7c | ||
|
|
f501ef3269 | ||
|
|
79f1074f93 | ||
|
|
48aa6bf489 | ||
|
|
8ed2aacd9e | ||
|
|
dfc5d914fc | ||
|
|
0a11c45065 | ||
|
|
4ae5de5a3a |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
This PR fixes `onBookerStateChange` prop not working
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: add send & verify code flow to booker atom when email verification is turned on
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@calcom/atoms": minor
|
||||
---
|
||||
|
||||
feat: added hideEventMetadata prop to the booker atom that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information.
|
||||
@@ -0,0 +1,187 @@
|
||||
language: en-US
|
||||
tone_instructions: ''
|
||||
early_access: false
|
||||
enable_free_tier: true
|
||||
reviews:
|
||||
profile: chill
|
||||
request_changes_workflow: false
|
||||
high_level_summary: false
|
||||
high_level_summary_placeholder: '@coderabbitai summary'
|
||||
high_level_summary_in_walkthrough: false
|
||||
auto_title_placeholder: '@coderabbitai'
|
||||
auto_title_instructions: ''
|
||||
review_status: true
|
||||
commit_status: true
|
||||
fail_commit_status: false
|
||||
collapse_walkthrough: false
|
||||
changed_files_summary: false
|
||||
sequence_diagrams: false
|
||||
estimate_code_review_effort: false
|
||||
assess_linked_issues: true
|
||||
related_issues: false
|
||||
related_prs: true
|
||||
suggested_labels: false
|
||||
auto_apply_labels: false
|
||||
suggested_reviewers: false
|
||||
auto_assign_reviewers: false
|
||||
poem: false
|
||||
labeling_instructions: []
|
||||
path_filters:
|
||||
- '!**/node_modules/**'
|
||||
- '!**/dist/**'
|
||||
- '!**/build/**'
|
||||
- '!**/.next/**'
|
||||
path_instructions:
|
||||
- path: "**/*.{ts,tsx,js,jsx}"
|
||||
instructions: "Flag default exports and encourage named exports. Named exports provide better tree-shaking, easier refactoring, and clearer imports. Exempt main components like pages, layouts, and components that serve as the primary export of a module."
|
||||
abort_on_close: true
|
||||
disable_cache: false
|
||||
auto_review:
|
||||
enabled: true
|
||||
auto_incremental_review: true
|
||||
ignore_title_keywords: []
|
||||
labels: []
|
||||
drafts: false
|
||||
base_branches: []
|
||||
finishing_touches:
|
||||
docstrings:
|
||||
enabled: true
|
||||
unit_tests:
|
||||
enabled: true
|
||||
pre_merge_checks:
|
||||
docstrings:
|
||||
mode: warning
|
||||
threshold: 80
|
||||
title:
|
||||
mode: warning
|
||||
requirements: ''
|
||||
description:
|
||||
mode: warning
|
||||
issue_assessment:
|
||||
mode: warning
|
||||
tools:
|
||||
ast-grep:
|
||||
rule_dirs: []
|
||||
util_dirs: []
|
||||
essential_rules: true
|
||||
packages: []
|
||||
shellcheck:
|
||||
enabled: true
|
||||
ruff:
|
||||
enabled: true
|
||||
markdownlint:
|
||||
enabled: true
|
||||
github-checks:
|
||||
enabled: true
|
||||
timeout_ms: 180000
|
||||
languagetool:
|
||||
enabled: true
|
||||
enabled_rules: []
|
||||
disabled_rules: []
|
||||
enabled_categories: []
|
||||
disabled_categories: []
|
||||
enabled_only: false
|
||||
level: default
|
||||
biome:
|
||||
enabled: true
|
||||
hadolint:
|
||||
enabled: true
|
||||
swiftlint:
|
||||
enabled: true
|
||||
phpstan:
|
||||
enabled: true
|
||||
level: default
|
||||
phpmd:
|
||||
enabled: true
|
||||
phpcs:
|
||||
enabled: true
|
||||
golangci-lint:
|
||||
enabled: true
|
||||
yamllint:
|
||||
enabled: true
|
||||
gitleaks:
|
||||
enabled: true
|
||||
checkov:
|
||||
enabled: true
|
||||
detekt:
|
||||
enabled: true
|
||||
eslint:
|
||||
enabled: true
|
||||
flake8:
|
||||
enabled: true
|
||||
rubocop:
|
||||
enabled: true
|
||||
buf:
|
||||
enabled: true
|
||||
regal:
|
||||
enabled: true
|
||||
actionlint:
|
||||
enabled: true
|
||||
pmd:
|
||||
enabled: true
|
||||
cppcheck:
|
||||
enabled: true
|
||||
semgrep:
|
||||
enabled: true
|
||||
circleci:
|
||||
enabled: true
|
||||
clippy:
|
||||
enabled: true
|
||||
sqlfluff:
|
||||
enabled: true
|
||||
prismaLint:
|
||||
enabled: true
|
||||
pylint:
|
||||
enabled: true
|
||||
oxc:
|
||||
enabled: true
|
||||
shopifyThemeCheck:
|
||||
enabled: true
|
||||
luacheck:
|
||||
enabled: true
|
||||
brakeman:
|
||||
enabled: true
|
||||
dotenvLint:
|
||||
enabled: true
|
||||
htmlhint:
|
||||
enabled: true
|
||||
checkmake:
|
||||
enabled: true
|
||||
osvScanner:
|
||||
enabled: true
|
||||
chat:
|
||||
art: true
|
||||
auto_reply: true
|
||||
integrations:
|
||||
jira:
|
||||
usage: auto
|
||||
linear:
|
||||
usage: auto
|
||||
knowledge_base:
|
||||
opt_out: false
|
||||
web_search:
|
||||
enabled: true
|
||||
code_guidelines:
|
||||
enabled: true
|
||||
filePatterns: []
|
||||
learnings:
|
||||
scope: auto
|
||||
issues:
|
||||
scope: auto
|
||||
jira:
|
||||
usage: auto
|
||||
project_keys: []
|
||||
linear:
|
||||
usage: auto
|
||||
team_keys: []
|
||||
pull_requests:
|
||||
scope: auto
|
||||
mcp:
|
||||
usage: auto
|
||||
disabled_servers: []
|
||||
code_generation:
|
||||
docstrings:
|
||||
language: en-US
|
||||
path_instructions: []
|
||||
unit_tests:
|
||||
path_instructions: []
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"functions": {
|
||||
"pages/api/slots/*.ts": {
|
||||
"memory": 512
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.318",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -18,7 +18,6 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
|
||||
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
@@ -27,7 +26,6 @@ import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@cal
|
||||
import {
|
||||
CancelBookingInput_2024_08_13,
|
||||
CancelSeatedBookingInput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
@@ -35,11 +33,9 @@ import {
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
|
||||
describe("Seated bookings", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
@@ -48,7 +44,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
|
||||
@@ -58,7 +53,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let apiKeyString: string;
|
||||
|
||||
let seatedEventTypeId: number;
|
||||
const maxRecurrenceCount = 3;
|
||||
|
||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
|
||||
@@ -83,7 +77,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
@@ -190,6 +183,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
guests: [],
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
@@ -264,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
absent: false,
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
bookingFieldsResponses: {
|
||||
guests: [],
|
||||
name: createdSeatedBooking.attendees[0].name,
|
||||
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
|
||||
},
|
||||
@@ -278,6 +273,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
guests: [],
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
@@ -517,6 +513,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
guests: [],
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
|
||||
+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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,42 +3,48 @@
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
|
||||
|
||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
// Define how likely Replay events are sampled.
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
// Define how likely Replay events are sampled.
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
// Define how likely Replay events are sampled when an error occurs.
|
||||
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
// Define how likely Replay events are sampled when an error occurs.
|
||||
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: !!process.env.SENTRY_DEBUG,
|
||||
beforeSend(event) {
|
||||
if (
|
||||
event.exception?.values?.some(
|
||||
(e) =>
|
||||
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
|
||||
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
|
||||
e.value?.includes("Non-Error promise rejection captured with") ||
|
||||
e.value?.includes("Object Not Found Matching Id")
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: !!process.env.SENTRY_DEBUG,
|
||||
beforeSend(event) {
|
||||
if (
|
||||
event.exception?.values?.some(
|
||||
(e) =>
|
||||
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
|
||||
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
|
||||
e.value?.includes("Non-Error promise rejection captured with") ||
|
||||
e.value?.includes("Object Not Found Matching Id")
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
errorSource: "client",
|
||||
};
|
||||
return event;
|
||||
},
|
||||
});
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
errorSource: "client",
|
||||
};
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
export function onRouterTransitionStart(url: string, navigationType: "push" | "replace" | "traverse") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
Sentry.captureRouterTransitionStart(url, navigationType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { type Instrumentation } from 'next'
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config");
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
export const onRequestError: Instrumentation.onRequestError = ( err,
|
||||
request,
|
||||
context) => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
Sentry.captureRequestError(err, request, context);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
darkBrandColor: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
isPlatformManaged: true,
|
||||
};
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
@@ -75,6 +76,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
createdByOAuthClientId: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
|
||||
@@ -60,22 +60,6 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
|
||||
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
|
||||
|
||||
if (!url.pathname.startsWith("/api")) {
|
||||
//
|
||||
// NOTE: When tRPC hits an error a 500 is returned, when this is received
|
||||
// by the application the user is automatically redirected to /auth/login.
|
||||
//
|
||||
// - For this reason our matchers are sufficient for an app-wide maintenance page.
|
||||
//
|
||||
// Check whether the maintenance page should be shown
|
||||
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
|
||||
// If is in maintenance mode, point the url pathname to the maintenance page
|
||||
if (isInMaintenanceMode) {
|
||||
reqWithEnrichedHeaders.nextUrl.pathname = `/maintenance`;
|
||||
return NextResponse.rewrite(reqWithEnrichedHeaders.nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const routingFormRewriteResponse = routingForms.handleRewrite(url);
|
||||
if (routingFormRewriteResponse) {
|
||||
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
|
||||
|
||||
@@ -228,16 +228,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
|
||||
: previousBooking;
|
||||
|
||||
const isPlatformBooking = eventType.users[0]?.isPlatformManaged || eventType.team?.createdByOAuthClientId;
|
||||
|
||||
return {
|
||||
props: {
|
||||
orgSlug: currentOrgDomain,
|
||||
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
|
||||
hideBranding: await shouldHideBrandingForEvent({
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
hideBranding: isPlatformBooking
|
||||
? true
|
||||
: await shouldHideBrandingForEvent({
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
profile,
|
||||
eventType,
|
||||
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
|
||||
|
||||
@@ -138,7 +138,8 @@ const OnboardingPage = (props: PageProps) => {
|
||||
"dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand:#111827] dark:[--cal-brand:#FFFFFF]",
|
||||
"[--cal-brand-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1]",
|
||||
"[--cal-brand-subtle:#9CA3AF]",
|
||||
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]"
|
||||
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]",
|
||||
"[--cal-brand-accent:#FFFFFF] dark:[--cal-brand-accent:#000000]"
|
||||
)}
|
||||
data-testid="onboarding"
|
||||
key={pathname}>
|
||||
|
||||
+490
-478
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.6.2",
|
||||
"version": "5.6.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -29,7 +29,7 @@
|
||||
"yarn": "3.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "1.42.0",
|
||||
"@boxyhq/saml-jackson": "1.52.2",
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/app-store-cli": "*",
|
||||
"@calcom/dayjs": "*",
|
||||
@@ -77,7 +77,6 @@
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"@team-plain/typescript-sdk": "^5.9.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@unkey/ratelimit": "^0.1.1",
|
||||
"@upstash/redis": "^1.35.2",
|
||||
"@vercel/edge-config": "^0.1.1",
|
||||
"@vercel/edge-functions-ui": "^0.2.1",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
|
||||
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { highPerfRouter } from "@calcom/trpc/server/routers/viewer/highPerf/_router";
|
||||
|
||||
export default wrapApiHandlerWithSentry(createNextApiHandler(highPerfRouter), "/api/trpc/highPerf/[trpc]");
|
||||
@@ -1,6 +1,11 @@
|
||||
module.exports = {
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
config.plugins.autoprefixer = {};
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -3472,5 +3472,6 @@
|
||||
"webhook_metadata": "Metadata",
|
||||
"stats": "Stats",
|
||||
"booking_status": "Booking status",
|
||||
"phone": "Phone",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -41,18 +41,6 @@
|
||||
},
|
||||
"pages/api/trpc/workflows/[trpc].ts": {
|
||||
"maxDuration": 800
|
||||
},
|
||||
"pages/api/trpc/highPerf/[trpc].ts": {
|
||||
"memory": 3008
|
||||
},
|
||||
"pages/api/trpc/appRoutingForms/[trpc].ts": {
|
||||
"memory": 2048
|
||||
},
|
||||
"pages/router/embed.tsx": {
|
||||
"memory": 2048
|
||||
},
|
||||
"pages/api/book/event.ts": {
|
||||
"memory": 2048
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,363 +0,0 @@
|
||||
import { describe, expect, it, afterEach, vi } from "vitest";
|
||||
|
||||
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("jsonLogicToPrisma(Reporting)", () => {
|
||||
describe("Text Operand", () => {
|
||||
it("should support 'Equals' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "A"] }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "A",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Not Equals' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "!=": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "abc",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Contains' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ in: ["A", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
string_contains: "A",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Not Contains' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "!": { in: ["a", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] } }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
string_contains: "a",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("Number Type", () => {
|
||||
it("should support 'greater than' operator", () => {
|
||||
let prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
">": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// Giving a string here to test that it is converted to a number
|
||||
"100",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
|
||||
});
|
||||
|
||||
prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
">": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// A number would also work
|
||||
100,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
|
||||
});
|
||||
});
|
||||
it("should support 'greater than or equal to' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
">=": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// Giving a string here to test that it is converted to a number
|
||||
"100",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gte: 100 } }],
|
||||
});
|
||||
});
|
||||
it("should support 'less than' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
"<": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// Giving a string here to test that it is converted to a number
|
||||
"100",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lt: 100 } }],
|
||||
});
|
||||
});
|
||||
it("should support 'less than or equal to' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
"<=": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// Giving a string here to test that it is converted to a number
|
||||
"100",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lte: 100 } }],
|
||||
});
|
||||
});
|
||||
it("'Equals' operator should query with string as well as number", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "1"] }] },
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MultiSelect", () => {
|
||||
it("should support 'Equals' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{ all: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, { in: [{ var: "" }, ["C", "D"]] }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
response: { path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"], array_contains: ["C", "D"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Any In' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ in: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, ["C", "D"]] }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
|
||||
equals: "C",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
|
||||
equals: "D",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Not In' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "!": { in: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, ["C", "D"]] } }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
|
||||
equals: "C",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
|
||||
equals: "D",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support where All Match ['Equals', 'Equals'] operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "a",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "b",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support where Any Match ['Equals', 'Equals'] operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
or: [
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "a",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "b",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support where None Match ['Equals', 'Equals'] operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
"!": {
|
||||
or: [
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] },
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abcd"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
NOT: {
|
||||
OR: [
|
||||
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abc" } },
|
||||
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abcd" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
// It can have many shapes, so just use any and we rely on unit tests to test all those scenarios.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type LogicData = Partial<Record<keyof typeof OPERATOR_MAP, any>>;
|
||||
type NegatedLogicData = {
|
||||
"!": LogicData;
|
||||
};
|
||||
|
||||
export type JsonLogicQuery = {
|
||||
logic: {
|
||||
and?: LogicData[];
|
||||
or?: LogicData[];
|
||||
"!"?: {
|
||||
and?: LogicData[];
|
||||
or?: LogicData[];
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PrismaWhere = {
|
||||
AND?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
|
||||
OR?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
|
||||
NOT?: PrismaWhere;
|
||||
};
|
||||
|
||||
const OPERATOR_MAP = {
|
||||
"==": {
|
||||
operator: "equals",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
in: {
|
||||
operator: "string_contains",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
"!=": {
|
||||
operator: "NOT.equals",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
"!": {
|
||||
operator: "equals",
|
||||
secondaryOperand: "",
|
||||
},
|
||||
"!!": {
|
||||
operator: "NOT.equals",
|
||||
secondaryOperand: "",
|
||||
},
|
||||
">": {
|
||||
operator: "gt",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
">=": {
|
||||
operator: "gte",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
"<": {
|
||||
operator: "lt",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
"<=": {
|
||||
operator: "lte",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
all: {
|
||||
operator: "array_contains",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Operators supported on array of basic queries
|
||||
*/
|
||||
const GROUP_OPERATOR_MAP = {
|
||||
and: "AND",
|
||||
or: "OR",
|
||||
"!": "NOT",
|
||||
} as const;
|
||||
|
||||
const NumberOperators = [">", ">=", "<", "<="];
|
||||
|
||||
const negatePrismaWhereClauseIfNeeded = (data: any, isNegation: boolean) => {
|
||||
if (!isNegation) {
|
||||
return data;
|
||||
}
|
||||
return {
|
||||
NOT: {
|
||||
...data,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const convertSingleQueryToPrismaWhereClause = (
|
||||
operatorName: keyof typeof OPERATOR_MAP,
|
||||
logicData: LogicData,
|
||||
isNegation: boolean
|
||||
) => {
|
||||
const mappedOperator = OPERATOR_MAP[operatorName].operator;
|
||||
const staticSecondaryOperand = OPERATOR_MAP[operatorName].secondaryOperand;
|
||||
isNegation = isNegation || mappedOperator.startsWith("NOT.");
|
||||
const prismaOperator = mappedOperator.replace("NOT.", "");
|
||||
const operands =
|
||||
logicData[operatorName] instanceof Array ? logicData[operatorName] : [logicData[operatorName]];
|
||||
|
||||
if (operatorName === "in" && operands[0]?.var && Array.isArray(operands[1])) {
|
||||
// Case A: Item "in" array
|
||||
// operands[0]: { var: ... }
|
||||
// operands[1]: items to test against
|
||||
|
||||
// Convert 'in' operator to union of OR clauses
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
OR: (operands[1] ?? []).map((value) => ({
|
||||
response: {
|
||||
path: [operands[0].var, "value"],
|
||||
[`${OPERATOR_MAP["=="].operator}`]: value,
|
||||
},
|
||||
})),
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
}
|
||||
|
||||
if (operatorName === "in" && typeof operands[0] === "string" && operands[1]?.var) {
|
||||
// Case B: String "in" string
|
||||
// operands[0]: string to test against
|
||||
// operands[1]: { var: ... }
|
||||
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
response: {
|
||||
path: [operands[1].var, "value"],
|
||||
[`${prismaOperator}`]: operands[0],
|
||||
},
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
}
|
||||
|
||||
const mainOperand = operands[0].var;
|
||||
let secondaryOperand;
|
||||
|
||||
if (operatorName === "all") {
|
||||
secondaryOperand = operands[1].in[1];
|
||||
} else {
|
||||
secondaryOperand = staticSecondaryOperand || operands[1] || "";
|
||||
}
|
||||
|
||||
const isNumberOperator = NumberOperators.includes(operatorName);
|
||||
const secondaryOperandAsNumber = typeof secondaryOperand === "string" ? Number(secondaryOperand) : null;
|
||||
|
||||
if (secondaryOperandAsNumber && isNumberOperator) {
|
||||
// We know that it's number operator so Prisma should query number
|
||||
// Note that if we get string values in DB(e.g. '100'), those values can't be filtered with number operators.
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
response: {
|
||||
path: [mainOperand, "value"],
|
||||
[`${prismaOperator}`]: secondaryOperandAsNumber,
|
||||
},
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
}
|
||||
|
||||
if (secondaryOperandAsNumber && !isNumberOperator) {
|
||||
// We know that it's not number operator but the input field might have been a number and thus stored value in DB as number.
|
||||
// Also, even for input type=number we might accidentally get string value(e.g. '100'). So, let reporting do it's best job with both number and string.
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: [mainOperand, "value"],
|
||||
// Query as string e.g. equals '100'
|
||||
[`${prismaOperator}`]: secondaryOperand,
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: [mainOperand, "value"],
|
||||
// Query as number e.g. equals 100
|
||||
[`${prismaOperator}`]: secondaryOperandAsNumber,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
}
|
||||
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
response: {
|
||||
path: [mainOperand, "value"],
|
||||
[`${prismaOperator}`]: secondaryOperand,
|
||||
},
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
};
|
||||
|
||||
const isNegation = (logicData: LogicData | NegatedLogicData) => {
|
||||
if ("!" in logicData) {
|
||||
const negatedLogicData = logicData["!"];
|
||||
|
||||
for (const [operatorName] of Object.entries(OPERATOR_MAP)) {
|
||||
if (negatedLogicData[operatorName]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const convertQueriesToPrismaWhereClause = (logicData: LogicData) => {
|
||||
const _isNegation = isNegation(logicData);
|
||||
if (_isNegation) {
|
||||
logicData = logicData["!"];
|
||||
}
|
||||
for (const [key] of Object.entries(OPERATOR_MAP)) {
|
||||
const operatorName = key as keyof typeof OPERATOR_MAP;
|
||||
if (logicData[operatorName]) {
|
||||
return convertSingleQueryToPrismaWhereClause(operatorName, logicData, _isNegation);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const jsonLogicToPrisma = (query: JsonLogicQuery) => {
|
||||
try {
|
||||
let logic = query.logic;
|
||||
if (!logic) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let prismaWhere: PrismaWhere = {};
|
||||
let negateLogic = false;
|
||||
|
||||
// Case: Negation of "Any of these"
|
||||
// Example: {"logic":{"!":{"or":[{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]},{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]}]}}}
|
||||
if (logic["!"]) {
|
||||
logic = logic["!"];
|
||||
negateLogic = true;
|
||||
}
|
||||
|
||||
// Case: All of these
|
||||
if (logic.and) {
|
||||
const where: PrismaWhere["AND"] = (prismaWhere[GROUP_OPERATOR_MAP["and"]] = []);
|
||||
logic.and.forEach((and) => {
|
||||
const res = convertQueriesToPrismaWhereClause(and);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
where.push(res);
|
||||
});
|
||||
}
|
||||
// Case: Any of these
|
||||
else if (logic.or) {
|
||||
const where: PrismaWhere["OR"] = (prismaWhere[GROUP_OPERATOR_MAP["or"]] = []);
|
||||
|
||||
logic.or.forEach((or) => {
|
||||
const res = convertQueriesToPrismaWhereClause(or);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
where.push(res);
|
||||
});
|
||||
}
|
||||
|
||||
if (negateLogic) {
|
||||
prismaWhere = { NOT: { ...prismaWhere } };
|
||||
}
|
||||
|
||||
return prismaWhere;
|
||||
} catch (e) {
|
||||
console.log("Error converting to prisma `where`", JSON.stringify(query), "Error is ", e);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
@@ -84,7 +84,7 @@ export function getQueryBuilderConfigForFormFields(form: Pick<RoutingForm, "fiel
|
||||
delete initialConfigCopy.operators.is_empty;
|
||||
delete initialConfigCopy.operators.is_not_empty;
|
||||
|
||||
// Between and Not between aren't directly supported by prisma. So, we need to update jsonLogicToPrisma to generate gte and lte query for between. It can be implemented later.
|
||||
// These operators can be implemented later if needed.
|
||||
delete initialConfigCopy.operators.between;
|
||||
delete initialConfigCopy.operators.not_between;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ZFormQueryInputSchema } from "./formQuery.schema";
|
||||
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
|
||||
import { ZGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
|
||||
import { forms } from "./procedures/forms";
|
||||
import { ZReportInputSchema } from "./report.schema";
|
||||
import { ZSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
@@ -76,11 +75,6 @@ const appRoutingForms = router({
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
|
||||
report: authedProcedure.input(ZReportInputSchema).query(async ({ ctx, input }) => {
|
||||
const handler = await getHandler("report", () => import("./report.handler"));
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
|
||||
getAttributesForTeam: authedProcedure
|
||||
.input(ZGetAttributesForTeamInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildResponsesForReporting } from "./report.handler";
|
||||
|
||||
describe("buildResponsesForReporting", () => {
|
||||
it("for fields with options, it should return the labels of the options", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["option1", "option2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("for fields with options having null id, it should return what DB provided(as that would be labels only).", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("for fields with no options but having array value, it should return the value as is", () => {
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 2"]);
|
||||
});
|
||||
|
||||
it("should correctly handle numeric responses converting them to strings", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: 1 },
|
||||
[field2Id]: { value: [1, 2] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const expectedResponses = [["1", "1, 2"]];
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("should handle empty responses", () => {
|
||||
const field1Id = uuidv4();
|
||||
const responsesFromDb = [{}];
|
||||
|
||||
const fields = [{ id: field1Id, label: "Field 1" }];
|
||||
|
||||
const expectedResponses = [[""]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1"]);
|
||||
});
|
||||
|
||||
it("should show correct header for deleted fields", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
deleted: true,
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
const expectedHeaders = ["Field 1", "Field 2(Deleted)"];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(expectedHeaders);
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import { ensureStringOrStringArray, getLabelsFromOptionIds } from "../lib/reportingUtils";
|
||||
import type { FormResponse } from "../types/types";
|
||||
import type { zodFieldView } from "../zod";
|
||||
import type { TReportInputSchema } from "./report.schema";
|
||||
|
||||
interface ReportHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TReportInputSchema;
|
||||
}
|
||||
|
||||
const makeFormatDate = (locale: string, timeZone: string) => {
|
||||
const formatDate = (date: Date): string => {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
timeZone,
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
};
|
||||
return formatDate;
|
||||
};
|
||||
|
||||
const getRows = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
|
||||
// Can be any prisma `where` clause
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const prismaWhere: Record<string, any> = input.jsonLogicQuery
|
||||
? jsonLogicToPrisma(input.jsonLogicQuery)
|
||||
: {};
|
||||
const skip = input.cursor ?? 0;
|
||||
const take = input.limit ? input.limit + 1 : 50;
|
||||
|
||||
logger.debug(
|
||||
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
|
||||
input.jsonLogicQuery
|
||||
)}`
|
||||
);
|
||||
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
formId: input.formId,
|
||||
...prismaWhere,
|
||||
},
|
||||
include: {
|
||||
routedToBooking: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
assignmentReason: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take,
|
||||
skip,
|
||||
});
|
||||
return { skip, take, rows };
|
||||
};
|
||||
|
||||
export const reportHandler = async (options: ReportHandlerOptions) => {
|
||||
const {
|
||||
ctx: { prisma },
|
||||
input,
|
||||
} = options;
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: input.formId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Form not found",
|
||||
});
|
||||
}
|
||||
// TODO: Second argument is required to return deleted operators.
|
||||
const serializedForm = await getSerializableForm({ form, withDeletedFields: true });
|
||||
const { rows, skip, take } = await getRows(options);
|
||||
|
||||
const fields = serializedForm?.fields || [];
|
||||
const { responses, headers } = buildResponsesForReporting({
|
||||
responsesFromDb: rows.map((r) => r.response),
|
||||
fields,
|
||||
});
|
||||
|
||||
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
|
||||
return presenter({
|
||||
rows,
|
||||
options,
|
||||
headers,
|
||||
responses,
|
||||
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a temporary solution to make the report work. It should be incorporated into the report data itself.
|
||||
* Right now we cannot filter by Routed To and Booked At because they are not part of the response data.
|
||||
*/
|
||||
function presenter(args: {
|
||||
rows: Awaited<ReturnType<typeof getRows>>["rows"];
|
||||
options: ReportHandlerOptions;
|
||||
headers: string[];
|
||||
responses: string[][];
|
||||
nextCursor: number | null;
|
||||
}) {
|
||||
const { headers, responses, nextCursor, options, rows } = args;
|
||||
const { ctx } = options;
|
||||
const formatDate = makeFormatDate(ctx.user.locale, ctx.user.timeZone);
|
||||
return {
|
||||
nextCursor,
|
||||
headers: [...headers, "Routed To", "Assignment Reason", "Booked At", "Submitted At"],
|
||||
responses: responses.map((r, i) => {
|
||||
const currentRow = rows[i];
|
||||
return [
|
||||
...r,
|
||||
currentRow.routedToBooking?.user?.email || "",
|
||||
currentRow.routedToBooking?.assignmentReason.length
|
||||
? currentRow.routedToBooking.assignmentReason[0].reasonString
|
||||
: "",
|
||||
currentRow.routedToBooking?.createdAt ? formatDate(currentRow.routedToBooking.createdAt) : "",
|
||||
formatDate(currentRow.createdAt),
|
||||
];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default reportHandler;
|
||||
|
||||
export function buildResponsesForReporting({
|
||||
responsesFromDb,
|
||||
fields,
|
||||
}: {
|
||||
responsesFromDb: App_RoutingForms_FormResponse["response"][];
|
||||
fields: Pick<z.infer<typeof zodFieldView>, "id" | "options" | "label" | "deleted">[];
|
||||
}) {
|
||||
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
|
||||
const responses: string[][] = [];
|
||||
responsesFromDb.forEach((r) => {
|
||||
const rowResponses: string[] = [];
|
||||
responses.push(rowResponses);
|
||||
fields.forEach((field) => {
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
const response = r as FormResponse;
|
||||
const value = response[field.id]?.value || "";
|
||||
if (field.options) {
|
||||
const optionIds = ensureStringOrStringArray(value);
|
||||
const labels = getLabelsFromOptionIds({ options: field.options, optionIds });
|
||||
rowResponses.push(labels.join(", "));
|
||||
} else {
|
||||
const arrayOfValues = value instanceof Array ? value : [value];
|
||||
const transformedValue = arrayOfValues.join(", ");
|
||||
rowResponses.push(transformedValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { responses, headers };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ZReportInputSchema = z.object({
|
||||
limit: z.number().default(50),
|
||||
formId: z.string(),
|
||||
jsonLogicQuery: z.object({
|
||||
logic: z.union([z.record(z.any()), z.null()]),
|
||||
}),
|
||||
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
|
||||
});
|
||||
|
||||
export type TReportInputSchema = z.infer<typeof ZReportInputSchema>;
|
||||
@@ -404,23 +404,25 @@ const BookerComponent = ({
|
||||
src={orgBannerUrl}
|
||||
/>
|
||||
)}
|
||||
<EventMeta
|
||||
selectedTimeslot={selectedTimeslot}
|
||||
classNames={{
|
||||
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
|
||||
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
|
||||
eventMetaTimezoneSelect:
|
||||
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
|
||||
}}
|
||||
event={event.data}
|
||||
isPending={event.isPending}
|
||||
isPlatform={isPlatform}
|
||||
isPrivateLink={!!hashedLink}
|
||||
locale={userLocale}
|
||||
timeZones={timeZones}
|
||||
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
|
||||
{eventMetaChildren}
|
||||
</EventMeta>
|
||||
{!hideEventTypeDetails && (
|
||||
<EventMeta
|
||||
selectedTimeslot={selectedTimeslot}
|
||||
classNames={{
|
||||
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
|
||||
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
|
||||
eventMetaTimezoneSelect:
|
||||
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
|
||||
}}
|
||||
event={event.data}
|
||||
isPending={event.isPending}
|
||||
isPlatform={isPlatform}
|
||||
isPrivateLink={!!hashedLink}
|
||||
locale={userLocale}
|
||||
timeZones={timeZones}
|
||||
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
|
||||
{eventMetaChildren}
|
||||
</EventMeta>
|
||||
)}
|
||||
{layout !== BookerLayouts.MONTH_VIEW &&
|
||||
!(layout === "mobile" && bookerState === "booking") && (
|
||||
<div className="mt-auto px-5 py-3">
|
||||
|
||||
@@ -13,6 +13,7 @@ export const getCalEventResponses = ({
|
||||
bookingFields,
|
||||
booking,
|
||||
responses,
|
||||
seatsEnabled,
|
||||
}: {
|
||||
// If the eventType has been deleted and a booking is Accepted later on, then bookingFields will be null and we can't know the label of fields. So, we should store the label as well in the DB
|
||||
// Also, it is no longer straightforward to identify if a field is system field or not
|
||||
@@ -32,6 +33,7 @@ export const getCalEventResponses = ({
|
||||
};
|
||||
}>;
|
||||
responses?: z.infer<typeof bookingResponsesDbSchema>;
|
||||
seatsEnabled?: boolean;
|
||||
}) => {
|
||||
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
|
||||
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
|
||||
@@ -61,8 +63,7 @@ export const getCalEventResponses = ({
|
||||
throw new Error(`Missing label for booking field "${field.name}"`);
|
||||
}
|
||||
|
||||
// If the guests field is hidden (disableGuests is set on the event type) don't try and infer guests from attendees list
|
||||
if (field.name == "guests" && field.hidden) {
|
||||
if (field.name == "guests" && !!seatsEnabled) {
|
||||
backwardCompatibleResponses[field.name] = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -1919,7 +1919,7 @@ async function handler(
|
||||
|
||||
if (!isDryRun) {
|
||||
sendRoundRobinRescheduledEmailsAndSMS(
|
||||
copyEventAdditionalInfo,
|
||||
{ ...copyEventAdditionalInfo, iCalUID },
|
||||
rescheduledMembers,
|
||||
eventType.metadata
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ const _getBookingData = async <T extends z.ZodType>({
|
||||
getCalEventResponses({
|
||||
bookingFields: eventType.bookingFields,
|
||||
responses,
|
||||
seatsEnabled: !!eventType.seatsPerTimeSlot,
|
||||
});
|
||||
return {
|
||||
...parsedBody,
|
||||
|
||||
@@ -30,10 +30,6 @@ const opts: JacksonOption = {
|
||||
},
|
||||
idpEnabled: true,
|
||||
clientSecretVerifier,
|
||||
ory: {
|
||||
projectId: undefined,
|
||||
sdkToken: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useId } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import type { RoutingFormTableRow } from "../lib/types";
|
||||
import { CellWithOverflowX } from "./CellWithOverflowX";
|
||||
@@ -9,21 +11,39 @@ export function BookedByCell({
|
||||
attendees,
|
||||
rowId,
|
||||
}: {
|
||||
attendees: RoutingFormTableRow["bookingAttendees"] | undefined;
|
||||
attendees: RoutingFormTableRow["bookingAttendees"];
|
||||
rowId: number;
|
||||
}) {
|
||||
const cellId = useId();
|
||||
const { t } = useLocale();
|
||||
if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />;
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[200px] flex-wrap gap-1">
|
||||
{attendees.map((attendee) => (
|
||||
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
|
||||
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}>
|
||||
{attendee.name}
|
||||
</Badge>
|
||||
</CellWithOverflowX>
|
||||
))}
|
||||
{attendees.map((attendee) => {
|
||||
const tooltipContent = (
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
{t("email")}: {attendee.email}
|
||||
</div>
|
||||
{attendee.phoneNumber && (
|
||||
<div>
|
||||
{t("phone")}: {attendee.phoneNumber}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
|
||||
<Tooltip content={tooltipContent || undefined}>
|
||||
<Badge variant="gray" className="whitespace-nowrap">
|
||||
{attendee.name}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</CellWithOverflowX>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
convertMapToFacetedValues,
|
||||
type FilterableColumn,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
|
||||
import { RoutingFormResponsesDownload } from "../../filters/Download";
|
||||
@@ -35,8 +36,7 @@ const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_R
|
||||
};
|
||||
|
||||
export function RoutingFormResponsesTable() {
|
||||
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
|
||||
useInsightsParameters();
|
||||
const { isAll, teamId, userId, routingFormId } = useInsightsParameters();
|
||||
|
||||
const { data: headers, isSuccess: isHeadersSuccess } =
|
||||
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
||||
@@ -53,22 +53,15 @@ export function RoutingFormResponsesTable() {
|
||||
isAll,
|
||||
});
|
||||
|
||||
const insightsRoutingParams = useInsightsRoutingParameters();
|
||||
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
|
||||
|
||||
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
userId,
|
||||
memberUserIds,
|
||||
isAll,
|
||||
routingFormId,
|
||||
columnFilters,
|
||||
...insightsRoutingParams,
|
||||
sorting,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
|
||||
const processedData = useMemo(() => {
|
||||
if (!isHeadersSuccess || !data) return [];
|
||||
return data.data as RoutingFormTableRow[];
|
||||
@@ -88,6 +81,9 @@ export function RoutingFormResponsesTable() {
|
||||
columnVisibility: {
|
||||
formId: false,
|
||||
bookingUserId: false,
|
||||
attendeeName: false,
|
||||
attendeeEmail: false,
|
||||
attendeePhone: false,
|
||||
utm_source: false,
|
||||
utm_medium: false,
|
||||
utm_campaign: false,
|
||||
|
||||
@@ -1,37 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
import { valueFormatter } from "../../lib";
|
||||
import { ChartCard } from "../ChartCard";
|
||||
|
||||
export const RoutingKPICards = () => {
|
||||
const { t } = useLocale();
|
||||
const { teamId, startDate, endDate, userId, memberUserIds, isAll, routingFormId, columnFilters } =
|
||||
useInsightsParameters();
|
||||
const insightsRoutingParameters = useInsightsRoutingParameters();
|
||||
|
||||
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(
|
||||
{
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
userId,
|
||||
memberUserIds,
|
||||
isAll,
|
||||
routingFormId,
|
||||
columnFilters,
|
||||
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(insightsRoutingParameters, {
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
{
|
||||
staleTime: 30000,
|
||||
trpc: {
|
||||
context: { skipBatch: true },
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const categories: {
|
||||
title: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { SortingState } from "@calcom/features/data-table";
|
||||
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
@@ -15,8 +16,6 @@ import {
|
||||
} from "@calcom/ui/components/dropdown";
|
||||
import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
||||
|
||||
type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number];
|
||||
|
||||
type Props = {
|
||||
@@ -27,9 +26,9 @@ const BATCH_SIZE = 100; // Increased batch size for downloads
|
||||
|
||||
export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
||||
const { t } = useLocale();
|
||||
const { teamId, userId, memberUserIds, routingFormId, isAll, startDate, endDate, columnFilters } =
|
||||
useInsightsParameters();
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const insightsRoutingParameters = useInsightsRoutingParameters();
|
||||
const { startDate, endDate } = insightsRoutingParameters;
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
@@ -40,14 +39,7 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
||||
total: number;
|
||||
}> => {
|
||||
const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
userId,
|
||||
memberUserIds,
|
||||
isAll: isAll,
|
||||
routingFormId,
|
||||
columnFilters,
|
||||
...insightsRoutingParameters,
|
||||
sorting,
|
||||
limit: BATCH_SIZE,
|
||||
offset,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const useInsightsBookings = () => {
|
||||
size: 200,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
},
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
|
||||
@@ -103,19 +103,53 @@ export const useInsightsColumns = ({
|
||||
size: 200,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
textOptions: {
|
||||
placeholder: t("name_or_email"),
|
||||
},
|
||||
},
|
||||
},
|
||||
cell: (info) => {
|
||||
return <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
|
||||
},
|
||||
}),
|
||||
|
||||
// Invisible attendee name filter
|
||||
columnHelper.accessor("bookingAttendees", {
|
||||
id: "attendeeName",
|
||||
header: t("attendee_name"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
|
||||
// Invisible attendee email filter
|
||||
columnHelper.accessor("bookingAttendees", {
|
||||
id: "attendeeEmail",
|
||||
header: t("attendee_email_variable"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
|
||||
// Invisible attendee phone filter
|
||||
columnHelper.accessor("bookingAttendees", {
|
||||
id: "attendeePhone",
|
||||
header: t("attendee_phone_number"),
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
filter: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
},
|
||||
},
|
||||
cell: () => null,
|
||||
}),
|
||||
|
||||
...((headers || []).map((fieldHeader) => {
|
||||
const isText = [
|
||||
RoutingFormFieldType.TEXT,
|
||||
@@ -145,19 +179,28 @@ export const useInsightsColumns = ({
|
||||
return acc;
|
||||
}, {} as Record<string, string>) ?? {};
|
||||
|
||||
return columnHelper.accessor(`response.${fieldHeader.id}`, {
|
||||
return columnHelper.accessor((row) => row.fields.find((field) => field.fieldId === fieldHeader.id), {
|
||||
id: fieldHeader.id,
|
||||
header: startCase(fieldHeader.label),
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
const values = info.getValue();
|
||||
let values;
|
||||
|
||||
if (isMultiSelect) {
|
||||
values = info.getValue()?.valueStringArray;
|
||||
} else if (isNumber) {
|
||||
values = info.getValue()?.valueNumber;
|
||||
} else {
|
||||
values = info.getValue()?.valueString;
|
||||
}
|
||||
|
||||
if (isMultiSelect || isSingleSelect) {
|
||||
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
|
||||
return (
|
||||
result.success && (
|
||||
<ResponseValueCell
|
||||
optionMap={optionMap}
|
||||
values={Array.isArray(result.data.value) ? result.data.value : [result.data.value]}
|
||||
values={Array.isArray(result.data) ? result.data : [result.data]}
|
||||
rowId={info.row.original.id}
|
||||
/>
|
||||
)
|
||||
@@ -167,7 +210,7 @@ export const useInsightsColumns = ({
|
||||
return (
|
||||
result.success && (
|
||||
<div className="truncate">
|
||||
<span title={String(result.data.value)}>{result.data.value}</span>
|
||||
<span title={String(result.data)}>{result.data}</span>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -51,7 +51,7 @@ export function useInsightsParameters() {
|
||||
}, [createdAtRange?.preset]);
|
||||
|
||||
const columnFilters = useColumnFilters({
|
||||
exclude: ["bookingUserId", "formId", "createdAt", "eventTypeId"],
|
||||
exclude: ["createdAt"],
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,25 +6,13 @@ export type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFo
|
||||
|
||||
export type HeaderRow = RouterOutputs["viewer"]["insights"]["routingFormResponsesHeaders"][number];
|
||||
|
||||
export const ZResponseMultipleValues = z.object({
|
||||
label: z.string(),
|
||||
value: z.array(z.string()),
|
||||
});
|
||||
export const ZResponseMultipleValues = z.array(z.string());
|
||||
|
||||
export const ZResponseSingleValue = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
export const ZResponseSingleValue = z.string();
|
||||
|
||||
export const ZResponseTextValue = z.object({
|
||||
label: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
export const ZResponseTextValue = z.string();
|
||||
|
||||
export const ZResponseNumericValue = z.object({
|
||||
label: z.string(),
|
||||
value: z.number(),
|
||||
});
|
||||
export const ZResponseNumericValue = z.number();
|
||||
|
||||
export const ZResponseValue = z.union([
|
||||
ZResponseMultipleValues,
|
||||
|
||||
@@ -39,9 +39,19 @@ export type RoutingFormStatsInput = {
|
||||
sorting?: Sorting[];
|
||||
};
|
||||
|
||||
export type RoutingFormResponsesInput = RoutingFormStatsInput & {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
export type InsightsRoutingServiceInput = {
|
||||
scope: "user" | "team" | "org";
|
||||
selectedTeamId?: number;
|
||||
|
||||
columnFilters?: ColumnFilter[];
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
export type InsightsRoutingServicePaginatedInput = InsightsRoutingServiceInput & {
|
||||
sorting?: Sorting[];
|
||||
offset: number;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export const routingFormStatsInputSchema = z.object({
|
||||
@@ -56,11 +66,20 @@ export const routingFormStatsInputSchema = z.object({
|
||||
sorting: z.array(ZSorting).optional(),
|
||||
}) satisfies z.ZodType<RoutingFormStatsInput>;
|
||||
|
||||
export const routingFormResponsesInputSchema = z.object({
|
||||
...routingFormStatsInputSchema.shape,
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().max(100).optional(),
|
||||
}) satisfies z.ZodType<RoutingFormResponsesInput>;
|
||||
export const insightsRoutingServiceInputSchema = z.object({
|
||||
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
|
||||
selectedTeamId: z.number().optional(),
|
||||
columnFilters: z.array(ZColumnFilter).optional(),
|
||||
startDate: z.string(),
|
||||
endDate: z.string(),
|
||||
}) satisfies z.ZodType<InsightsRoutingServiceInput>;
|
||||
|
||||
export const insightsRoutingServicePaginatedInputSchema = z.object({
|
||||
...insightsRoutingServiceInputSchema.shape,
|
||||
offset: z.number(),
|
||||
limit: z.number().max(100),
|
||||
sorting: z.array(ZSorting).optional(),
|
||||
}) satisfies z.ZodType<InsightsRoutingServicePaginatedInput>;
|
||||
|
||||
export const routingRepositoryBaseInputSchema = z.object({
|
||||
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
|
||||
|
||||
@@ -10,17 +10,10 @@ import {
|
||||
} from "@calcom/app-store/routing-forms/lib/FieldTypes";
|
||||
import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { makeWhereClause, makeOrderBy } from "@calcom/features/data-table/lib/server";
|
||||
import { type TypedColumnFilter, ColumnFilterType } from "@calcom/features/data-table/lib/types";
|
||||
import type {
|
||||
RoutingFormResponsesInput,
|
||||
RoutingFormStatsInput,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import type { InsightsRoutingBaseService } from "@calcom/lib/server/service/InsightsRoutingBaseService";
|
||||
import { readonlyPrisma as prisma } from "@calcom/prisma";
|
||||
|
||||
import { type ResponseValue, ZResponse } from "../lib/types";
|
||||
|
||||
type RoutingFormInsightsTeamFilter = {
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
@@ -29,14 +22,6 @@ type RoutingFormInsightsTeamFilter = {
|
||||
routingFormId?: string | null;
|
||||
};
|
||||
|
||||
type RoutingFormStatsFilter = RoutingFormStatsInput & {
|
||||
organizationId: number | null;
|
||||
};
|
||||
|
||||
type RoutingFormResponsesFilter = RoutingFormResponsesInput & {
|
||||
organizationId: number | null;
|
||||
};
|
||||
|
||||
type WhereForTeamOrAllTeams = Pick<Prisma.App_RoutingForms_FormWhereInput, "id" | "teamId" | "userId">;
|
||||
|
||||
class RoutingEventsInsights {
|
||||
@@ -100,57 +85,6 @@ class RoutingEventsInsights {
|
||||
return formsWhereCondition;
|
||||
}
|
||||
|
||||
static async getRoutingFormStats({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
sorting,
|
||||
}: RoutingFormStatsFilter) {
|
||||
const whereClause = await this.getWhereClauseForRoutingFormResponses({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
sorting,
|
||||
});
|
||||
|
||||
if (whereClause.bookingUid) {
|
||||
// If bookingUid filter is applied, total count should be either 0 or 1.
|
||||
// So this metrics doesn't provide any value.
|
||||
return null;
|
||||
}
|
||||
|
||||
const totalPromise = prisma.routingFormResponse.count({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
const totalWithoutBookingPromise = prisma.routingFormResponse.count({
|
||||
where: {
|
||||
...whereClause,
|
||||
bookingUid: null,
|
||||
},
|
||||
});
|
||||
|
||||
const [total, totalWithoutBooking] = await Promise.all([totalPromise, totalWithoutBookingPromise]);
|
||||
|
||||
return {
|
||||
total,
|
||||
totalWithoutBooking,
|
||||
totalWithBooking: total - totalWithoutBooking,
|
||||
};
|
||||
}
|
||||
|
||||
static async getRoutingFormsForFilters({
|
||||
userId,
|
||||
teamId,
|
||||
@@ -183,250 +117,37 @@ class RoutingEventsInsights {
|
||||
});
|
||||
}
|
||||
|
||||
static async getWhereClauseForRoutingFormResponses({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
}: RoutingFormResponsesFilter) {
|
||||
const formsTeamWhereCondition = await this.getWhereForTeamOrAllTeams({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
});
|
||||
|
||||
const getLowercasedFilterValue = <TData extends ColumnFilterType>(filter: TypedColumnFilter<TData>) => {
|
||||
if (filter.value.type === ColumnFilterType.TEXT) {
|
||||
return {
|
||||
...filter.value,
|
||||
data: {
|
||||
...filter.value.data,
|
||||
operand: filter.value.data.operand.toLowerCase(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return filter.value;
|
||||
};
|
||||
|
||||
const bookingStatusOrder = columnFilters.find((filter) => filter.id === "bookingStatusOrder");
|
||||
const bookingAssignmentReason = columnFilters.find(
|
||||
(filter) => filter.id === "bookingAssignmentReason"
|
||||
) as TypedColumnFilter<ColumnFilterType.TEXT> | undefined;
|
||||
const assignmentReasonValue = bookingAssignmentReason
|
||||
? getLowercasedFilterValue(bookingAssignmentReason)
|
||||
: undefined;
|
||||
const bookingUid = columnFilters.find((filter) => filter.id === "bookingUid") as
|
||||
| TypedColumnFilter<ColumnFilterType.TEXT>
|
||||
| undefined;
|
||||
|
||||
const responseFilters = columnFilters.filter(
|
||||
(filter) =>
|
||||
filter.id !== "bookingStatusOrder" &&
|
||||
filter.id !== "bookingAssignmentReason" &&
|
||||
filter.id !== "bookingUid"
|
||||
);
|
||||
|
||||
const whereClause: Prisma.RoutingFormResponseWhereInput = {
|
||||
...(formsTeamWhereCondition.id !== undefined && {
|
||||
formId: formsTeamWhereCondition.id as string | Prisma.StringFilter<"RoutingFormResponse">,
|
||||
}),
|
||||
...(formsTeamWhereCondition.teamId !== undefined && {
|
||||
formTeamId: formsTeamWhereCondition.teamId as number | Prisma.IntFilter<"RoutingFormResponse">,
|
||||
}),
|
||||
...(formsTeamWhereCondition.userId !== undefined && {
|
||||
formUserId: formsTeamWhereCondition.userId as number | Prisma.IntFilter<"RoutingFormResponse">,
|
||||
}),
|
||||
|
||||
// bookingStatus
|
||||
...(bookingStatusOrder &&
|
||||
makeWhereClause({ columnName: "bookingStatusOrder", filterValue: bookingStatusOrder.value })),
|
||||
|
||||
// bookingAssignmentReason
|
||||
...(assignmentReasonValue &&
|
||||
makeWhereClause({
|
||||
columnName: "bookingAssignmentReasonLowercase",
|
||||
filterValue: assignmentReasonValue,
|
||||
})),
|
||||
|
||||
// memberUserId
|
||||
...(memberUserIds && memberUserIds.length > 0 && { bookingUserId: { in: memberUserIds } }),
|
||||
|
||||
// createdAt
|
||||
...(startDate &&
|
||||
endDate && {
|
||||
createdAt: {
|
||||
gte: dayjs(startDate).startOf("day").toDate(),
|
||||
lte: dayjs(endDate).endOf("day").toDate(),
|
||||
},
|
||||
}),
|
||||
|
||||
// bookingUid
|
||||
...(bookingUid &&
|
||||
makeWhereClause({
|
||||
columnName: "bookingUid",
|
||||
filterValue: bookingUid.value,
|
||||
})),
|
||||
|
||||
// AND clause
|
||||
...(responseFilters.length > 0 && {
|
||||
AND: responseFilters.map((fieldFilter) => {
|
||||
return makeWhereClause({
|
||||
columnName: "responseLowercase",
|
||||
filterValue: getLowercasedFilterValue(fieldFilter),
|
||||
json: { path: [fieldFilter.id, "value"] },
|
||||
});
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
return whereClause;
|
||||
}
|
||||
|
||||
static async getRoutingFormPaginatedResponses({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
limit,
|
||||
offset,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
sorting,
|
||||
}: RoutingFormResponsesFilter) {
|
||||
const whereClause = await this.getWhereClauseForRoutingFormResponses({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
sorting,
|
||||
});
|
||||
|
||||
const totalResponsePromise = prisma.routingFormResponse.count({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
const responsesPromise = prisma.routingFormResponse.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
response: true,
|
||||
formId: true,
|
||||
formName: true,
|
||||
bookingUid: true,
|
||||
bookingStatus: true,
|
||||
bookingStatusOrder: true,
|
||||
bookingCreatedAt: true,
|
||||
bookingAttendees: true,
|
||||
bookingUserId: true,
|
||||
bookingUserName: true,
|
||||
bookingUserEmail: true,
|
||||
bookingUserAvatarUrl: true,
|
||||
bookingAssignmentReason: true,
|
||||
bookingStartTime: true,
|
||||
bookingEndTime: true,
|
||||
createdAt: true,
|
||||
utm_source: true,
|
||||
utm_medium: true,
|
||||
utm_campaign: true,
|
||||
utm_term: true,
|
||||
utm_content: true,
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: sorting && sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const [totalResponses, responses] = await Promise.all([totalResponsePromise, responsesPromise]);
|
||||
|
||||
type Response = Omit<
|
||||
(typeof responses)[number],
|
||||
"response" | "responseLowercase" | "bookingAttendees"
|
||||
> & {
|
||||
response: Record<string, ResponseValue>;
|
||||
responseLowercase: Record<string, ResponseValue>;
|
||||
bookingAttendees?: { name: string; email: string; timeZone: string }[];
|
||||
};
|
||||
|
||||
return {
|
||||
total: totalResponses,
|
||||
data: responses as Response[],
|
||||
};
|
||||
}
|
||||
|
||||
static async getRoutingFormPaginatedResponsesForDownload({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
limit,
|
||||
offset,
|
||||
userId,
|
||||
memberUserIds,
|
||||
columnFilters,
|
||||
sorting,
|
||||
}: RoutingFormResponsesFilter) {
|
||||
const headersPromise = this.getRoutingFormHeaders({
|
||||
userId,
|
||||
teamId,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
});
|
||||
const dataPromise = this.getRoutingFormPaginatedResponses({
|
||||
teamId,
|
||||
startDate,
|
||||
endDate,
|
||||
isAll,
|
||||
organizationId,
|
||||
routingFormId,
|
||||
userId,
|
||||
memberUserIds,
|
||||
limit,
|
||||
offset,
|
||||
columnFilters,
|
||||
sorting,
|
||||
});
|
||||
|
||||
headersPromise,
|
||||
dataPromise,
|
||||
}: {
|
||||
headersPromise: ReturnType<typeof RoutingEventsInsights.getRoutingFormHeaders>;
|
||||
dataPromise: ReturnType<typeof InsightsRoutingBaseService.prototype.getTableData>;
|
||||
}) {
|
||||
const [headers, data] = await Promise.all([headersPromise, dataPromise]);
|
||||
|
||||
const dataWithFlatResponse = data.data.map((item) => {
|
||||
const { bookingAttendees } = item;
|
||||
const responseParseResult = ZResponse.safeParse(item.response);
|
||||
const response = responseParseResult.success ? responseParseResult.data : {};
|
||||
const bookingAttendees = item.bookingAttendees || [];
|
||||
|
||||
const fields = (headers || []).reduce((acc, header) => {
|
||||
const id = header.id;
|
||||
if (!response[id]) {
|
||||
const field = item.fields.find((field) => field.fieldId === id);
|
||||
if (!field) {
|
||||
acc[header.label] = "";
|
||||
return acc;
|
||||
}
|
||||
if (header.type === "select") {
|
||||
acc[header.label] = header.options?.find((option) => option.id === response[id].value)?.label;
|
||||
} else if (header.type === "multiselect" && Array.isArray(response[id].value)) {
|
||||
acc[header.label] = (response[id].value as string[])
|
||||
acc[header.label] = header.options?.find((option) => option.id === field.valueString)?.label;
|
||||
} else if (header.type === "multiselect" && Array.isArray(field.valueStringArray)) {
|
||||
acc[header.label] = field.valueStringArray
|
||||
.map((value) => header.options?.find((option) => option.id === value)?.label)
|
||||
.filter((label): label is string => label !== undefined)
|
||||
.sort()
|
||||
.join(", ");
|
||||
} else if (header.type === "number") {
|
||||
acc[header.label] = field.valueNumber?.toString() || "";
|
||||
} else {
|
||||
acc[header.label] = response[id].value as string;
|
||||
acc[header.label] = field.valueString || "";
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, string | undefined>);
|
||||
@@ -442,9 +163,6 @@ class RoutingEventsInsights {
|
||||
"Booking Created At": item.bookingCreatedAt?.toISOString() || "",
|
||||
"Booking Start Time": item.bookingStartTime?.toISOString() || "",
|
||||
"Booking End Time": item.bookingEndTime?.toISOString() || "",
|
||||
"Attendee Name": (bookingAttendees as any)?.[0]?.name,
|
||||
"Attendee Email": (bookingAttendees as any)?.[0]?.email,
|
||||
"Attendee Timezone": (bookingAttendees as any)?.[0]?.timeZone,
|
||||
"Assignment Reason": item.bookingAssignmentReason || "",
|
||||
"Routed To Name": item.bookingUserName || "",
|
||||
"Routed To Email": item.bookingUserEmail || "",
|
||||
@@ -454,6 +172,12 @@ class RoutingEventsInsights {
|
||||
utm_campaign: item.utm_campaign || "",
|
||||
utm_term: item.utm_term || "",
|
||||
utm_content: item.utm_content || "",
|
||||
...((bookingAttendees || [])
|
||||
.filter((attendee) => typeof attendee.name === "string" && typeof attendee.email === "string")
|
||||
.reduce((acc, attendee, index) => {
|
||||
acc[`Attendee ${index + 1}`] = `${attendee.name} (${attendee.email})`;
|
||||
return acc;
|
||||
}, {} as Record<string, string>) || {}),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import { z } from "zod";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
rawDataInputSchema,
|
||||
routingFormResponsesInputSchema,
|
||||
routingFormStatsInputSchema,
|
||||
insightsRoutingServiceInputSchema,
|
||||
insightsRoutingServicePaginatedInputSchema,
|
||||
routingRepositoryBaseInputSchema,
|
||||
bookingRepositoryBaseInputSchema,
|
||||
} from "@calcom/features/insights/server/raw-data.schema";
|
||||
@@ -827,55 +827,44 @@ export const insightsRouter = router({
|
||||
});
|
||||
}),
|
||||
routingFormsByStatus: userBelongsToTeamProcedure
|
||||
.input(routingFormStatsInputSchema)
|
||||
.input(insightsRoutingServiceInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await RoutingEventsInsights.getRoutingFormStats({
|
||||
teamId: input.teamId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isAll: input.isAll,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId,
|
||||
userId: ctx.user.id,
|
||||
memberUserIds: input.memberUserIds,
|
||||
columnFilters: input.columnFilters,
|
||||
sorting: input.sorting,
|
||||
});
|
||||
const insightsRoutingService = createInsightsRoutingService(ctx, input);
|
||||
return await insightsRoutingService.getRoutingFormStats();
|
||||
}),
|
||||
routingFormResponses: userBelongsToTeamProcedure
|
||||
.input(routingFormResponsesInputSchema)
|
||||
.input(insightsRoutingServicePaginatedInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await RoutingEventsInsights.getRoutingFormPaginatedResponses({
|
||||
teamId: input.teamId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isAll: input.isAll,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId,
|
||||
userId: ctx.user.id,
|
||||
memberUserIds: input.memberUserIds,
|
||||
const insightsRoutingService = createInsightsRoutingService(ctx, input);
|
||||
return await insightsRoutingService.getTableData({
|
||||
sorting: input.sorting,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
columnFilters: input.columnFilters,
|
||||
sorting: input.sorting,
|
||||
});
|
||||
}),
|
||||
routingFormResponsesForDownload: userBelongsToTeamProcedure
|
||||
.input(routingFormResponsesInputSchema)
|
||||
.input(insightsRoutingServicePaginatedInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
|
||||
teamId: input.teamId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isAll: input.isAll,
|
||||
organizationId: ctx.user.organizationId ?? null,
|
||||
routingFormId: input.routingFormId,
|
||||
const headersPromise = RoutingEventsInsights.getRoutingFormHeaders({
|
||||
userId: ctx.user.id,
|
||||
memberUserIds: input.memberUserIds,
|
||||
limit: input.limit ?? BATCH_SIZE,
|
||||
offset: input.offset,
|
||||
columnFilters: input.columnFilters,
|
||||
teamId: input.selectedTeamId,
|
||||
isAll: input.scope === "org",
|
||||
organizationId: ctx.user.organizationId,
|
||||
routingFormId: (input.columnFilters || []).find((filter) => filter.id === "formId")?.value?.data as
|
||||
| string
|
||||
| undefined,
|
||||
});
|
||||
|
||||
const insightsRoutingService = createInsightsRoutingService(ctx, input);
|
||||
const dataPromise = insightsRoutingService.getTableData({
|
||||
sorting: input.sorting,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
});
|
||||
|
||||
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
|
||||
headersPromise,
|
||||
dataPromise,
|
||||
});
|
||||
}),
|
||||
getRoutingFormFieldOptions: userBelongsToTeamProcedure
|
||||
|
||||
@@ -136,13 +136,11 @@ export const useSchedule = ({
|
||||
eventTypeId: eventId ?? undefined,
|
||||
});
|
||||
|
||||
const schedule = isTeamEvent
|
||||
? trpc.viewer.highPerf.getTeamSchedule.useQuery(input, {
|
||||
...options,
|
||||
// Only enable if we're not using API V2
|
||||
enabled: options.enabled && !isCallingApiV2Slots,
|
||||
})
|
||||
: trpc.viewer.slots.getSchedule.useQuery(input, options);
|
||||
const schedule = trpc.viewer.slots.getSchedule.useQuery(input, {
|
||||
...options,
|
||||
// Only enable if we're not using API V2
|
||||
enabled: options.enabled && !isCallingApiV2Slots,
|
||||
});
|
||||
|
||||
if (isCallingApiV2Slots && !teamScheduleV2.failureReason) {
|
||||
updateEmbedBookerState({
|
||||
|
||||
@@ -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 = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ const user: User & { credentials: CredentialPayload[] } = {
|
||||
timeFormat: 12,
|
||||
travelSchedules: [],
|
||||
locked: false,
|
||||
isPlatformManaged: false,
|
||||
};
|
||||
|
||||
const customInputs: CustomInputSchema[] = [];
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -298,8 +298,16 @@ export class InsightsBookingBaseService {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
||||
return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`;
|
||||
if (id === "eventTypeId" && isMultiSelectFilterValue(value)) {
|
||||
const eventTypeIds = value.data.map((id) => Number(id));
|
||||
|
||||
if (eventTypeIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Prisma.sql`("eventTypeId" IN (${Prisma.join(eventTypeIds)}) OR "eventParentId" IN (${Prisma.join(
|
||||
eventTypeIds
|
||||
)}))`;
|
||||
}
|
||||
|
||||
if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@calcom/features/data-table/lib/utils";
|
||||
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { MembershipRepository } from "../repository/membership";
|
||||
@@ -51,8 +52,81 @@ export type InsightsRoutingServiceFilterOptions = {
|
||||
columnFilters?: TypedColumnFilter<ColumnFilterType>[];
|
||||
};
|
||||
|
||||
export type InsightsRoutingTableItem = {
|
||||
id: number;
|
||||
uuid: string | null;
|
||||
formId: string;
|
||||
formName: string;
|
||||
formTeamId: number | null;
|
||||
formUserId: number;
|
||||
bookingUid: string | null;
|
||||
bookingId: number | null;
|
||||
bookingStatus: BookingStatus | null;
|
||||
bookingStatusOrder: number | null;
|
||||
bookingCreatedAt: Date | null;
|
||||
bookingUserId: number | null;
|
||||
bookingUserName: string | null;
|
||||
bookingUserEmail: string | null;
|
||||
bookingUserAvatarUrl: string | null;
|
||||
bookingAssignmentReason: string | null;
|
||||
bookingStartTime: Date | null;
|
||||
bookingEndTime: Date | null;
|
||||
eventTypeId: number | null;
|
||||
eventTypeParentId: number | null;
|
||||
eventTypeSchedulingType: string | null;
|
||||
createdAt: Date;
|
||||
utm_source: string | null;
|
||||
utm_medium: string | null;
|
||||
utm_campaign: string | null;
|
||||
utm_term: string | null;
|
||||
utm_content: string | null;
|
||||
bookingAttendees: Array<{
|
||||
name: string;
|
||||
timeZone: string;
|
||||
email: string;
|
||||
phoneNumber: string | null;
|
||||
}>;
|
||||
fields: Array<{
|
||||
fieldId: string | null;
|
||||
valueString: string | null;
|
||||
valueNumber: number | null;
|
||||
valueStringArray: string[] | null;
|
||||
}>;
|
||||
};
|
||||
|
||||
const NOTHING_CONDITION = Prisma.sql`1=0`;
|
||||
|
||||
// Define allowed column names for sorting to prevent SQL injection
|
||||
const ALLOWED_SORT_COLUMNS = new Set([
|
||||
"id",
|
||||
"uuid",
|
||||
"formId",
|
||||
"formName",
|
||||
"formTeamId",
|
||||
"formUserId",
|
||||
"bookingUid",
|
||||
"bookingId",
|
||||
"bookingStatus",
|
||||
"bookingStatusOrder",
|
||||
"bookingCreatedAt",
|
||||
"bookingUserId",
|
||||
"bookingUserName",
|
||||
"bookingUserEmail",
|
||||
"bookingUserAvatarUrl",
|
||||
"bookingAssignmentReason",
|
||||
"bookingStartTime",
|
||||
"bookingEndTime",
|
||||
"eventTypeId",
|
||||
"eventTypeParentId",
|
||||
"eventTypeSchedulingType",
|
||||
"createdAt",
|
||||
"utm_source",
|
||||
"utm_medium",
|
||||
"utm_campaign",
|
||||
"utm_term",
|
||||
"utm_content",
|
||||
]);
|
||||
|
||||
export class InsightsRoutingBaseService {
|
||||
private prisma: typeof readonlyPrisma;
|
||||
private options: InsightsRoutingServiceOptions | null;
|
||||
@@ -118,7 +192,7 @@ export class InsightsRoutingBaseService {
|
||||
END as "dateRange",
|
||||
"bookingUid",
|
||||
"bookingStatus"
|
||||
FROM "RoutingFormResponseDenormalized"
|
||||
FROM "RoutingFormResponseDenormalized" rfrd
|
||||
WHERE ${baseConditions}
|
||||
)
|
||||
SELECT
|
||||
@@ -164,6 +238,166 @@ export class InsightsRoutingBaseService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns paginated table data for routing form responses.
|
||||
* @param sorting Array of sorting objects with id and desc properties
|
||||
* @param limit Number of records to return
|
||||
* @param offset Number of records to skip
|
||||
*/
|
||||
async getTableData({
|
||||
sorting,
|
||||
limit,
|
||||
offset,
|
||||
}: {
|
||||
sorting?: Array<{ id: string; desc: boolean }>;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}) {
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
// Build ORDER BY clause
|
||||
const orderByClause = this.buildOrderByClause(sorting);
|
||||
|
||||
// Get total count
|
||||
const totalCountResult = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "RoutingFormResponseDenormalized" rfrd
|
||||
WHERE ${baseConditions}
|
||||
`;
|
||||
|
||||
const totalCount = Number(totalCountResult[0]?.count || 0);
|
||||
|
||||
// Get paginated data with JSON aggregation for attendees and fields
|
||||
const data = await this.prisma.$queryRaw<Array<InsightsRoutingTableItem>>`
|
||||
SELECT
|
||||
rfrd."id",
|
||||
rfrd."uuid",
|
||||
rfrd."formId",
|
||||
rfrd."formName",
|
||||
rfrd."formTeamId",
|
||||
rfrd."formUserId",
|
||||
rfrd."bookingUid",
|
||||
rfrd."bookingId",
|
||||
UPPER(rfrd."bookingStatus"::text) as "bookingStatus",
|
||||
rfrd."bookingStatusOrder",
|
||||
rfrd."bookingCreatedAt",
|
||||
rfrd."bookingUserId",
|
||||
rfrd."bookingUserName",
|
||||
rfrd."bookingUserEmail",
|
||||
rfrd."bookingUserAvatarUrl",
|
||||
rfrd."bookingAssignmentReason",
|
||||
rfrd."bookingStartTime",
|
||||
rfrd."bookingEndTime",
|
||||
rfrd."eventTypeId",
|
||||
rfrd."eventTypeParentId",
|
||||
rfrd."eventTypeSchedulingType",
|
||||
rfrd."createdAt",
|
||||
rfrd."utm_source",
|
||||
rfrd."utm_medium",
|
||||
rfrd."utm_campaign",
|
||||
rfrd."utm_term",
|
||||
rfrd."utm_content",
|
||||
(
|
||||
SELECT COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'name', a."name",
|
||||
'timeZone', a."timeZone",
|
||||
'email', a."email",
|
||||
'phoneNumber', a."phoneNumber"
|
||||
)
|
||||
) FILTER (WHERE a."id" IS NOT NULL),
|
||||
'[]'::json
|
||||
)
|
||||
FROM "Booking" b2
|
||||
LEFT JOIN "Attendee" a ON a."bookingId" = b2."id"
|
||||
WHERE b2."uid" = rfrd."bookingUid"
|
||||
) as "bookingAttendees",
|
||||
(
|
||||
SELECT COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'fieldId', f."fieldId",
|
||||
'valueString', f."valueString",
|
||||
'valueNumber', f."valueNumber",
|
||||
'valueStringArray', f."valueStringArray"
|
||||
)
|
||||
) FILTER (WHERE f."fieldId" IS NOT NULL),
|
||||
'[]'::json
|
||||
)
|
||||
FROM "RoutingFormResponseField" f
|
||||
WHERE f."responseId" = rfrd."id"
|
||||
) as "fields"
|
||||
FROM "RoutingFormResponseDenormalized" rfrd
|
||||
WHERE ${baseConditions}
|
||||
${orderByClause}
|
||||
LIMIT ${limit}
|
||||
OFFSET ${offset}
|
||||
`;
|
||||
|
||||
return {
|
||||
total: totalCount,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
async getRoutingFormStats() {
|
||||
const baseConditions = await this.getBaseConditions();
|
||||
|
||||
// Check if bookingUid filter is applied - if so, return null as metrics don't provide value
|
||||
const bookingUid = this.filters.columnFilters?.find((filter) => filter.id === "bookingUid");
|
||||
if (bookingUid && isTextFilterValue(bookingUid.value) && bookingUid.value.data) {
|
||||
// If bookingUid filter is applied, total count should be either 0 or 1.
|
||||
// So this metrics doesn't provide any value.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const totalResult = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "RoutingFormResponseDenormalized" rfrd
|
||||
WHERE ${baseConditions}
|
||||
`;
|
||||
|
||||
// Get total without booking count
|
||||
const totalWithoutBookingResult = await this.prisma.$queryRaw<Array<{ count: bigint }>>`
|
||||
SELECT COUNT(*) as count
|
||||
FROM "RoutingFormResponseDenormalized" rfrd
|
||||
WHERE (${baseConditions}) AND ("bookingUid" IS NULL)
|
||||
`;
|
||||
|
||||
const total = Number(totalResult[0]?.count || 0);
|
||||
const totalWithoutBooking = Number(totalWithoutBookingResult[0]?.count || 0);
|
||||
|
||||
return {
|
||||
total,
|
||||
totalWithoutBooking,
|
||||
totalWithBooking: total - totalWithoutBooking,
|
||||
};
|
||||
}
|
||||
|
||||
private buildOrderByClause(sorting?: Array<{ id: string; desc: boolean }>): Prisma.Sql {
|
||||
if (!sorting || sorting.length === 0) {
|
||||
return Prisma.sql`ORDER BY "createdAt" DESC`;
|
||||
}
|
||||
|
||||
const orderByParts = sorting
|
||||
.filter((sort) => ALLOWED_SORT_COLUMNS.has(sort.id))
|
||||
.map((sort) => {
|
||||
const direction = sort.desc ? Prisma.sql`DESC` : Prisma.sql`ASC`;
|
||||
return Prisma.sql`"${Prisma.raw(sort.id)}" ${direction}`;
|
||||
});
|
||||
|
||||
if (orderByParts.length === 0) {
|
||||
return Prisma.sql`ORDER BY "createdAt" DESC`;
|
||||
}
|
||||
|
||||
return Prisma.sql`ORDER BY ${orderByParts.reduce((acc, part, index) => {
|
||||
if (index === 0) return part;
|
||||
return Prisma.sql`${acc}, ${part}`;
|
||||
})}`;
|
||||
}
|
||||
|
||||
async getBaseConditions(): Promise<Prisma.Sql> {
|
||||
const authConditions = await this.getAuthorizationConditions();
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
@@ -238,12 +472,30 @@ export class InsightsRoutingBaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract booking attendees filter
|
||||
const bookingAttendees = filtersMap["bookingAttendees"];
|
||||
if (bookingAttendees && isTextFilterValue(bookingAttendees.value)) {
|
||||
const attendeesCondition = this.buildAttendeeSqlCondition(bookingAttendees.value);
|
||||
if (attendeesCondition) {
|
||||
conditions.push(attendeesCondition);
|
||||
// Extract attendee name filter
|
||||
const attendeeName = filtersMap["attendeeName"];
|
||||
if (attendeeName && isTextFilterValue(attendeeName.value)) {
|
||||
const nameCondition = this.buildAttendeeSqlCondition(attendeeName.value, "name");
|
||||
if (nameCondition) {
|
||||
conditions.push(nameCondition);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract attendee email filter
|
||||
const attendeeEmail = filtersMap["attendeeEmail"];
|
||||
if (attendeeEmail && isTextFilterValue(attendeeEmail.value)) {
|
||||
const emailCondition = this.buildAttendeeSqlCondition(attendeeEmail.value, "email");
|
||||
if (emailCondition) {
|
||||
conditions.push(emailCondition);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract attendee phone filter
|
||||
const attendeePhone = filtersMap["attendeePhone"];
|
||||
if (attendeePhone && isTextFilterValue(attendeePhone.value)) {
|
||||
const phoneCondition = this.buildAttendeeSqlCondition(attendeePhone.value, "phone");
|
||||
if (phoneCondition) {
|
||||
conditions.push(phoneCondition);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,18 +514,9 @@ export class InsightsRoutingBaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// Extract form field filters (exclude the system filters we already processed)
|
||||
const alreadyHandledFilters = [
|
||||
"bookingStatusOrder",
|
||||
"bookingAssignmentReason",
|
||||
"bookingUid",
|
||||
"bookingAttendees",
|
||||
"bookingUserId",
|
||||
"formId",
|
||||
];
|
||||
|
||||
const fieldIdSchema = z.string().uuid();
|
||||
const fieldFilters = (this.filters.columnFilters || []).filter(
|
||||
(filter) => !alreadyHandledFilters.includes(filter.id)
|
||||
(filter) => fieldIdSchema.safeParse(filter.id).success
|
||||
);
|
||||
|
||||
if (fieldFilters.length > 0) {
|
||||
@@ -376,14 +619,14 @@ export class InsightsRoutingBaseService {
|
||||
// For multi-select fields, check if the field contains any of the selected values
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
WHERE rrf."responseId" = rfrd."id"
|
||||
AND rrf."fieldId" = ${fieldId}
|
||||
AND rrf."valueStringArray" @> ${filterValue.data.map(String)}
|
||||
)`;
|
||||
} else if (isSingleSelectFilterValue(filterValue)) {
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
WHERE rrf."responseId" = rfrd."id"
|
||||
AND rrf."fieldId" = ${fieldId}
|
||||
AND rrf."valueString" = ${filterValue.data}
|
||||
)`;
|
||||
@@ -394,7 +637,7 @@ export class InsightsRoutingBaseService {
|
||||
}
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
WHERE rrf."responseId" = rfrd."id"
|
||||
AND rrf."fieldId" = ${fieldId}
|
||||
AND rrf."valueString" ${condition}
|
||||
)`;
|
||||
@@ -405,7 +648,7 @@ export class InsightsRoutingBaseService {
|
||||
}
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
WHERE rrf."responseId" = rfrd."id"
|
||||
AND rrf."fieldId" = ${fieldId}
|
||||
AND rrf."valueNumber" ${condition}
|
||||
)`;
|
||||
@@ -414,27 +657,40 @@ export class InsightsRoutingBaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private buildAttendeeSqlCondition(filterValue: TextFilterValue): Prisma.Sql | null {
|
||||
private buildAttendeeSqlCondition(
|
||||
filterValue: TextFilterValue,
|
||||
attendeeColumn: "name" | "email" | "phone"
|
||||
): Prisma.Sql | null {
|
||||
if (!isTextFilterValue(filterValue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textCondition = makeSqlCondition(filterValue);
|
||||
|
||||
if (!textCondition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nameCondition = Prisma.sql`a.name ${textCondition}`;
|
||||
const emailCondition = Prisma.sql`a.email ${textCondition}`;
|
||||
|
||||
const combinedCondition = Prisma.sql`(${nameCondition}) OR (${emailCondition})`;
|
||||
// Use switch-case to avoid Prisma.raw for column names
|
||||
let columnCondition: Prisma.Sql;
|
||||
switch (attendeeColumn) {
|
||||
case "name":
|
||||
columnCondition = Prisma.sql`a.name ${textCondition}`;
|
||||
break;
|
||||
case "email":
|
||||
columnCondition = Prisma.sql`a.email ${textCondition}`;
|
||||
break;
|
||||
case "phone":
|
||||
columnCondition = Prisma.sql`a."phoneNumber" ${textCondition}`;
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return Prisma.sql`EXISTS (
|
||||
SELECT 1 FROM "Booking" b
|
||||
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
||||
WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
|
||||
AND (${combinedCondition})
|
||||
WHERE b."uid" = rfrd."bookingUid"
|
||||
AND ${columnCondition}
|
||||
)`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,8 +381,8 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
{
|
||||
id: "eventTypeId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.eventType.id,
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [testData.eventType.id],
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -391,7 +391,9 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual(
|
||||
Prisma.sql`("eventTypeId" = ${testData.eventType.id}) OR ("eventParentId" = ${testData.eventType.id})`
|
||||
Prisma.sql`("eventTypeId" IN (${Prisma.join([
|
||||
testData.eventType.id,
|
||||
])}) OR "eventParentId" IN (${Prisma.join([testData.eventType.id])}))`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
@@ -441,8 +443,8 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
{
|
||||
id: "eventTypeId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: testData.eventType.id,
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [testData.eventType.id],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -458,7 +460,11 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual(
|
||||
Prisma.sql`(("eventTypeId" = ${testData.eventType.id}) OR ("eventParentId" = ${testData.eventType.id})) AND ("userId" = ${testData.user.id})`
|
||||
Prisma.sql`(("eventTypeId" IN (${Prisma.join([
|
||||
testData.eventType.id,
|
||||
])}) OR "eventParentId" IN (${Prisma.join([testData.eventType.id])}))) AND ("userId" = ${
|
||||
testData.user.id
|
||||
})`
|
||||
);
|
||||
|
||||
await testData.cleanup();
|
||||
@@ -542,8 +548,8 @@ describe("InsightsBookingService Integration Tests", () => {
|
||||
{
|
||||
id: "eventTypeId",
|
||||
value: {
|
||||
type: ColumnFilterType.SINGLE_SELECT,
|
||||
data: userEventType.id,
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [userEventType.id],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { Team, User, Membership } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { randomUUID } from "crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { describe, expect, it, vi, beforeAll, afterAll } from "vitest";
|
||||
|
||||
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -830,6 +831,14 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
describe("columnFilters", () => {
|
||||
beforeAll(() => {
|
||||
vi.useFakeTimers().setSystemTime(new Date("2025-08-20T00:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it("should handle empty columnFilters", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
@@ -1044,7 +1053,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by booking attendees (text)", async () => {
|
||||
it("should filter by attendee name (text)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
@@ -1063,10 +1072,10 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "bookingAttendees",
|
||||
id: "attendeeName",
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "contains", operand: "john@example.com" },
|
||||
data: { operator: "contains", operand: "john" },
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1074,21 +1083,31 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toEqual(
|
||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND (EXISTS (
|
||||
SELECT 1 FROM "Booking" b
|
||||
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
||||
WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
|
||||
AND (${Prisma.sql`(a.name ILIKE ${`%john@example.com%`}) OR (a.email ILIKE ${`%john@example.com%`})`})
|
||||
))`
|
||||
);
|
||||
expect(filterConditions).toMatchInlineSnapshot(`
|
||||
e {
|
||||
"strings": [
|
||||
"("createdAt" >= ",
|
||||
"::timestamp AND "createdAt" <= ",
|
||||
"::timestamp) AND (EXISTS (
|
||||
SELECT 1 FROM "Booking" b
|
||||
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
||||
WHERE b."uid" = rfrd."bookingUid"
|
||||
AND a.name ILIKE ",
|
||||
"
|
||||
))",
|
||||
],
|
||||
"values": [
|
||||
"2025-08-18T00:00:00.000Z",
|
||||
"2025-08-20T00:00:00.000Z",
|
||||
"%john%",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by custom form fields (text)", async () => {
|
||||
it("should filter by attendee email (text)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
@@ -1107,7 +1126,117 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "custom-field-id",
|
||||
id: "attendeeEmail",
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "endsWith", operand: "@gmail.com" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toMatchInlineSnapshot(`
|
||||
e {
|
||||
"strings": [
|
||||
"("createdAt" >= ",
|
||||
"::timestamp AND "createdAt" <= ",
|
||||
"::timestamp) AND (EXISTS (
|
||||
SELECT 1 FROM "Booking" b
|
||||
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
||||
WHERE b."uid" = rfrd."bookingUid"
|
||||
AND a.email ILIKE ",
|
||||
"
|
||||
))",
|
||||
],
|
||||
"values": [
|
||||
"2025-08-18T00:00:00.000Z",
|
||||
"2025-08-20T00:00:00.000Z",
|
||||
"%@gmail.com",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by attendee phone number (text)", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "attendeePhone",
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "contains", operand: "000" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const filterConditions = await service.getFilterConditions();
|
||||
expect(filterConditions).toMatchInlineSnapshot(`
|
||||
e {
|
||||
"strings": [
|
||||
"("createdAt" >= ",
|
||||
"::timestamp AND "createdAt" <= ",
|
||||
"::timestamp) AND (EXISTS (
|
||||
SELECT 1 FROM "Booking" b
|
||||
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
||||
WHERE b."uid" = rfrd."bookingUid"
|
||||
AND a."phoneNumber" ILIKE ",
|
||||
"
|
||||
))",
|
||||
],
|
||||
"values": [
|
||||
"2025-08-18T00:00:00.000Z",
|
||||
"2025-08-20T00:00:00.000Z",
|
||||
"%000%",
|
||||
],
|
||||
}
|
||||
`);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter by custom form fields (text)", async () => {
|
||||
const customFieldId = uuid();
|
||||
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: undefined,
|
||||
},
|
||||
filters: {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: customFieldId,
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "equals", operand: "test value" },
|
||||
@@ -1123,8 +1252,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND (EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${"custom-field-id"}
|
||||
WHERE rrf."responseId" = rfrd."id"
|
||||
AND rrf."fieldId" = ${customFieldId}
|
||||
AND rrf."valueString" = ${"test value"}
|
||||
))`
|
||||
);
|
||||
@@ -1138,6 +1267,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const customFieldId = uuid();
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
@@ -1151,7 +1281,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
...defaultFilters,
|
||||
columnFilters: [
|
||||
{
|
||||
id: "custom-multi-field-id",
|
||||
id: customFieldId,
|
||||
value: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: ["option1", "option2"],
|
||||
@@ -1167,8 +1297,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND (EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${"custom-multi-field-id"}
|
||||
WHERE rrf."responseId" = rfrd."id"
|
||||
AND rrf."fieldId" = ${customFieldId}
|
||||
AND rrf."valueStringArray" @> ${["option1", "option2"]}
|
||||
))`
|
||||
);
|
||||
@@ -1177,6 +1307,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
});
|
||||
|
||||
it("should combine multiple filters with AND", async () => {
|
||||
const customFieldId = uuid();
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
@@ -1209,7 +1340,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "custom-field-id",
|
||||
id: customFieldId,
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "equals", operand: "test" },
|
||||
@@ -1227,8 +1358,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
"pending",
|
||||
]}))) AND ("bookingAssignmentReason" ILIKE ${`%manual%`})) AND (EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${"custom-field-id"}
|
||||
WHERE rrf."responseId" = rfrd."id"
|
||||
AND rrf."fieldId" = ${customFieldId}
|
||||
AND rrf."valueString" = ${"test"}
|
||||
))`
|
||||
);
|
||||
@@ -1330,6 +1461,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const customFieldId = uuid();
|
||||
|
||||
const defaultFilters = createDefaultFilters();
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
@@ -1350,7 +1483,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "custom-field-id", // Custom field filter
|
||||
id: customFieldId, // Custom field filter
|
||||
value: {
|
||||
type: ColumnFilterType.TEXT,
|
||||
data: { operator: "equals", operand: "test" },
|
||||
@@ -1366,8 +1499,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
||||
defaultFilters.endDate
|
||||
}::timestamp) AND ("bookingStatusOrder" = ANY(${["pending"]}))) AND (EXISTS (
|
||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
||||
AND rrf."fieldId" = ${"custom-field-id"}
|
||||
WHERE rrf."responseId" = rfrd."id"
|
||||
AND rrf."fieldId" = ${customFieldId}
|
||||
AND rrf."valueString" = ${"test"}
|
||||
))`
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import debounce from "lodash/debounce";
|
||||
import { useMemo, useEffect, useCallback, useState, useRef } from "react";
|
||||
import { useMemo, useEffect, useCallback, useState, useRef, useContext } from "react";
|
||||
import { shallow } from "zustand/shallow";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
BookerStoreProvider,
|
||||
useInitializeBookerStoreContext,
|
||||
useBookerStoreContext,
|
||||
BookerStoreContext,
|
||||
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
||||
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
|
||||
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
|
||||
import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/useLocalSet";
|
||||
import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
|
||||
import { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
|
||||
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
|
||||
@@ -38,6 +39,8 @@ import { useCalendarsBusyTimes } from "../hooks/useCalendarsBusyTimes";
|
||||
import { useConnectedCalendars } from "../hooks/useConnectedCalendars";
|
||||
import { useMe } from "../hooks/useMe";
|
||||
import { useSlots } from "../hooks/useSlots";
|
||||
import { useVerifyCode } from "../hooks/useVerifyCode";
|
||||
import { useVerifyEmail } from "../hooks/useVerifyEmail";
|
||||
import { AtomsWrapper } from "../src/components/atoms-wrapper";
|
||||
import type {
|
||||
BookerPlatformWrapperAtomPropsForIndividual,
|
||||
@@ -65,6 +68,7 @@ const BookerPlatformWrapperComponent = (
|
||||
startTime: customStartTime,
|
||||
showNoAvailabilityDialog,
|
||||
silentlyHandleCalendarFailures = false,
|
||||
hideEventMetadata = false,
|
||||
} = props;
|
||||
const layout = BookerLayouts[view];
|
||||
|
||||
@@ -87,14 +91,12 @@ const BookerPlatformWrapperComponent = (
|
||||
Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault"))
|
||||
);
|
||||
const prevStateRef = useRef<BookerStoreValues | null>(null);
|
||||
const getStateValues = useCallback(
|
||||
(state: ReturnType<typeof useBookerStore.getState>): BookerStoreValues => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(state).filter(([_, value]) => typeof value !== "function")
|
||||
) as BookerStoreValues;
|
||||
},
|
||||
[]
|
||||
);
|
||||
const bookerStoreContext = useContext(BookerStoreContext);
|
||||
const getStateValues = useCallback((state: any): BookerStoreValues => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(state).filter(([_, value]) => typeof value !== "function")
|
||||
) as BookerStoreValues;
|
||||
}, []);
|
||||
const debouncedStateChange = useMemo(() => {
|
||||
return debounce(
|
||||
(currentStateValues: BookerStoreValues, callback: (values: BookerStoreValues) => void) => {
|
||||
@@ -111,15 +113,15 @@ const BookerPlatformWrapperComponent = (
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onBookerStateChange) return;
|
||||
if (!onBookerStateChange || !bookerStoreContext) return;
|
||||
|
||||
const unsubscribe = useBookerStore.subscribe((state) => {
|
||||
const unsubscribe = bookerStoreContext.subscribe((state) => {
|
||||
const currentStateValues = getStateValues(state);
|
||||
debouncedStateChange(currentStateValues, onBookerStateChange);
|
||||
});
|
||||
|
||||
// Initial call with current state
|
||||
const initialState = getStateValues(useBookerStore.getState());
|
||||
const initialState = getStateValues(bookerStoreContext.getState());
|
||||
onBookerStateChange(initialState);
|
||||
prevStateRef.current = initialState;
|
||||
|
||||
@@ -127,7 +129,7 @@ const BookerPlatformWrapperComponent = (
|
||||
unsubscribe();
|
||||
debouncedStateChange.cancel();
|
||||
};
|
||||
}, [onBookerStateChange, getStateValues, debouncedStateChange]);
|
||||
}, [onBookerStateChange, getStateValues, debouncedStateChange, bookerStoreContext]);
|
||||
|
||||
useGetBookingForReschedule({
|
||||
uid: props.rescheduleUid ?? props.bookingUid ?? "",
|
||||
@@ -395,6 +397,23 @@ const BookerPlatformWrapperComponent = (
|
||||
handleSlotReservation,
|
||||
});
|
||||
|
||||
const verifyEmail = useVerifyEmail({
|
||||
email: bookerForm.formEmail,
|
||||
name: bookerForm.formName,
|
||||
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
|
||||
onVerifyEmail: bookerForm.beforeVerifyEmail,
|
||||
});
|
||||
|
||||
const verifyCode = useVerifyCode({
|
||||
onSuccess: () => {
|
||||
if (!bookerForm.formEmail) return;
|
||||
|
||||
verifyEmail.setVerifiedEmail(bookerForm.formEmail);
|
||||
verifyEmail.setEmailVerificationModalVisible(false);
|
||||
handleBookEvent();
|
||||
},
|
||||
});
|
||||
|
||||
const { data: connectedCalendars, isPending: fetchingConnectedCalendars } = useConnectedCalendars({
|
||||
enabled: hasSession,
|
||||
});
|
||||
@@ -553,26 +572,13 @@ const BookerPlatformWrapperComponent = (
|
||||
return;
|
||||
},
|
||||
}}
|
||||
verifyEmail={{
|
||||
isEmailVerificationModalVisible: false,
|
||||
setEmailVerificationModalVisible: () => {
|
||||
return;
|
||||
},
|
||||
setVerifiedEmail: () => {
|
||||
return;
|
||||
},
|
||||
handleVerifyEmail: () => {
|
||||
return;
|
||||
},
|
||||
renderConfirmNotVerifyEmailButtonCond: true,
|
||||
isVerificationCodeSending: false,
|
||||
}}
|
||||
verifyEmail={verifyEmail}
|
||||
bookerForm={bookerForm}
|
||||
event={event}
|
||||
schedule={schedule}
|
||||
orgBannerUrl={bannerUrl ?? event.data?.bannerUrl}
|
||||
bookerLayout={bookerLayout}
|
||||
verifyCode={undefined}
|
||||
bookerLayout={{ ...bookerLayout, hideEventTypeDetails: hideEventMetadata }}
|
||||
verifyCode={verifyCode}
|
||||
isPlatform
|
||||
hasValidLicense={true}
|
||||
isBookingDryRun={isBookingDryRun ?? routingParams?.isBookingDryRun}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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.
@@ -10,6 +10,7 @@ export const availabilityUserSelect = {
|
||||
endTime: true,
|
||||
timeFormat: true,
|
||||
defaultScheduleId: true,
|
||||
isPlatformManaged: true,
|
||||
// Relationships
|
||||
schedules: {
|
||||
select: {
|
||||
|
||||
@@ -17,7 +17,6 @@ export const ENDPOINTS = [
|
||||
"dsync",
|
||||
"eventTypes",
|
||||
"features",
|
||||
"highPerf",
|
||||
"i18n",
|
||||
"insights",
|
||||
"me",
|
||||
|
||||
+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
|
||||
|
||||
@@ -25,7 +25,6 @@ import { dsyncRouter } from "./dsync/_router";
|
||||
import { eventTypesRouter } from "./eventTypes/_router";
|
||||
import { filterSegmentsRouter } from "./filterSegments/_router";
|
||||
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
|
||||
import { highPerfRouter } from "./highPerf/_router";
|
||||
import { i18nRouter } from "./i18n/_router";
|
||||
import { meRouter } from "./me/_router";
|
||||
import { oAuthRouter } from "./oAuth/_router";
|
||||
@@ -79,7 +78,6 @@ export const viewerRouter = router({
|
||||
googleWorkspace: googleWorkspaceRouter,
|
||||
admin: adminRouter,
|
||||
attributes: attributesRouter,
|
||||
highPerf: highPerfRouter,
|
||||
routingForms: routingFormsRouter,
|
||||
credits: creditsRouter,
|
||||
ooo: oooRouter,
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { NextApiRequest } from "next";
|
||||
import { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import getIP from "@calcom/lib/getIP";
|
||||
import { hashEmail, piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
import type { TRPCContext } from "../../../createContext";
|
||||
import type { TSendVerifyEmailCodeSchema } from "./sendVerifyEmailCode.schema";
|
||||
import { hashEmail, piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
type SendVerifyEmailCode = {
|
||||
input: TSendVerifyEmailCodeSchema;
|
||||
@@ -15,18 +15,25 @@ type SendVerifyEmailCode = {
|
||||
|
||||
export const sendVerifyEmailCodeHandler = async ({ input, req }: SendVerifyEmailCode) => {
|
||||
const identifier = req ? piiHasher.hash(getIP(req as NextApiRequest)) : hashEmail(input.email);
|
||||
return sendVerifyEmailCode({ input, identifier });
|
||||
};
|
||||
|
||||
export const sendVerifyEmailCode = async ({
|
||||
input,
|
||||
identifier,
|
||||
}: {
|
||||
input: TSendVerifyEmailCodeSchema;
|
||||
identifier: string;
|
||||
}) => {
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: `emailVerifyByCode.${identifier}`,
|
||||
});
|
||||
|
||||
const email = await sendEmailVerificationByCode({
|
||||
return await sendEmailVerificationByCode({
|
||||
email: input.email,
|
||||
username: input.username,
|
||||
language: input.language,
|
||||
isVerifyingEmail: input.isVerifyingEmail,
|
||||
});
|
||||
|
||||
return email;
|
||||
};
|
||||
|
||||
@@ -10,6 +10,10 @@ type VerifyTokenOptions = {
|
||||
};
|
||||
|
||||
export const verifyCodeUnAuthenticatedHandler = async ({ input }: VerifyTokenOptions) => {
|
||||
return verifyCodeUnAuthenticated(input);
|
||||
};
|
||||
|
||||
export const verifyCodeUnAuthenticated = async (input: ZVerifyCodeInputSchema) => {
|
||||
const { email, code } = input;
|
||||
|
||||
if (!email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
|
||||
|
||||
@@ -212,6 +212,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
|
||||
startTime: booking.startTime.toISOString(),
|
||||
endTime: booking.endTime.toISOString(),
|
||||
organizer: {
|
||||
id: booking.user?.id,
|
||||
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
|
||||
name: booking.user?.name || "Nameless",
|
||||
username: booking.user?.username || undefined,
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import publicProcedure from "../../../procedures/publicProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZGetTeamScheduleInputSchema } from "./getTeamSchedule.schema";
|
||||
|
||||
type SlotsRouterHandlerCache = {
|
||||
getTeamSchedule?: typeof import("./getTeamSchedule.handler").getTeamScheduleHandler;
|
||||
};
|
||||
|
||||
/** This should be called getAvailableSlots */
|
||||
export const highPerfRouter = router({
|
||||
getTeamSchedule: publicProcedure.input(ZGetTeamScheduleInputSchema).query(async ({ input, ctx }) => {
|
||||
const { getTeamScheduleHandler } = await import("./getTeamSchedule.handler");
|
||||
|
||||
return getTeamScheduleHandler({
|
||||
ctx,
|
||||
input,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
|
||||
import { getAvailableSlotsService } from "@calcom/lib/di/containers/AvailableSlots";
|
||||
|
||||
import type { TGetTeamScheduleInputSchema } from "./getTeamSchedule.schema";
|
||||
|
||||
export type GetTeamScheduleOptions = {
|
||||
ctx?: ContextForGetSchedule;
|
||||
input: TGetTeamScheduleInputSchema;
|
||||
};
|
||||
|
||||
interface ContextForGetSchedule extends Record<string, unknown> {
|
||||
req?: (IncomingMessage & { cookies: Partial<{ [key: string]: string }> }) | undefined;
|
||||
}
|
||||
|
||||
export const getTeamScheduleHandler = async ({ ctx, input }: GetTeamScheduleOptions) => {
|
||||
const availableSlotsService = getAvailableSlotsService();
|
||||
return await availableSlotsService.getAvailableSlots({ ctx, input });
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getScheduleSchema } from "../slots/types";
|
||||
|
||||
export const ZGetTeamScheduleInputSchema = getScheduleSchema;
|
||||
|
||||
export type TGetTeamScheduleInputSchema = z.infer<typeof ZGetTeamScheduleInputSchema>;
|
||||
@@ -3,12 +3,12 @@ import { createHash } from "crypto";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { hashEmail } from "@calcom/lib/server/PiiHasher";
|
||||
import { totpRawCheck } from "@calcom/lib/totp";
|
||||
import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { hashEmail } from "@calcom/lib/server/PiiHasher";
|
||||
|
||||
type VerifyCodeOptions = {
|
||||
ctx: {
|
||||
@@ -18,9 +18,22 @@ type VerifyCodeOptions = {
|
||||
};
|
||||
|
||||
export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => {
|
||||
const { email, code } = input;
|
||||
const { user } = ctx;
|
||||
return verifyCode({
|
||||
user: ctx.user,
|
||||
email: input.email,
|
||||
code: input.code,
|
||||
});
|
||||
};
|
||||
|
||||
export const verifyCode = async ({
|
||||
user,
|
||||
email,
|
||||
code,
|
||||
}: {
|
||||
user?: Pick<NonNullable<TrpcSessionUser>, "role">;
|
||||
email?: string;
|
||||
code?: string;
|
||||
}) => {
|
||||
if (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
|
||||
|
||||
if (!IS_PRODUCTION || process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
|
||||
@@ -165,7 +165,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(function
|
||||
"w-full min-w-0 truncate border-0 bg-transparent focus:outline-none focus:ring-0",
|
||||
"text-default rounded-lg text-sm font-medium leading-none",
|
||||
"placeholder:text-muted disabled:cursor-not-allowed disabled:bg-transparent",
|
||||
addOnLeading && "pl-0.5 pr-0",
|
||||
addOnLeading && "rounded-none pl-0.5 pr-0",
|
||||
addOnSuffix && "pl-0",
|
||||
className
|
||||
)}
|
||||
|
||||
+2
-1
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user