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",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@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-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
"@calcom/prisma": "*",
|
"@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 { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
||||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.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 { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||||
import { randomString } from "test/utils/randomString";
|
import { randomString } from "test/utils/randomString";
|
||||||
@@ -27,7 +26,6 @@ import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@cal
|
|||||||
import {
|
import {
|
||||||
CancelBookingInput_2024_08_13,
|
CancelBookingInput_2024_08_13,
|
||||||
CancelSeatedBookingInput_2024_08_13,
|
CancelSeatedBookingInput_2024_08_13,
|
||||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
|
||||||
CreateSeatedBookingOutput_2024_08_13,
|
CreateSeatedBookingOutput_2024_08_13,
|
||||||
GetBookingOutput_2024_08_13,
|
GetBookingOutput_2024_08_13,
|
||||||
GetBookingsOutput_2024_08_13,
|
GetBookingsOutput_2024_08_13,
|
||||||
@@ -35,11 +33,9 @@ import {
|
|||||||
RescheduleSeatedBookingInput_2024_08_13,
|
RescheduleSeatedBookingInput_2024_08_13,
|
||||||
} from "@calcom/platform-types";
|
} from "@calcom/platform-types";
|
||||||
import { CreateBookingInput_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", () => {
|
describe("Bookings Endpoints 2024-08-13", () => {
|
||||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
|
||||||
|
|
||||||
describe("Seated bookings", () => {
|
describe("Seated bookings", () => {
|
||||||
let app: INestApplication;
|
let app: INestApplication;
|
||||||
let organization: Team;
|
let organization: Team;
|
||||||
@@ -48,7 +44,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||||
let schedulesService: SchedulesService_2024_04_15;
|
let schedulesService: SchedulesService_2024_04_15;
|
||||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
|
||||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
|
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
|
||||||
@@ -58,7 +53,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
let apiKeyString: string;
|
let apiKeyString: string;
|
||||||
|
|
||||||
let seatedEventTypeId: number;
|
let seatedEventTypeId: number;
|
||||||
const maxRecurrenceCount = 3;
|
|
||||||
|
|
||||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||||
|
|
||||||
@@ -83,7 +77,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
|
||||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||||
@@ -190,6 +183,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
absent: false,
|
absent: false,
|
||||||
seatUid,
|
seatUid,
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
|
guests: [],
|
||||||
name: body.attendee.name,
|
name: body.attendee.name,
|
||||||
...body.bookingFieldsResponses,
|
...body.bookingFieldsResponses,
|
||||||
},
|
},
|
||||||
@@ -264,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
absent: false,
|
absent: false,
|
||||||
seatUid: createdSeatedBooking.seatUid,
|
seatUid: createdSeatedBooking.seatUid,
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
|
guests: [],
|
||||||
name: createdSeatedBooking.attendees[0].name,
|
name: createdSeatedBooking.attendees[0].name,
|
||||||
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
|
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
|
||||||
},
|
},
|
||||||
@@ -278,6 +273,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
absent: false,
|
absent: false,
|
||||||
seatUid,
|
seatUid,
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
|
guests: [],
|
||||||
name: body.attendee.name,
|
name: body.attendee.name,
|
||||||
...body.bookingFieldsResponses,
|
...body.bookingFieldsResponses,
|
||||||
},
|
},
|
||||||
@@ -517,6 +513,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
absent: false,
|
absent: false,
|
||||||
seatUid,
|
seatUid,
|
||||||
bookingFieldsResponses: {
|
bookingFieldsResponses: {
|
||||||
|
guests: [],
|
||||||
name: body.attendee.name,
|
name: body.attendee.name,
|
||||||
...body.bookingFieldsResponses,
|
...body.bookingFieldsResponses,
|
||||||
},
|
},
|
||||||
|
|||||||
+76
-1
@@ -21,7 +21,12 @@ import { randomString } from "test/utils/randomString";
|
|||||||
import { withApiAuth } from "test/utils/withApiAuth";
|
import { withApiAuth } from "test/utils/withApiAuth";
|
||||||
|
|
||||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
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";
|
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||||
|
|
||||||
describe("Bookings Endpoints 2024-08-13", () => {
|
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()}`;
|
const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
|
||||||
let normalEventType: EventType;
|
let normalEventType: EventType;
|
||||||
const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
|
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 () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await withApiAuth(
|
const moduleRef = await withApiAuth(
|
||||||
@@ -105,6 +112,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
|||||||
user.id
|
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();
|
app = moduleRef.createNestApplication();
|
||||||
bootstrap(app as NestExpressApplication);
|
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 () => {
|
afterAll(async () => {
|
||||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||||
await teamRepositoryFixture.delete(organization.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`);
|
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 occurrence = recurringEventSchema.parse(eventType.recurringEvent);
|
||||||
const repeatsEvery = occurrence.interval;
|
const repeatsEvery = occurrence.interval;
|
||||||
|
|
||||||
@@ -456,7 +459,7 @@ export class InputBookingsService_2024_08_13 {
|
|||||||
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
||||||
|
|
||||||
for (let i = 0; i < repeatsTimes; i++) {
|
for (let i = 0; i < repeatsTimes; i++) {
|
||||||
const endTime = startTime.plus({ minutes: eventType.length });
|
const endTime = startTime.plus({ minutes: lengthInMinutes });
|
||||||
|
|
||||||
events.push({
|
events.push({
|
||||||
start: startTime.toISO(),
|
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 { 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 { 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 { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { Module } from "@nestjs/common";
|
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 { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||||
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
|
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
|
||||||
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.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 { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||||
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-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 { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||||
@@ -32,6 +34,7 @@ import { Module } from "@nestjs/common";
|
|||||||
AtomsRepository,
|
AtomsRepository,
|
||||||
UsersService,
|
UsersService,
|
||||||
SchedulesAtomsService,
|
SchedulesAtomsService,
|
||||||
|
VerificationAtomsService,
|
||||||
RedisService,
|
RedisService,
|
||||||
],
|
],
|
||||||
exports: [EventTypesAtomService],
|
exports: [EventTypesAtomService],
|
||||||
@@ -40,6 +43,7 @@ import { Module } from "@nestjs/common";
|
|||||||
AtomsEventTypesController,
|
AtomsEventTypesController,
|
||||||
AtomsConferencingAppsController,
|
AtomsConferencingAppsController,
|
||||||
AtomsSchedulesController,
|
AtomsSchedulesController,
|
||||||
|
AtomsVerificationController,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AtomsModule {}
|
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 { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
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 { generateMeetingMetadata } from "app/_utils";
|
||||||
import { cookies, headers } from "next/headers";
|
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 LegacyPage from "~/team/type-view";
|
||||||
import type { PageProps as LegacyPageProps } 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 featuresRepository = new FeaturesRepository(prisma);
|
||||||
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
const isTeamFeatureEnabled = await featuresRepository.checkIfTeamHasFeature(
|
||||||
|
teamId,
|
||||||
"team-booking-page-cache"
|
"team-booking-page-cache"
|
||||||
);
|
);
|
||||||
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true";
|
return isTeamFeatureEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||||
if (await isCachedTeamBookingEnabled(await searchParams)) {
|
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
|
||||||
return await generateCachedMetadata({ params, searchParams });
|
return await generateCachedMetadata({ params, searchParams });
|
||||||
}
|
}
|
||||||
|
|
||||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||||
const props = await getData(legacyCtx);
|
const props = await getData(legacyCtx);
|
||||||
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
|
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
|
||||||
@@ -68,7 +82,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
|||||||
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
|
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
|
||||||
|
|
||||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||||
if (await isCachedTeamBookingEnabled(await searchParams)) {
|
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
|
||||||
return await CachedTeamBooker({ params, 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({
|
const result = paramsSchema.safeParse({
|
||||||
slug: params?.slug,
|
slug: params?.slug,
|
||||||
type: params?.type,
|
type: params?.type,
|
||||||
@@ -74,7 +74,7 @@ const _getMultipleDurationValue = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
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);
|
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
|
||||||
if (!teamData) return {}; // should never happen
|
if (!teamData) return {}; // should never happen
|
||||||
@@ -123,7 +123,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const CachedTeamBooker = 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);
|
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||||
|
|
||||||
// Handle org redirects
|
// Handle org redirects
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
|
|||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
|
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||||
|
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
import type { SchedulingType } from "@calcom/prisma/enums";
|
import type { SchedulingType } from "@calcom/prisma/enums";
|
||||||
@@ -158,3 +159,14 @@ export async function getCRMData(
|
|||||||
crmRecordId,
|
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,6 +3,7 @@
|
|||||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||||
import * as Sentry from "@sentry/nextjs";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
|
||||||
|
|
||||||
@@ -40,5 +41,10 @@ Sentry.init({
|
|||||||
return event;
|
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";
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
|
||||||
export async function register() {
|
export async function register() {
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
await import("./sentry.server.config");
|
await import("./sentry.server.config");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
|
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
|
||||||
await import("./sentry.edge.config");
|
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,
|
darkBrandColor: true,
|
||||||
email: true,
|
email: true,
|
||||||
timeZone: true,
|
timeZone: true,
|
||||||
|
isPlatformManaged: true,
|
||||||
};
|
};
|
||||||
const eventType = await prisma.eventType.findUnique({
|
const eventType = await prisma.eventType.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -75,6 +76,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
|||||||
hideBranding: true,
|
hideBranding: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
createdByOAuthClientId: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
workflows: {
|
workflows: {
|
||||||
|
|||||||
@@ -60,22 +60,6 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
|||||||
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
|
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
|
||||||
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
|
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);
|
const routingFormRewriteResponse = routingForms.handleRewrite(url);
|
||||||
if (routingFormRewriteResponse) {
|
if (routingFormRewriteResponse) {
|
||||||
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
|
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
|
||||||
|
|||||||
@@ -228,11 +228,15 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
|
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
|
||||||
: previousBooking;
|
: previousBooking;
|
||||||
|
|
||||||
|
const isPlatformBooking = eventType.users[0]?.isPlatformManaged || eventType.team?.createdByOAuthClientId;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
orgSlug: currentOrgDomain,
|
orgSlug: currentOrgDomain,
|
||||||
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
|
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
|
||||||
hideBranding: await shouldHideBrandingForEvent({
|
hideBranding: isPlatformBooking
|
||||||
|
? true
|
||||||
|
: await shouldHideBrandingForEvent({
|
||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
team: eventType.team,
|
team: eventType.team,
|
||||||
owner: eventType.users[0] ?? null,
|
owner: eventType.users[0] ?? null,
|
||||||
|
|||||||
@@ -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]",
|
"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-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1]",
|
||||||
"[--cal-brand-subtle:#9CA3AF]",
|
"[--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"
|
data-testid="onboarding"
|
||||||
key={pathname}>
|
key={pathname}>
|
||||||
|
|||||||
+14
-2
@@ -170,7 +170,18 @@ const orgDomainMatcherConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** @type {import("next").NextConfig} */
|
/** @type {import("next").NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = (phase) => {
|
||||||
|
if (isOrganizationsEnabled) {
|
||||||
|
// We want to log the phase here because it is important that the rewrite is added during the build phase(phase=phase-production-build)
|
||||||
|
console.log(
|
||||||
|
`[Phase: ${phase}] Adding rewrite config for organizations - orgHostPath: ${nextJsOrgRewriteConfig.orgHostPath}, orgSlug: ${nextJsOrgRewriteConfig.orgSlug}, disableRootPathRewrite: ${nextJsOrgRewriteConfig.disableRootPathRewrite}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`[Phase: ${phase}] Skipping rewrite config for organizations because ORGANIZATIONS_ENABLED is not set`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
output: process.env.BUILD_STANDALONE === "true" ? "standalone" : undefined,
|
output: process.env.BUILD_STANDALONE === "true" ? "standalone" : undefined,
|
||||||
serverExternalPackages: [
|
serverExternalPackages: [
|
||||||
"deasync",
|
"deasync",
|
||||||
@@ -678,5 +689,6 @@ const nextConfig = {
|
|||||||
return redirects;
|
return redirects;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = () => plugins.reduce((acc, next) => next(acc), nextConfig);
|
module.exports = (phase) => plugins.reduce((acc, next) => next(acc), nextConfig(phase));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.6.2",
|
"version": "5.6.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"yarn": "3.4.1"
|
"yarn": "3.4.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@boxyhq/saml-jackson": "1.42.0",
|
"@boxyhq/saml-jackson": "1.52.2",
|
||||||
"@calcom/app-store": "*",
|
"@calcom/app-store": "*",
|
||||||
"@calcom/app-store-cli": "*",
|
"@calcom/app-store-cli": "*",
|
||||||
"@calcom/dayjs": "*",
|
"@calcom/dayjs": "*",
|
||||||
@@ -77,7 +77,6 @@
|
|||||||
"@tanstack/react-query": "^5.17.15",
|
"@tanstack/react-query": "^5.17.15",
|
||||||
"@team-plain/typescript-sdk": "^5.9.0",
|
"@team-plain/typescript-sdk": "^5.9.0",
|
||||||
"@types/turndown": "^5.0.1",
|
"@types/turndown": "^5.0.1",
|
||||||
"@unkey/ratelimit": "^0.1.1",
|
|
||||||
"@upstash/redis": "^1.35.2",
|
"@upstash/redis": "^1.35.2",
|
||||||
"@vercel/edge-config": "^0.1.1",
|
"@vercel/edge-config": "^0.1.1",
|
||||||
"@vercel/edge-functions-ui": "^0.2.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: {
|
plugins: {
|
||||||
tailwindcss: {},
|
tailwindcss: {},
|
||||||
autoprefixer: {},
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === "production") {
|
||||||
|
config.plugins.autoprefixer = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
|
|||||||
@@ -3472,5 +3472,6 @@
|
|||||||
"webhook_metadata": "Metadata",
|
"webhook_metadata": "Metadata",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"booking_status": "Booking status",
|
"booking_status": "Booking status",
|
||||||
|
"phone": "Phone",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"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": {
|
"pages/api/trpc/workflows/[trpc].ts": {
|
||||||
"maxDuration": 800
|
"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. |
|
| 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. |
|
| 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 |
|
| 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
|
## Styling
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,6 @@
|
|||||||
"@daily-co/daily-js": "^0.76.0",
|
"@daily-co/daily-js": "^0.76.0",
|
||||||
"@evyweb/ioctopus": "^1.2.0",
|
"@evyweb/ioctopus": "^1.2.0",
|
||||||
"@next/third-parties": "^14.2.5",
|
"@next/third-parties": "^14.2.5",
|
||||||
"@unkey/ratelimit": "^2.1.2",
|
|
||||||
"@vercel/functions": "^1.4.0",
|
"@vercel/functions": "^1.4.0",
|
||||||
"city-timezones": "^1.2.1",
|
"city-timezones": "^1.2.1",
|
||||||
"date-fns-tz": "^3.2.0",
|
"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 { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
|
||||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||||
import { uniqueBy } from "@calcom/lib/array";
|
import { uniqueBy } from "@calcom/lib/array";
|
||||||
|
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||||
@@ -85,10 +86,16 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
const selectedHostDestinationCalendar = event.destinationCalendar?.find(
|
const selectedHostDestinationCalendar = event.destinationCalendar?.find(
|
||||||
(cal) => cal.credentialId === this.credential.id
|
(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 }) => ({
|
const eventAttendees = event.attendees.map(({ id: _id, ...rest }) => ({
|
||||||
...rest,
|
...rest,
|
||||||
responseStatus: "accepted",
|
responseStatus: "accepted",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const attendees: calendar_v3.Schema$EventAttendee[] = [
|
const attendees: calendar_v3.Schema$EventAttendee[] = [
|
||||||
{
|
{
|
||||||
...event.organizer,
|
...event.organizer,
|
||||||
@@ -100,7 +107,7 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
// We use || instead of ?? here to handle empty strings
|
// We use || instead of ?? here to handle empty strings
|
||||||
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
|
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
|
||||||
},
|
},
|
||||||
...eventAttendees,
|
...(event.hideOrganizerEmail && !isOrganizerExempt ? [] : eventAttendees),
|
||||||
];
|
];
|
||||||
|
|
||||||
if (event.team?.members) {
|
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_empty;
|
||||||
delete initialConfigCopy.operators.is_not_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.between;
|
||||||
delete initialConfigCopy.operators.not_between;
|
delete initialConfigCopy.operators.not_between;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { ZFormQueryInputSchema } from "./formQuery.schema";
|
|||||||
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
|
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
|
||||||
import { ZGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
|
import { ZGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
|
||||||
import { forms } from "./procedures/forms";
|
import { forms } from "./procedures/forms";
|
||||||
import { ZReportInputSchema } from "./report.schema";
|
|
||||||
import { ZSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema";
|
import { ZSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema";
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||||
@@ -76,11 +75,6 @@ const appRoutingForms = router({
|
|||||||
return handler({ ctx, input });
|
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
|
getAttributesForTeam: authedProcedure
|
||||||
.input(ZGetAttributesForTeamInputSchema)
|
.input(ZGetAttributesForTeamInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.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,6 +404,7 @@ const BookerComponent = ({
|
|||||||
src={orgBannerUrl}
|
src={orgBannerUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{!hideEventTypeDetails && (
|
||||||
<EventMeta
|
<EventMeta
|
||||||
selectedTimeslot={selectedTimeslot}
|
selectedTimeslot={selectedTimeslot}
|
||||||
classNames={{
|
classNames={{
|
||||||
@@ -421,6 +422,7 @@ const BookerComponent = ({
|
|||||||
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
|
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
|
||||||
{eventMetaChildren}
|
{eventMetaChildren}
|
||||||
</EventMeta>
|
</EventMeta>
|
||||||
|
)}
|
||||||
{layout !== BookerLayouts.MONTH_VIEW &&
|
{layout !== BookerLayouts.MONTH_VIEW &&
|
||||||
!(layout === "mobile" && bookerState === "booking") && (
|
!(layout === "mobile" && bookerState === "booking") && (
|
||||||
<div className="mt-auto px-5 py-3">
|
<div className="mt-auto px-5 py-3">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const getCalEventResponses = ({
|
|||||||
bookingFields,
|
bookingFields,
|
||||||
booking,
|
booking,
|
||||||
responses,
|
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
|
// 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
|
// 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>;
|
responses?: z.infer<typeof bookingResponsesDbSchema>;
|
||||||
|
seatsEnabled?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
|
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
|
||||||
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
|
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
|
||||||
@@ -61,8 +63,7 @@ export const getCalEventResponses = ({
|
|||||||
throw new Error(`Missing label for booking field "${field.name}"`);
|
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" && !!seatsEnabled) {
|
||||||
if (field.name == "guests" && field.hidden) {
|
|
||||||
backwardCompatibleResponses[field.name] = [];
|
backwardCompatibleResponses[field.name] = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1919,7 +1919,7 @@ async function handler(
|
|||||||
|
|
||||||
if (!isDryRun) {
|
if (!isDryRun) {
|
||||||
sendRoundRobinRescheduledEmailsAndSMS(
|
sendRoundRobinRescheduledEmailsAndSMS(
|
||||||
copyEventAdditionalInfo,
|
{ ...copyEventAdditionalInfo, iCalUID },
|
||||||
rescheduledMembers,
|
rescheduledMembers,
|
||||||
eventType.metadata
|
eventType.metadata
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ const _getBookingData = async <T extends z.ZodType>({
|
|||||||
getCalEventResponses({
|
getCalEventResponses({
|
||||||
bookingFields: eventType.bookingFields,
|
bookingFields: eventType.bookingFields,
|
||||||
responses,
|
responses,
|
||||||
|
seatsEnabled: !!eventType.seatsPerTimeSlot,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...parsedBody,
|
...parsedBody,
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ const opts: JacksonOption = {
|
|||||||
},
|
},
|
||||||
idpEnabled: true,
|
idpEnabled: true,
|
||||||
clientSecretVerifier,
|
clientSecretVerifier,
|
||||||
ory: {
|
|
||||||
projectId: undefined,
|
|
||||||
sdkToken: undefined,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useId } from "react";
|
import { useId } from "react";
|
||||||
|
|
||||||
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
import type { RoutingFormTableRow } from "../lib/types";
|
import type { RoutingFormTableRow } from "../lib/types";
|
||||||
import { CellWithOverflowX } from "./CellWithOverflowX";
|
import { CellWithOverflowX } from "./CellWithOverflowX";
|
||||||
@@ -9,21 +11,39 @@ export function BookedByCell({
|
|||||||
attendees,
|
attendees,
|
||||||
rowId,
|
rowId,
|
||||||
}: {
|
}: {
|
||||||
attendees: RoutingFormTableRow["bookingAttendees"] | undefined;
|
attendees: RoutingFormTableRow["bookingAttendees"];
|
||||||
rowId: number;
|
rowId: number;
|
||||||
}) {
|
}) {
|
||||||
const cellId = useId();
|
const cellId = useId();
|
||||||
|
const { t } = useLocale();
|
||||||
if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />;
|
if (!attendees || attendees.length === 0) return <div className="min-w-[200px]" />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-[200px] flex-wrap gap-1">
|
<div className="flex min-w-[200px] flex-wrap gap-1">
|
||||||
{attendees.map((attendee) => (
|
{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]">
|
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
|
||||||
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}>
|
<Tooltip content={tooltipContent || undefined}>
|
||||||
|
<Badge variant="gray" className="whitespace-nowrap">
|
||||||
{attendee.name}
|
{attendee.name}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
</CellWithOverflowX>
|
</CellWithOverflowX>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
convertMapToFacetedValues,
|
convertMapToFacetedValues,
|
||||||
type FilterableColumn,
|
type FilterableColumn,
|
||||||
} from "@calcom/features/data-table";
|
} from "@calcom/features/data-table";
|
||||||
|
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
|
|
||||||
import { RoutingFormResponsesDownload } from "../../filters/Download";
|
import { RoutingFormResponsesDownload } from "../../filters/Download";
|
||||||
@@ -35,8 +36,7 @@ const createdAtColumn: Extract<FilterableColumn, { type: ColumnFilterType.DATE_R
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function RoutingFormResponsesTable() {
|
export function RoutingFormResponsesTable() {
|
||||||
const { isAll, teamId, userId, memberUserIds, routingFormId, startDate, endDate, columnFilters } =
|
const { isAll, teamId, userId, routingFormId } = useInsightsParameters();
|
||||||
useInsightsParameters();
|
|
||||||
|
|
||||||
const { data: headers, isSuccess: isHeadersSuccess } =
|
const { data: headers, isSuccess: isHeadersSuccess } =
|
||||||
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
trpc.viewer.insights.routingFormResponsesHeaders.useQuery({
|
||||||
@@ -53,22 +53,15 @@ export function RoutingFormResponsesTable() {
|
|||||||
isAll,
|
isAll,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const insightsRoutingParams = useInsightsRoutingParameters();
|
||||||
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
|
const { sorting, limit, offset, ctaContainerRef, updateFilter } = useDataTable();
|
||||||
|
|
||||||
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
|
const { data, isPending } = trpc.viewer.insights.routingFormResponses.useQuery({
|
||||||
teamId,
|
...insightsRoutingParams,
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
userId,
|
|
||||||
memberUserIds,
|
|
||||||
isAll,
|
|
||||||
routingFormId,
|
|
||||||
columnFilters,
|
|
||||||
sorting,
|
sorting,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
});
|
});
|
||||||
|
|
||||||
const processedData = useMemo(() => {
|
const processedData = useMemo(() => {
|
||||||
if (!isHeadersSuccess || !data) return [];
|
if (!isHeadersSuccess || !data) return [];
|
||||||
return data.data as RoutingFormTableRow[];
|
return data.data as RoutingFormTableRow[];
|
||||||
@@ -88,6 +81,9 @@ export function RoutingFormResponsesTable() {
|
|||||||
columnVisibility: {
|
columnVisibility: {
|
||||||
formId: false,
|
formId: false,
|
||||||
bookingUserId: false,
|
bookingUserId: false,
|
||||||
|
attendeeName: false,
|
||||||
|
attendeeEmail: false,
|
||||||
|
attendeePhone: false,
|
||||||
utm_source: false,
|
utm_source: false,
|
||||||
utm_medium: false,
|
utm_medium: false,
|
||||||
utm_campaign: false,
|
utm_campaign: false,
|
||||||
|
|||||||
@@ -1,37 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
import { SkeletonText } from "@calcom/ui/components/skeleton";
|
||||||
|
|
||||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
|
||||||
import { valueFormatter } from "../../lib";
|
import { valueFormatter } from "../../lib";
|
||||||
import { ChartCard } from "../ChartCard";
|
import { ChartCard } from "../ChartCard";
|
||||||
|
|
||||||
export const RoutingKPICards = () => {
|
export const RoutingKPICards = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { teamId, startDate, endDate, userId, memberUserIds, isAll, routingFormId, columnFilters } =
|
const insightsRoutingParameters = useInsightsRoutingParameters();
|
||||||
useInsightsParameters();
|
|
||||||
|
|
||||||
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(
|
const { data, isPending } = trpc.viewer.insights.routingFormsByStatus.useQuery(insightsRoutingParameters, {
|
||||||
{
|
|
||||||
teamId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
userId,
|
|
||||||
memberUserIds,
|
|
||||||
isAll,
|
|
||||||
routingFormId,
|
|
||||||
columnFilters,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
trpc: {
|
trpc: {
|
||||||
context: { skipBatch: true },
|
context: { skipBatch: true },
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const categories: {
|
const categories: {
|
||||||
title: string;
|
title: string;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
|||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import type { SortingState } from "@calcom/features/data-table";
|
import type { SortingState } from "@calcom/features/data-table";
|
||||||
|
import { useInsightsRoutingParameters } from "@calcom/features/insights/hooks/useInsightsRoutingParameters";
|
||||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
@@ -15,8 +16,6 @@ import {
|
|||||||
} from "@calcom/ui/components/dropdown";
|
} from "@calcom/ui/components/dropdown";
|
||||||
import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast";
|
import { showToast, showProgressToast, hideProgressToast } from "@calcom/ui/components/toast";
|
||||||
|
|
||||||
import { useInsightsParameters } from "../../hooks/useInsightsParameters";
|
|
||||||
|
|
||||||
type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number];
|
type RoutingData = RouterOutputs["viewer"]["insights"]["routingFormResponsesForDownload"]["data"][number];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -27,9 +26,9 @@ const BATCH_SIZE = 100; // Increased batch size for downloads
|
|||||||
|
|
||||||
export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { teamId, userId, memberUserIds, routingFormId, isAll, startDate, endDate, columnFilters } =
|
|
||||||
useInsightsParameters();
|
|
||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const insightsRoutingParameters = useInsightsRoutingParameters();
|
||||||
|
const { startDate, endDate } = insightsRoutingParameters;
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
@@ -40,14 +39,7 @@ export const RoutingFormResponsesDownload = ({ sorting }: Props) => {
|
|||||||
total: number;
|
total: number;
|
||||||
}> => {
|
}> => {
|
||||||
const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
|
const result = await utils.viewer.insights.routingFormResponsesForDownload.fetch({
|
||||||
teamId,
|
...insightsRoutingParameters,
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
userId,
|
|
||||||
memberUserIds,
|
|
||||||
isAll: isAll,
|
|
||||||
routingFormId,
|
|
||||||
columnFilters,
|
|
||||||
sorting,
|
sorting,
|
||||||
limit: BATCH_SIZE,
|
limit: BATCH_SIZE,
|
||||||
offset,
|
offset,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export const useInsightsBookings = () => {
|
|||||||
size: 200,
|
size: 200,
|
||||||
meta: {
|
meta: {
|
||||||
filter: {
|
filter: {
|
||||||
type: ColumnFilterType.SINGLE_SELECT,
|
type: ColumnFilterType.MULTI_SELECT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
enableColumnFilter: true,
|
enableColumnFilter: true,
|
||||||
|
|||||||
@@ -103,19 +103,53 @@ export const useInsightsColumns = ({
|
|||||||
size: 200,
|
size: 200,
|
||||||
enableColumnFilter: false,
|
enableColumnFilter: false,
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
meta: {
|
|
||||||
filter: {
|
|
||||||
type: ColumnFilterType.TEXT,
|
|
||||||
textOptions: {
|
|
||||||
placeholder: t("name_or_email"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cell: (info) => {
|
cell: (info) => {
|
||||||
return <BookedByCell attendees={info.getValue()} rowId={info.row.original.id} />;
|
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) => {
|
...((headers || []).map((fieldHeader) => {
|
||||||
const isText = [
|
const isText = [
|
||||||
RoutingFormFieldType.TEXT,
|
RoutingFormFieldType.TEXT,
|
||||||
@@ -145,19 +179,28 @@ export const useInsightsColumns = ({
|
|||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, string>) ?? {};
|
}, {} 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,
|
id: fieldHeader.id,
|
||||||
header: startCase(fieldHeader.label),
|
header: startCase(fieldHeader.label),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
cell: (info) => {
|
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) {
|
if (isMultiSelect || isSingleSelect) {
|
||||||
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
|
const result = z.union([ZResponseMultipleValues, ZResponseSingleValue]).safeParse(values);
|
||||||
return (
|
return (
|
||||||
result.success && (
|
result.success && (
|
||||||
<ResponseValueCell
|
<ResponseValueCell
|
||||||
optionMap={optionMap}
|
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}
|
rowId={info.row.original.id}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -167,7 +210,7 @@ export const useInsightsColumns = ({
|
|||||||
return (
|
return (
|
||||||
result.success && (
|
result.success && (
|
||||||
<div className="truncate">
|
<div className="truncate">
|
||||||
<span title={String(result.data.value)}>{result.data.value}</span>
|
<span title={String(result.data)}>{result.data}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function useInsightsParameters() {
|
|||||||
}, [createdAtRange?.preset]);
|
}, [createdAtRange?.preset]);
|
||||||
|
|
||||||
const columnFilters = useColumnFilters({
|
const columnFilters = useColumnFilters({
|
||||||
exclude: ["bookingUserId", "formId", "createdAt", "eventTypeId"],
|
exclude: ["createdAt"],
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,25 +6,13 @@ export type RoutingFormTableRow = RouterOutputs["viewer"]["insights"]["routingFo
|
|||||||
|
|
||||||
export type HeaderRow = RouterOutputs["viewer"]["insights"]["routingFormResponsesHeaders"][number];
|
export type HeaderRow = RouterOutputs["viewer"]["insights"]["routingFormResponsesHeaders"][number];
|
||||||
|
|
||||||
export const ZResponseMultipleValues = z.object({
|
export const ZResponseMultipleValues = z.array(z.string());
|
||||||
label: z.string(),
|
|
||||||
value: z.array(z.string()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZResponseSingleValue = z.object({
|
export const ZResponseSingleValue = z.string();
|
||||||
label: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZResponseTextValue = z.object({
|
export const ZResponseTextValue = z.string();
|
||||||
label: z.string(),
|
|
||||||
value: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZResponseNumericValue = z.object({
|
export const ZResponseNumericValue = z.number();
|
||||||
label: z.string(),
|
|
||||||
value: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ZResponseValue = z.union([
|
export const ZResponseValue = z.union([
|
||||||
ZResponseMultipleValues,
|
ZResponseMultipleValues,
|
||||||
|
|||||||
@@ -39,9 +39,19 @@ export type RoutingFormStatsInput = {
|
|||||||
sorting?: Sorting[];
|
sorting?: Sorting[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RoutingFormResponsesInput = RoutingFormStatsInput & {
|
export type InsightsRoutingServiceInput = {
|
||||||
offset?: number;
|
scope: "user" | "team" | "org";
|
||||||
limit?: number;
|
selectedTeamId?: number;
|
||||||
|
|
||||||
|
columnFilters?: ColumnFilter[];
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InsightsRoutingServicePaginatedInput = InsightsRoutingServiceInput & {
|
||||||
|
sorting?: Sorting[];
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const routingFormStatsInputSchema = z.object({
|
export const routingFormStatsInputSchema = z.object({
|
||||||
@@ -56,11 +66,20 @@ export const routingFormStatsInputSchema = z.object({
|
|||||||
sorting: z.array(ZSorting).optional(),
|
sorting: z.array(ZSorting).optional(),
|
||||||
}) satisfies z.ZodType<RoutingFormStatsInput>;
|
}) satisfies z.ZodType<RoutingFormStatsInput>;
|
||||||
|
|
||||||
export const routingFormResponsesInputSchema = z.object({
|
export const insightsRoutingServiceInputSchema = z.object({
|
||||||
...routingFormStatsInputSchema.shape,
|
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
|
||||||
offset: z.number().optional(),
|
selectedTeamId: z.number().optional(),
|
||||||
limit: z.number().max(100).optional(),
|
columnFilters: z.array(ZColumnFilter).optional(),
|
||||||
}) satisfies z.ZodType<RoutingFormResponsesInput>;
|
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({
|
export const routingRepositoryBaseInputSchema = z.object({
|
||||||
scope: z.union([z.literal("user"), z.literal("team"), z.literal("org")]),
|
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";
|
} from "@calcom/app-store/routing-forms/lib/FieldTypes";
|
||||||
import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod";
|
import { zodFields as routingFormFieldsSchema } from "@calcom/app-store/routing-forms/zod";
|
||||||
import dayjs from "@calcom/dayjs";
|
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 { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
import type { InsightsRoutingBaseService } from "@calcom/lib/server/service/InsightsRoutingBaseService";
|
||||||
import { readonlyPrisma as prisma } from "@calcom/prisma";
|
import { readonlyPrisma as prisma } from "@calcom/prisma";
|
||||||
|
|
||||||
import { type ResponseValue, ZResponse } from "../lib/types";
|
|
||||||
|
|
||||||
type RoutingFormInsightsTeamFilter = {
|
type RoutingFormInsightsTeamFilter = {
|
||||||
userId?: number | null;
|
userId?: number | null;
|
||||||
teamId?: number | null;
|
teamId?: number | null;
|
||||||
@@ -29,14 +22,6 @@ type RoutingFormInsightsTeamFilter = {
|
|||||||
routingFormId?: string | null;
|
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">;
|
type WhereForTeamOrAllTeams = Pick<Prisma.App_RoutingForms_FormWhereInput, "id" | "teamId" | "userId">;
|
||||||
|
|
||||||
class RoutingEventsInsights {
|
class RoutingEventsInsights {
|
||||||
@@ -100,57 +85,6 @@ class RoutingEventsInsights {
|
|||||||
return formsWhereCondition;
|
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({
|
static async getRoutingFormsForFilters({
|
||||||
userId,
|
userId,
|
||||||
teamId,
|
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({
|
static async getRoutingFormPaginatedResponsesForDownload({
|
||||||
teamId,
|
headersPromise,
|
||||||
startDate,
|
dataPromise,
|
||||||
endDate,
|
}: {
|
||||||
isAll,
|
headersPromise: ReturnType<typeof RoutingEventsInsights.getRoutingFormHeaders>;
|
||||||
organizationId,
|
dataPromise: ReturnType<typeof InsightsRoutingBaseService.prototype.getTableData>;
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [headers, data] = await Promise.all([headersPromise, dataPromise]);
|
const [headers, data] = await Promise.all([headersPromise, dataPromise]);
|
||||||
|
|
||||||
const dataWithFlatResponse = data.data.map((item) => {
|
const dataWithFlatResponse = data.data.map((item) => {
|
||||||
const { bookingAttendees } = item;
|
const bookingAttendees = item.bookingAttendees || [];
|
||||||
const responseParseResult = ZResponse.safeParse(item.response);
|
|
||||||
const response = responseParseResult.success ? responseParseResult.data : {};
|
|
||||||
|
|
||||||
const fields = (headers || []).reduce((acc, header) => {
|
const fields = (headers || []).reduce((acc, header) => {
|
||||||
const id = header.id;
|
const id = header.id;
|
||||||
if (!response[id]) {
|
const field = item.fields.find((field) => field.fieldId === id);
|
||||||
|
if (!field) {
|
||||||
acc[header.label] = "";
|
acc[header.label] = "";
|
||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
if (header.type === "select") {
|
if (header.type === "select") {
|
||||||
acc[header.label] = header.options?.find((option) => option.id === response[id].value)?.label;
|
acc[header.label] = header.options?.find((option) => option.id === field.valueString)?.label;
|
||||||
} else if (header.type === "multiselect" && Array.isArray(response[id].value)) {
|
} else if (header.type === "multiselect" && Array.isArray(field.valueStringArray)) {
|
||||||
acc[header.label] = (response[id].value as string[])
|
acc[header.label] = field.valueStringArray
|
||||||
.map((value) => header.options?.find((option) => option.id === value)?.label)
|
.map((value) => header.options?.find((option) => option.id === value)?.label)
|
||||||
.filter((label): label is string => label !== undefined)
|
.filter((label): label is string => label !== undefined)
|
||||||
.sort()
|
.sort()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
} else if (header.type === "number") {
|
||||||
|
acc[header.label] = field.valueNumber?.toString() || "";
|
||||||
} else {
|
} else {
|
||||||
acc[header.label] = response[id].value as string;
|
acc[header.label] = field.valueString || "";
|
||||||
}
|
}
|
||||||
return acc;
|
return acc;
|
||||||
}, {} as Record<string, string | undefined>);
|
}, {} as Record<string, string | undefined>);
|
||||||
@@ -442,9 +163,6 @@ class RoutingEventsInsights {
|
|||||||
"Booking Created At": item.bookingCreatedAt?.toISOString() || "",
|
"Booking Created At": item.bookingCreatedAt?.toISOString() || "",
|
||||||
"Booking Start Time": item.bookingStartTime?.toISOString() || "",
|
"Booking Start Time": item.bookingStartTime?.toISOString() || "",
|
||||||
"Booking End Time": item.bookingEndTime?.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 || "",
|
"Assignment Reason": item.bookingAssignmentReason || "",
|
||||||
"Routed To Name": item.bookingUserName || "",
|
"Routed To Name": item.bookingUserName || "",
|
||||||
"Routed To Email": item.bookingUserEmail || "",
|
"Routed To Email": item.bookingUserEmail || "",
|
||||||
@@ -454,6 +172,12 @@ class RoutingEventsInsights {
|
|||||||
utm_campaign: item.utm_campaign || "",
|
utm_campaign: item.utm_campaign || "",
|
||||||
utm_term: item.utm_term || "",
|
utm_term: item.utm_term || "",
|
||||||
utm_content: item.utm_content || "",
|
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 dayjs from "@calcom/dayjs";
|
||||||
import {
|
import {
|
||||||
rawDataInputSchema,
|
rawDataInputSchema,
|
||||||
routingFormResponsesInputSchema,
|
insightsRoutingServiceInputSchema,
|
||||||
routingFormStatsInputSchema,
|
insightsRoutingServicePaginatedInputSchema,
|
||||||
routingRepositoryBaseInputSchema,
|
routingRepositoryBaseInputSchema,
|
||||||
bookingRepositoryBaseInputSchema,
|
bookingRepositoryBaseInputSchema,
|
||||||
} from "@calcom/features/insights/server/raw-data.schema";
|
} from "@calcom/features/insights/server/raw-data.schema";
|
||||||
@@ -827,55 +827,44 @@ export const insightsRouter = router({
|
|||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
routingFormsByStatus: userBelongsToTeamProcedure
|
routingFormsByStatus: userBelongsToTeamProcedure
|
||||||
.input(routingFormStatsInputSchema)
|
.input(insightsRoutingServiceInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return await RoutingEventsInsights.getRoutingFormStats({
|
const insightsRoutingService = createInsightsRoutingService(ctx, input);
|
||||||
teamId: input.teamId,
|
return await insightsRoutingService.getRoutingFormStats();
|
||||||
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,
|
|
||||||
});
|
|
||||||
}),
|
}),
|
||||||
routingFormResponses: userBelongsToTeamProcedure
|
routingFormResponses: userBelongsToTeamProcedure
|
||||||
.input(routingFormResponsesInputSchema)
|
.input(insightsRoutingServicePaginatedInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return await RoutingEventsInsights.getRoutingFormPaginatedResponses({
|
const insightsRoutingService = createInsightsRoutingService(ctx, input);
|
||||||
teamId: input.teamId,
|
return await insightsRoutingService.getTableData({
|
||||||
startDate: input.startDate,
|
sorting: input.sorting,
|
||||||
endDate: input.endDate,
|
|
||||||
isAll: input.isAll,
|
|
||||||
organizationId: ctx.user.organizationId ?? null,
|
|
||||||
routingFormId: input.routingFormId,
|
|
||||||
userId: ctx.user.id,
|
|
||||||
memberUserIds: input.memberUserIds,
|
|
||||||
limit: input.limit,
|
limit: input.limit,
|
||||||
offset: input.offset,
|
offset: input.offset,
|
||||||
columnFilters: input.columnFilters,
|
|
||||||
sorting: input.sorting,
|
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
routingFormResponsesForDownload: userBelongsToTeamProcedure
|
routingFormResponsesForDownload: userBelongsToTeamProcedure
|
||||||
.input(routingFormResponsesInputSchema)
|
.input(insightsRoutingServicePaginatedInputSchema)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
|
const headersPromise = RoutingEventsInsights.getRoutingFormHeaders({
|
||||||
teamId: input.teamId,
|
|
||||||
startDate: input.startDate,
|
|
||||||
endDate: input.endDate,
|
|
||||||
isAll: input.isAll,
|
|
||||||
organizationId: ctx.user.organizationId ?? null,
|
|
||||||
routingFormId: input.routingFormId,
|
|
||||||
userId: ctx.user.id,
|
userId: ctx.user.id,
|
||||||
memberUserIds: input.memberUserIds,
|
teamId: input.selectedTeamId,
|
||||||
limit: input.limit ?? BATCH_SIZE,
|
isAll: input.scope === "org",
|
||||||
offset: input.offset,
|
organizationId: ctx.user.organizationId,
|
||||||
columnFilters: input.columnFilters,
|
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,
|
sorting: input.sorting,
|
||||||
|
limit: input.limit,
|
||||||
|
offset: input.offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
return await RoutingEventsInsights.getRoutingFormPaginatedResponsesForDownload({
|
||||||
|
headersPromise,
|
||||||
|
dataPromise,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
getRoutingFormFieldOptions: userBelongsToTeamProcedure
|
getRoutingFormFieldOptions: userBelongsToTeamProcedure
|
||||||
|
|||||||
@@ -136,13 +136,11 @@ export const useSchedule = ({
|
|||||||
eventTypeId: eventId ?? undefined,
|
eventTypeId: eventId ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const schedule = isTeamEvent
|
const schedule = trpc.viewer.slots.getSchedule.useQuery(input, {
|
||||||
? trpc.viewer.highPerf.getTeamSchedule.useQuery(input, {
|
|
||||||
...options,
|
...options,
|
||||||
// Only enable if we're not using API V2
|
// Only enable if we're not using API V2
|
||||||
enabled: options.enabled && !isCallingApiV2Slots,
|
enabled: options.enabled && !isCallingApiV2Slots,
|
||||||
})
|
});
|
||||||
: trpc.viewer.slots.getSchedule.useQuery(input, options);
|
|
||||||
|
|
||||||
if (isCallingApiV2Slots && !teamScheduleV2.failureReason) {
|
if (isCallingApiV2Slots && !teamScheduleV2.failureReason) {
|
||||||
updateEmbedBookerState({
|
updateEmbedBookerState({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { sortBy } from "lodash";
|
import { sortBy } from "lodash";
|
||||||
|
|
||||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||||
|
import { MeetLocationType } from "@calcom/app-store/locations";
|
||||||
import getApps from "@calcom/app-store/utils";
|
import getApps from "@calcom/app-store/utils";
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import { getUid } from "@calcom/lib/CalEventParser";
|
import { getUid } from "@calcom/lib/CalEventParser";
|
||||||
@@ -494,12 +495,14 @@ const processEvent = (calEvent: CalendarEvent): CalendarServiceEvent => {
|
|||||||
calendarDescription: getRichDescription(calEvent),
|
calendarDescription: getRichDescription(calEvent),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMeetLocationType = calEvent.location === MeetLocationType;
|
||||||
|
|
||||||
// Determine if the calendar event should include attendees
|
// Determine if the calendar event should include attendees
|
||||||
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
|
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
|
||||||
.filter((domain) => domain.trim() !== "")
|
.filter((domain) => domain.trim() !== "")
|
||||||
.some((domain) => calEvent.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
|
.some((domain) => calEvent.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
|
||||||
|
|
||||||
if (calEvent.hideOrganizerEmail && !isOrganizerExempt) {
|
if (calEvent.hideOrganizerEmail && !isOrganizerExempt && !isMeetLocationType) {
|
||||||
calendarEvent.attendees = [];
|
calendarEvent.attendees = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const user: User & { credentials: CredentialPayload[] } = {
|
|||||||
timeFormat: 12,
|
timeFormat: 12,
|
||||||
travelSchedules: [],
|
travelSchedules: [],
|
||||||
locked: false,
|
locked: false,
|
||||||
|
isPlatformManaged: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const customInputs: CustomInputSchema[] = [];
|
const customInputs: CustomInputSchema[] = [];
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@formbricks/api": "^1.7.0",
|
"@formbricks/api": "^1.7.0",
|
||||||
"@formbricks/js": "^2.1.0",
|
"@formbricks/js": "^2.1.0",
|
||||||
"@sendgrid/client": "^7.7.0",
|
"@sendgrid/client": "^7.7.0",
|
||||||
|
"@unkey/ratelimit": "^2.1.3",
|
||||||
"@vercel/og": "^0.6.3",
|
"@vercel/og": "^0.6.3",
|
||||||
"city-timezones": "^1.2.1",
|
"city-timezones": "^1.2.1",
|
||||||
"i18next": "^23.2.3",
|
"i18next": "^23.2.3",
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ export function rateLimiter() {
|
|||||||
ms: 5000,
|
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 = {
|
const limiter = {
|
||||||
core: new Ratelimit({
|
core: new Ratelimit({
|
||||||
rootKey: UNKEY_ROOT_KEY,
|
rootKey: UNKEY_ROOT_KEY,
|
||||||
@@ -37,6 +47,7 @@ export function rateLimiter() {
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
duration: "60s",
|
duration: "60s",
|
||||||
timeout,
|
timeout,
|
||||||
|
onError,
|
||||||
}),
|
}),
|
||||||
common: new Ratelimit({
|
common: new Ratelimit({
|
||||||
rootKey: UNKEY_ROOT_KEY,
|
rootKey: UNKEY_ROOT_KEY,
|
||||||
@@ -44,6 +55,7 @@ export function rateLimiter() {
|
|||||||
limit: 200,
|
limit: 200,
|
||||||
duration: "60s",
|
duration: "60s",
|
||||||
timeout,
|
timeout,
|
||||||
|
onError,
|
||||||
}),
|
}),
|
||||||
forcedSlowMode: new Ratelimit({
|
forcedSlowMode: new Ratelimit({
|
||||||
rootKey: UNKEY_ROOT_KEY,
|
rootKey: UNKEY_ROOT_KEY,
|
||||||
@@ -51,6 +63,7 @@ export function rateLimiter() {
|
|||||||
limit: 1,
|
limit: 1,
|
||||||
duration: "30s",
|
duration: "30s",
|
||||||
timeout,
|
timeout,
|
||||||
|
onError,
|
||||||
}),
|
}),
|
||||||
api: new Ratelimit({
|
api: new Ratelimit({
|
||||||
rootKey: UNKEY_ROOT_KEY,
|
rootKey: UNKEY_ROOT_KEY,
|
||||||
@@ -58,6 +71,7 @@ export function rateLimiter() {
|
|||||||
limit: API_KEY_RATE_LIMIT,
|
limit: API_KEY_RATE_LIMIT,
|
||||||
duration: "60s",
|
duration: "60s",
|
||||||
timeout,
|
timeout,
|
||||||
|
onError,
|
||||||
}),
|
}),
|
||||||
ai: new Ratelimit({
|
ai: new Ratelimit({
|
||||||
rootKey: UNKEY_ROOT_KEY,
|
rootKey: UNKEY_ROOT_KEY,
|
||||||
@@ -65,6 +79,7 @@ export function rateLimiter() {
|
|||||||
limit: 20,
|
limit: 20,
|
||||||
duration: "1d",
|
duration: "1d",
|
||||||
timeout,
|
timeout,
|
||||||
|
onError,
|
||||||
}),
|
}),
|
||||||
sms: new Ratelimit({
|
sms: new Ratelimit({
|
||||||
rootKey: UNKEY_ROOT_KEY,
|
rootKey: UNKEY_ROOT_KEY,
|
||||||
@@ -72,6 +87,7 @@ export function rateLimiter() {
|
|||||||
limit: 50,
|
limit: 50,
|
||||||
duration: "5m",
|
duration: "5m",
|
||||||
timeout,
|
timeout,
|
||||||
|
onError,
|
||||||
}),
|
}),
|
||||||
smsMonth: new Ratelimit({
|
smsMonth: new Ratelimit({
|
||||||
rootKey: UNKEY_ROOT_KEY,
|
rootKey: UNKEY_ROOT_KEY,
|
||||||
@@ -79,6 +95,7 @@ export function rateLimiter() {
|
|||||||
limit: 250,
|
limit: 250,
|
||||||
duration: "30d",
|
duration: "30d",
|
||||||
timeout,
|
timeout,
|
||||||
|
onError,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -298,8 +298,16 @@ export class InsightsBookingBaseService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (id === "eventTypeId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
if (id === "eventTypeId" && isMultiSelectFilterValue(value)) {
|
||||||
return Prisma.sql`("eventTypeId" = ${value.data}) OR ("eventParentId" = ${value.data})`;
|
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") {
|
if (id === "userId" && isSingleSelectFilterValue(value) && typeof value.data === "number") {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@calcom/features/data-table/lib/utils";
|
} from "@calcom/features/data-table/lib/utils";
|
||||||
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
|
import type { DateRange } from "@calcom/features/insights/server/insightsDateUtils";
|
||||||
import type { readonlyPrisma } from "@calcom/prisma";
|
import type { readonlyPrisma } from "@calcom/prisma";
|
||||||
|
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||||
import { MembershipRole } from "@calcom/prisma/enums";
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import { MembershipRepository } from "../repository/membership";
|
import { MembershipRepository } from "../repository/membership";
|
||||||
@@ -51,8 +52,81 @@ export type InsightsRoutingServiceFilterOptions = {
|
|||||||
columnFilters?: TypedColumnFilter<ColumnFilterType>[];
|
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`;
|
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 {
|
export class InsightsRoutingBaseService {
|
||||||
private prisma: typeof readonlyPrisma;
|
private prisma: typeof readonlyPrisma;
|
||||||
private options: InsightsRoutingServiceOptions | null;
|
private options: InsightsRoutingServiceOptions | null;
|
||||||
@@ -118,7 +192,7 @@ export class InsightsRoutingBaseService {
|
|||||||
END as "dateRange",
|
END as "dateRange",
|
||||||
"bookingUid",
|
"bookingUid",
|
||||||
"bookingStatus"
|
"bookingStatus"
|
||||||
FROM "RoutingFormResponseDenormalized"
|
FROM "RoutingFormResponseDenormalized" rfrd
|
||||||
WHERE ${baseConditions}
|
WHERE ${baseConditions}
|
||||||
)
|
)
|
||||||
SELECT
|
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> {
|
async getBaseConditions(): Promise<Prisma.Sql> {
|
||||||
const authConditions = await this.getAuthorizationConditions();
|
const authConditions = await this.getAuthorizationConditions();
|
||||||
const filterConditions = await this.getFilterConditions();
|
const filterConditions = await this.getFilterConditions();
|
||||||
@@ -238,12 +472,30 @@ export class InsightsRoutingBaseService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract booking attendees filter
|
// Extract attendee name filter
|
||||||
const bookingAttendees = filtersMap["bookingAttendees"];
|
const attendeeName = filtersMap["attendeeName"];
|
||||||
if (bookingAttendees && isTextFilterValue(bookingAttendees.value)) {
|
if (attendeeName && isTextFilterValue(attendeeName.value)) {
|
||||||
const attendeesCondition = this.buildAttendeeSqlCondition(bookingAttendees.value);
|
const nameCondition = this.buildAttendeeSqlCondition(attendeeName.value, "name");
|
||||||
if (attendeesCondition) {
|
if (nameCondition) {
|
||||||
conditions.push(attendeesCondition);
|
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 fieldIdSchema = z.string().uuid();
|
||||||
const alreadyHandledFilters = [
|
|
||||||
"bookingStatusOrder",
|
|
||||||
"bookingAssignmentReason",
|
|
||||||
"bookingUid",
|
|
||||||
"bookingAttendees",
|
|
||||||
"bookingUserId",
|
|
||||||
"formId",
|
|
||||||
];
|
|
||||||
|
|
||||||
const fieldFilters = (this.filters.columnFilters || []).filter(
|
const fieldFilters = (this.filters.columnFilters || []).filter(
|
||||||
(filter) => !alreadyHandledFilters.includes(filter.id)
|
(filter) => fieldIdSchema.safeParse(filter.id).success
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fieldFilters.length > 0) {
|
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
|
// For multi-select fields, check if the field contains any of the selected values
|
||||||
return Prisma.sql`EXISTS (
|
return Prisma.sql`EXISTS (
|
||||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
WHERE rrf."responseId" = rfrd."id"
|
||||||
AND rrf."fieldId" = ${fieldId}
|
AND rrf."fieldId" = ${fieldId}
|
||||||
AND rrf."valueStringArray" @> ${filterValue.data.map(String)}
|
AND rrf."valueStringArray" @> ${filterValue.data.map(String)}
|
||||||
)`;
|
)`;
|
||||||
} else if (isSingleSelectFilterValue(filterValue)) {
|
} else if (isSingleSelectFilterValue(filterValue)) {
|
||||||
return Prisma.sql`EXISTS (
|
return Prisma.sql`EXISTS (
|
||||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
WHERE rrf."responseId" = rfrd."id"
|
||||||
AND rrf."fieldId" = ${fieldId}
|
AND rrf."fieldId" = ${fieldId}
|
||||||
AND rrf."valueString" = ${filterValue.data}
|
AND rrf."valueString" = ${filterValue.data}
|
||||||
)`;
|
)`;
|
||||||
@@ -394,7 +637,7 @@ export class InsightsRoutingBaseService {
|
|||||||
}
|
}
|
||||||
return Prisma.sql`EXISTS (
|
return Prisma.sql`EXISTS (
|
||||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
WHERE rrf."responseId" = rfrd."id"
|
||||||
AND rrf."fieldId" = ${fieldId}
|
AND rrf."fieldId" = ${fieldId}
|
||||||
AND rrf."valueString" ${condition}
|
AND rrf."valueString" ${condition}
|
||||||
)`;
|
)`;
|
||||||
@@ -405,7 +648,7 @@ export class InsightsRoutingBaseService {
|
|||||||
}
|
}
|
||||||
return Prisma.sql`EXISTS (
|
return Prisma.sql`EXISTS (
|
||||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
WHERE rrf."responseId" = rfrd."id"
|
||||||
AND rrf."fieldId" = ${fieldId}
|
AND rrf."fieldId" = ${fieldId}
|
||||||
AND rrf."valueNumber" ${condition}
|
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)) {
|
if (!isTextFilterValue(filterValue)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const textCondition = makeSqlCondition(filterValue);
|
const textCondition = makeSqlCondition(filterValue);
|
||||||
|
|
||||||
if (!textCondition) {
|
if (!textCondition) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameCondition = Prisma.sql`a.name ${textCondition}`;
|
// Use switch-case to avoid Prisma.raw for column names
|
||||||
const emailCondition = Prisma.sql`a.email ${textCondition}`;
|
let columnCondition: Prisma.Sql;
|
||||||
|
switch (attendeeColumn) {
|
||||||
const combinedCondition = Prisma.sql`(${nameCondition}) OR (${emailCondition})`;
|
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 (
|
return Prisma.sql`EXISTS (
|
||||||
SELECT 1 FROM "Booking" b
|
SELECT 1 FROM "Booking" b
|
||||||
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
||||||
WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
|
WHERE b."uid" = rfrd."bookingUid"
|
||||||
AND (${combinedCondition})
|
AND ${columnCondition}
|
||||||
)`;
|
)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -381,8 +381,8 @@ describe("InsightsBookingService Integration Tests", () => {
|
|||||||
{
|
{
|
||||||
id: "eventTypeId",
|
id: "eventTypeId",
|
||||||
value: {
|
value: {
|
||||||
type: ColumnFilterType.SINGLE_SELECT,
|
type: ColumnFilterType.MULTI_SELECT,
|
||||||
data: testData.eventType.id,
|
data: [testData.eventType.id],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -391,7 +391,9 @@ describe("InsightsBookingService Integration Tests", () => {
|
|||||||
|
|
||||||
const conditions = await service.getFilterConditions();
|
const conditions = await service.getFilterConditions();
|
||||||
expect(conditions).toEqual(
|
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();
|
await testData.cleanup();
|
||||||
@@ -441,8 +443,8 @@ describe("InsightsBookingService Integration Tests", () => {
|
|||||||
{
|
{
|
||||||
id: "eventTypeId",
|
id: "eventTypeId",
|
||||||
value: {
|
value: {
|
||||||
type: ColumnFilterType.SINGLE_SELECT,
|
type: ColumnFilterType.MULTI_SELECT,
|
||||||
data: testData.eventType.id,
|
data: [testData.eventType.id],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -458,7 +460,11 @@ describe("InsightsBookingService Integration Tests", () => {
|
|||||||
|
|
||||||
const conditions = await service.getFilterConditions();
|
const conditions = await service.getFilterConditions();
|
||||||
expect(conditions).toEqual(
|
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();
|
await testData.cleanup();
|
||||||
@@ -542,8 +548,8 @@ describe("InsightsBookingService Integration Tests", () => {
|
|||||||
{
|
{
|
||||||
id: "eventTypeId",
|
id: "eventTypeId",
|
||||||
value: {
|
value: {
|
||||||
type: ColumnFilterType.SINGLE_SELECT,
|
type: ColumnFilterType.MULTI_SELECT,
|
||||||
data: userEventType.id,
|
data: [userEventType.id],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Team, User, Membership } from "@prisma/client";
|
import type { Team, User, Membership } from "@prisma/client";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { randomUUID } from "crypto";
|
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 { ColumnFilterType } from "@calcom/features/data-table/lib/types";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -830,6 +831,14 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("columnFilters", () => {
|
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 () => {
|
it("should handle empty columnFilters", async () => {
|
||||||
const testData = await createTestData({
|
const testData = await createTestData({
|
||||||
teamRole: MembershipRole.OWNER,
|
teamRole: MembershipRole.OWNER,
|
||||||
@@ -1044,7 +1053,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
await testData.cleanup();
|
await testData.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter by booking attendees (text)", async () => {
|
it("should filter by attendee name (text)", async () => {
|
||||||
const testData = await createTestData({
|
const testData = await createTestData({
|
||||||
teamRole: MembershipRole.OWNER,
|
teamRole: MembershipRole.OWNER,
|
||||||
orgRole: MembershipRole.OWNER,
|
orgRole: MembershipRole.OWNER,
|
||||||
@@ -1063,10 +1072,10 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
...defaultFilters,
|
...defaultFilters,
|
||||||
columnFilters: [
|
columnFilters: [
|
||||||
{
|
{
|
||||||
id: "bookingAttendees",
|
id: "attendeeName",
|
||||||
value: {
|
value: {
|
||||||
type: ColumnFilterType.TEXT,
|
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();
|
const filterConditions = await service.getFilterConditions();
|
||||||
expect(filterConditions).toEqual(
|
expect(filterConditions).toMatchInlineSnapshot(`
|
||||||
Prisma.sql`("createdAt" >= ${defaultFilters.startDate}::timestamp AND "createdAt" <= ${
|
e {
|
||||||
defaultFilters.endDate
|
"strings": [
|
||||||
}::timestamp) AND (EXISTS (
|
"("createdAt" >= ",
|
||||||
|
"::timestamp AND "createdAt" <= ",
|
||||||
|
"::timestamp) AND (EXISTS (
|
||||||
SELECT 1 FROM "Booking" b
|
SELECT 1 FROM "Booking" b
|
||||||
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
INNER JOIN "Attendee" a ON a."bookingId" = b."id"
|
||||||
WHERE b."uid" = "RoutingFormResponseDenormalized"."bookingUid"
|
WHERE b."uid" = rfrd."bookingUid"
|
||||||
AND (${Prisma.sql`(a.name ILIKE ${`%john@example.com%`}) OR (a.email ILIKE ${`%john@example.com%`})`})
|
AND a.name ILIKE ",
|
||||||
))`
|
"
|
||||||
);
|
))",
|
||||||
|
],
|
||||||
|
"values": [
|
||||||
|
"2025-08-18T00:00:00.000Z",
|
||||||
|
"2025-08-20T00:00:00.000Z",
|
||||||
|
"%john%",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
|
||||||
await testData.cleanup();
|
await testData.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should filter by custom form fields (text)", async () => {
|
it("should filter by attendee email (text)", async () => {
|
||||||
const testData = await createTestData({
|
const testData = await createTestData({
|
||||||
teamRole: MembershipRole.OWNER,
|
teamRole: MembershipRole.OWNER,
|
||||||
orgRole: MembershipRole.OWNER,
|
orgRole: MembershipRole.OWNER,
|
||||||
@@ -1107,7 +1126,117 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
...defaultFilters,
|
...defaultFilters,
|
||||||
columnFilters: [
|
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: {
|
value: {
|
||||||
type: ColumnFilterType.TEXT,
|
type: ColumnFilterType.TEXT,
|
||||||
data: { operator: "equals", operand: "test value" },
|
data: { operator: "equals", operand: "test value" },
|
||||||
@@ -1123,8 +1252,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
defaultFilters.endDate
|
defaultFilters.endDate
|
||||||
}::timestamp) AND (EXISTS (
|
}::timestamp) AND (EXISTS (
|
||||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
WHERE rrf."responseId" = rfrd."id"
|
||||||
AND rrf."fieldId" = ${"custom-field-id"}
|
AND rrf."fieldId" = ${customFieldId}
|
||||||
AND rrf."valueString" = ${"test value"}
|
AND rrf."valueString" = ${"test value"}
|
||||||
))`
|
))`
|
||||||
);
|
);
|
||||||
@@ -1138,6 +1267,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
orgRole: MembershipRole.OWNER,
|
orgRole: MembershipRole.OWNER,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const customFieldId = uuid();
|
||||||
const defaultFilters = createDefaultFilters();
|
const defaultFilters = createDefaultFilters();
|
||||||
const service = new InsightsRoutingService({
|
const service = new InsightsRoutingService({
|
||||||
prisma,
|
prisma,
|
||||||
@@ -1151,7 +1281,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
...defaultFilters,
|
...defaultFilters,
|
||||||
columnFilters: [
|
columnFilters: [
|
||||||
{
|
{
|
||||||
id: "custom-multi-field-id",
|
id: customFieldId,
|
||||||
value: {
|
value: {
|
||||||
type: ColumnFilterType.MULTI_SELECT,
|
type: ColumnFilterType.MULTI_SELECT,
|
||||||
data: ["option1", "option2"],
|
data: ["option1", "option2"],
|
||||||
@@ -1167,8 +1297,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
defaultFilters.endDate
|
defaultFilters.endDate
|
||||||
}::timestamp) AND (EXISTS (
|
}::timestamp) AND (EXISTS (
|
||||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
WHERE rrf."responseId" = rfrd."id"
|
||||||
AND rrf."fieldId" = ${"custom-multi-field-id"}
|
AND rrf."fieldId" = ${customFieldId}
|
||||||
AND rrf."valueStringArray" @> ${["option1", "option2"]}
|
AND rrf."valueStringArray" @> ${["option1", "option2"]}
|
||||||
))`
|
))`
|
||||||
);
|
);
|
||||||
@@ -1177,6 +1307,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should combine multiple filters with AND", async () => {
|
it("should combine multiple filters with AND", async () => {
|
||||||
|
const customFieldId = uuid();
|
||||||
const testData = await createTestData({
|
const testData = await createTestData({
|
||||||
teamRole: MembershipRole.OWNER,
|
teamRole: MembershipRole.OWNER,
|
||||||
orgRole: MembershipRole.OWNER,
|
orgRole: MembershipRole.OWNER,
|
||||||
@@ -1209,7 +1340,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "custom-field-id",
|
id: customFieldId,
|
||||||
value: {
|
value: {
|
||||||
type: ColumnFilterType.TEXT,
|
type: ColumnFilterType.TEXT,
|
||||||
data: { operator: "equals", operand: "test" },
|
data: { operator: "equals", operand: "test" },
|
||||||
@@ -1227,8 +1358,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
"pending",
|
"pending",
|
||||||
]}))) AND ("bookingAssignmentReason" ILIKE ${`%manual%`})) AND (EXISTS (
|
]}))) AND ("bookingAssignmentReason" ILIKE ${`%manual%`})) AND (EXISTS (
|
||||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
WHERE rrf."responseId" = rfrd."id"
|
||||||
AND rrf."fieldId" = ${"custom-field-id"}
|
AND rrf."fieldId" = ${customFieldId}
|
||||||
AND rrf."valueString" = ${"test"}
|
AND rrf."valueString" = ${"test"}
|
||||||
))`
|
))`
|
||||||
);
|
);
|
||||||
@@ -1330,6 +1461,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
orgRole: MembershipRole.OWNER,
|
orgRole: MembershipRole.OWNER,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const customFieldId = uuid();
|
||||||
|
|
||||||
const defaultFilters = createDefaultFilters();
|
const defaultFilters = createDefaultFilters();
|
||||||
const service = new InsightsRoutingService({
|
const service = new InsightsRoutingService({
|
||||||
prisma,
|
prisma,
|
||||||
@@ -1350,7 +1483,7 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "custom-field-id", // Custom field filter
|
id: customFieldId, // Custom field filter
|
||||||
value: {
|
value: {
|
||||||
type: ColumnFilterType.TEXT,
|
type: ColumnFilterType.TEXT,
|
||||||
data: { operator: "equals", operand: "test" },
|
data: { operator: "equals", operand: "test" },
|
||||||
@@ -1366,8 +1499,8 @@ describe("InsightsRoutingService Integration Tests", () => {
|
|||||||
defaultFilters.endDate
|
defaultFilters.endDate
|
||||||
}::timestamp) AND ("bookingStatusOrder" = ANY(${["pending"]}))) AND (EXISTS (
|
}::timestamp) AND ("bookingStatusOrder" = ANY(${["pending"]}))) AND (EXISTS (
|
||||||
SELECT 1 FROM "RoutingFormResponseField" rrf
|
SELECT 1 FROM "RoutingFormResponseField" rrf
|
||||||
WHERE rrf."responseId" = "RoutingFormResponseDenormalized"."id"
|
WHERE rrf."responseId" = rfrd."id"
|
||||||
AND rrf."fieldId" = ${"custom-field-id"}
|
AND rrf."fieldId" = ${customFieldId}
|
||||||
AND rrf."valueString" = ${"test"}
|
AND rrf."valueString" = ${"test"}
|
||||||
))`
|
))`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import debounce from "lodash/debounce";
|
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 { shallow } from "zustand/shallow";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
@@ -10,11 +10,12 @@ import {
|
|||||||
BookerStoreProvider,
|
BookerStoreProvider,
|
||||||
useInitializeBookerStoreContext,
|
useInitializeBookerStoreContext,
|
||||||
useBookerStoreContext,
|
useBookerStoreContext,
|
||||||
|
BookerStoreContext,
|
||||||
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
|
||||||
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
|
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
|
||||||
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
|
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
|
||||||
import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/useLocalSet";
|
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 { useTimePreferences } from "@calcom/features/bookings/lib";
|
||||||
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
|
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
|
||||||
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
|
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
|
||||||
@@ -38,6 +39,8 @@ import { useCalendarsBusyTimes } from "../hooks/useCalendarsBusyTimes";
|
|||||||
import { useConnectedCalendars } from "../hooks/useConnectedCalendars";
|
import { useConnectedCalendars } from "../hooks/useConnectedCalendars";
|
||||||
import { useMe } from "../hooks/useMe";
|
import { useMe } from "../hooks/useMe";
|
||||||
import { useSlots } from "../hooks/useSlots";
|
import { useSlots } from "../hooks/useSlots";
|
||||||
|
import { useVerifyCode } from "../hooks/useVerifyCode";
|
||||||
|
import { useVerifyEmail } from "../hooks/useVerifyEmail";
|
||||||
import { AtomsWrapper } from "../src/components/atoms-wrapper";
|
import { AtomsWrapper } from "../src/components/atoms-wrapper";
|
||||||
import type {
|
import type {
|
||||||
BookerPlatformWrapperAtomPropsForIndividual,
|
BookerPlatformWrapperAtomPropsForIndividual,
|
||||||
@@ -65,6 +68,7 @@ const BookerPlatformWrapperComponent = (
|
|||||||
startTime: customStartTime,
|
startTime: customStartTime,
|
||||||
showNoAvailabilityDialog,
|
showNoAvailabilityDialog,
|
||||||
silentlyHandleCalendarFailures = false,
|
silentlyHandleCalendarFailures = false,
|
||||||
|
hideEventMetadata = false,
|
||||||
} = props;
|
} = props;
|
||||||
const layout = BookerLayouts[view];
|
const layout = BookerLayouts[view];
|
||||||
|
|
||||||
@@ -87,14 +91,12 @@ const BookerPlatformWrapperComponent = (
|
|||||||
Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault"))
|
Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault"))
|
||||||
);
|
);
|
||||||
const prevStateRef = useRef<BookerStoreValues | null>(null);
|
const prevStateRef = useRef<BookerStoreValues | null>(null);
|
||||||
const getStateValues = useCallback(
|
const bookerStoreContext = useContext(BookerStoreContext);
|
||||||
(state: ReturnType<typeof useBookerStore.getState>): BookerStoreValues => {
|
const getStateValues = useCallback((state: any): BookerStoreValues => {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(state).filter(([_, value]) => typeof value !== "function")
|
Object.entries(state).filter(([_, value]) => typeof value !== "function")
|
||||||
) as BookerStoreValues;
|
) as BookerStoreValues;
|
||||||
},
|
}, []);
|
||||||
[]
|
|
||||||
);
|
|
||||||
const debouncedStateChange = useMemo(() => {
|
const debouncedStateChange = useMemo(() => {
|
||||||
return debounce(
|
return debounce(
|
||||||
(currentStateValues: BookerStoreValues, callback: (values: BookerStoreValues) => void) => {
|
(currentStateValues: BookerStoreValues, callback: (values: BookerStoreValues) => void) => {
|
||||||
@@ -111,15 +113,15 @@ const BookerPlatformWrapperComponent = (
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!onBookerStateChange) return;
|
if (!onBookerStateChange || !bookerStoreContext) return;
|
||||||
|
|
||||||
const unsubscribe = useBookerStore.subscribe((state) => {
|
const unsubscribe = bookerStoreContext.subscribe((state) => {
|
||||||
const currentStateValues = getStateValues(state);
|
const currentStateValues = getStateValues(state);
|
||||||
debouncedStateChange(currentStateValues, onBookerStateChange);
|
debouncedStateChange(currentStateValues, onBookerStateChange);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initial call with current state
|
// Initial call with current state
|
||||||
const initialState = getStateValues(useBookerStore.getState());
|
const initialState = getStateValues(bookerStoreContext.getState());
|
||||||
onBookerStateChange(initialState);
|
onBookerStateChange(initialState);
|
||||||
prevStateRef.current = initialState;
|
prevStateRef.current = initialState;
|
||||||
|
|
||||||
@@ -127,7 +129,7 @@ const BookerPlatformWrapperComponent = (
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
debouncedStateChange.cancel();
|
debouncedStateChange.cancel();
|
||||||
};
|
};
|
||||||
}, [onBookerStateChange, getStateValues, debouncedStateChange]);
|
}, [onBookerStateChange, getStateValues, debouncedStateChange, bookerStoreContext]);
|
||||||
|
|
||||||
useGetBookingForReschedule({
|
useGetBookingForReschedule({
|
||||||
uid: props.rescheduleUid ?? props.bookingUid ?? "",
|
uid: props.rescheduleUid ?? props.bookingUid ?? "",
|
||||||
@@ -395,6 +397,23 @@ const BookerPlatformWrapperComponent = (
|
|||||||
handleSlotReservation,
|
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({
|
const { data: connectedCalendars, isPending: fetchingConnectedCalendars } = useConnectedCalendars({
|
||||||
enabled: hasSession,
|
enabled: hasSession,
|
||||||
});
|
});
|
||||||
@@ -553,26 +572,13 @@ const BookerPlatformWrapperComponent = (
|
|||||||
return;
|
return;
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
verifyEmail={{
|
verifyEmail={verifyEmail}
|
||||||
isEmailVerificationModalVisible: false,
|
|
||||||
setEmailVerificationModalVisible: () => {
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
setVerifiedEmail: () => {
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
handleVerifyEmail: () => {
|
|
||||||
return;
|
|
||||||
},
|
|
||||||
renderConfirmNotVerifyEmailButtonCond: true,
|
|
||||||
isVerificationCodeSending: false,
|
|
||||||
}}
|
|
||||||
bookerForm={bookerForm}
|
bookerForm={bookerForm}
|
||||||
event={event}
|
event={event}
|
||||||
schedule={schedule}
|
schedule={schedule}
|
||||||
orgBannerUrl={bannerUrl ?? event.data?.bannerUrl}
|
orgBannerUrl={bannerUrl ?? event.data?.bannerUrl}
|
||||||
bookerLayout={bookerLayout}
|
bookerLayout={{ ...bookerLayout, hideEventTypeDetails: hideEventMetadata }}
|
||||||
verifyCode={undefined}
|
verifyCode={verifyCode}
|
||||||
isPlatform
|
isPlatform
|
||||||
hasValidLicense={true}
|
hasValidLicense={true}
|
||||||
isBookingDryRun={isBookingDryRun ?? routingParams?.isBookingDryRun}
|
isBookingDryRun={isBookingDryRun ?? routingParams?.isBookingDryRun}
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export type BookerPlatformWrapperAtomProps = Omit<
|
|||||||
startTime?: string | Date;
|
startTime?: string | Date;
|
||||||
roundRobinHideOrgAndTeam?: boolean;
|
roundRobinHideOrgAndTeam?: boolean;
|
||||||
silentlyHandleCalendarFailures?: boolean;
|
silentlyHandleCalendarFailures?: boolean;
|
||||||
|
hideEventMetadata?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VIEW_TYPE = keyof typeof BookerLayouts;
|
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
|
* Access Token must be provided to CalProvider in order to use this hook
|
||||||
* @returns The result of the query containing the user's profile.
|
* @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 pathname = `/${V2_ENDPOINTS.me}`;
|
||||||
const me = useQuery({
|
const me = useQuery({
|
||||||
queryKey: [QUERY_KEY],
|
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}
|
hostsLimit={3}
|
||||||
allowUpdatingUrlParams={true}
|
allowUpdatingUrlParams={true}
|
||||||
silentlyHandleCalendarFailures={false}
|
silentlyHandleCalendarFailures={false}
|
||||||
|
// hideEventMetadata={true}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -120,5 +120,13 @@ export { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
|
|||||||
|
|
||||||
export { verifyPhoneNumber, sendVerificationCode };
|
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 { TeamService } from "@calcom/lib/server/service/teamService";
|
||||||
export { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
|
export { CacheService } from "@calcom/features/calendar-cache/lib/getShouldServeCache";
|
||||||
|
|||||||
Binary file not shown.
@@ -10,6 +10,7 @@ export const availabilityUserSelect = {
|
|||||||
endTime: true,
|
endTime: true,
|
||||||
timeFormat: true,
|
timeFormat: true,
|
||||||
defaultScheduleId: true,
|
defaultScheduleId: true,
|
||||||
|
isPlatformManaged: true,
|
||||||
// Relationships
|
// Relationships
|
||||||
schedules: {
|
schedules: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export const ENDPOINTS = [
|
|||||||
"dsync",
|
"dsync",
|
||||||
"eventTypes",
|
"eventTypes",
|
||||||
"features",
|
"features",
|
||||||
"highPerf",
|
|
||||||
"i18n",
|
"i18n",
|
||||||
"insights",
|
"insights",
|
||||||
"me",
|
"me",
|
||||||
|
|||||||
+10
-1
@@ -6,7 +6,16 @@ import type { TUserEmailVerificationRequiredSchema } from "./checkIfUserEmailVer
|
|||||||
const log = logger.getSubLogger({ prefix: ["checkIfUserEmailVerificationRequired"] });
|
const log = logger.getSubLogger({ prefix: ["checkIfUserEmailVerificationRequired"] });
|
||||||
|
|
||||||
export const userWithEmailHandler = async ({ input }: { input: TUserEmailVerificationRequiredSchema }) => {
|
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 baseEmail = extractBaseEmail(email);
|
||||||
|
|
||||||
const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS
|
const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import { dsyncRouter } from "./dsync/_router";
|
|||||||
import { eventTypesRouter } from "./eventTypes/_router";
|
import { eventTypesRouter } from "./eventTypes/_router";
|
||||||
import { filterSegmentsRouter } from "./filterSegments/_router";
|
import { filterSegmentsRouter } from "./filterSegments/_router";
|
||||||
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
|
import { googleWorkspaceRouter } from "./googleWorkspace/_router";
|
||||||
import { highPerfRouter } from "./highPerf/_router";
|
|
||||||
import { i18nRouter } from "./i18n/_router";
|
import { i18nRouter } from "./i18n/_router";
|
||||||
import { meRouter } from "./me/_router";
|
import { meRouter } from "./me/_router";
|
||||||
import { oAuthRouter } from "./oAuth/_router";
|
import { oAuthRouter } from "./oAuth/_router";
|
||||||
@@ -79,7 +78,6 @@ export const viewerRouter = router({
|
|||||||
googleWorkspace: googleWorkspaceRouter,
|
googleWorkspace: googleWorkspaceRouter,
|
||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
attributes: attributesRouter,
|
attributes: attributesRouter,
|
||||||
highPerf: highPerfRouter,
|
|
||||||
routingForms: routingFormsRouter,
|
routingForms: routingFormsRouter,
|
||||||
credits: creditsRouter,
|
credits: creditsRouter,
|
||||||
ooo: oooRouter,
|
ooo: oooRouter,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import type { NextApiRequest } from "next";
|
|||||||
import { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail";
|
import { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail";
|
||||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import getIP from "@calcom/lib/getIP";
|
import getIP from "@calcom/lib/getIP";
|
||||||
|
import { hashEmail, piiHasher } from "@calcom/lib/server/PiiHasher";
|
||||||
|
|
||||||
import type { TRPCContext } from "../../../createContext";
|
import type { TRPCContext } from "../../../createContext";
|
||||||
import type { TSendVerifyEmailCodeSchema } from "./sendVerifyEmailCode.schema";
|
import type { TSendVerifyEmailCodeSchema } from "./sendVerifyEmailCode.schema";
|
||||||
import { hashEmail, piiHasher } from "@calcom/lib/server/PiiHasher";
|
|
||||||
|
|
||||||
type SendVerifyEmailCode = {
|
type SendVerifyEmailCode = {
|
||||||
input: TSendVerifyEmailCodeSchema;
|
input: TSendVerifyEmailCodeSchema;
|
||||||
@@ -15,18 +15,25 @@ type SendVerifyEmailCode = {
|
|||||||
|
|
||||||
export const sendVerifyEmailCodeHandler = async ({ input, req }: SendVerifyEmailCode) => {
|
export const sendVerifyEmailCodeHandler = async ({ input, req }: SendVerifyEmailCode) => {
|
||||||
const identifier = req ? piiHasher.hash(getIP(req as NextApiRequest)) : hashEmail(input.email);
|
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({
|
await checkRateLimitAndThrowError({
|
||||||
rateLimitingType: "core",
|
rateLimitingType: "core",
|
||||||
identifier: `emailVerifyByCode.${identifier}`,
|
identifier: `emailVerifyByCode.${identifier}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const email = await sendEmailVerificationByCode({
|
return await sendEmailVerificationByCode({
|
||||||
email: input.email,
|
email: input.email,
|
||||||
username: input.username,
|
username: input.username,
|
||||||
language: input.language,
|
language: input.language,
|
||||||
isVerifyingEmail: input.isVerifyingEmail,
|
isVerifyingEmail: input.isVerifyingEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
return email;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ type VerifyTokenOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const verifyCodeUnAuthenticatedHandler = async ({ input }: VerifyTokenOptions) => {
|
export const verifyCodeUnAuthenticatedHandler = async ({ input }: VerifyTokenOptions) => {
|
||||||
|
return verifyCodeUnAuthenticated(input);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const verifyCodeUnAuthenticated = async (input: ZVerifyCodeInputSchema) => {
|
||||||
const { email, code } = input;
|
const { email, code } = input;
|
||||||
|
|
||||||
if (!email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
|
if (!email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
|
||||||
|
|||||||
@@ -212,6 +212,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
|
|||||||
startTime: booking.startTime.toISOString(),
|
startTime: booking.startTime.toISOString(),
|
||||||
endTime: booking.endTime.toISOString(),
|
endTime: booking.endTime.toISOString(),
|
||||||
organizer: {
|
organizer: {
|
||||||
|
id: booking.user?.id,
|
||||||
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
|
email: booking?.userPrimaryEmail || booking.user?.email || "Email-less",
|
||||||
name: booking.user?.name || "Nameless",
|
name: booking.user?.name || "Nameless",
|
||||||
username: booking.user?.username || undefined,
|
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 { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { hashEmail } from "@calcom/lib/server/PiiHasher";
|
||||||
import { totpRawCheck } from "@calcom/lib/totp";
|
import { totpRawCheck } from "@calcom/lib/totp";
|
||||||
import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
|
import type { ZVerifyCodeInputSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { hashEmail } from "@calcom/lib/server/PiiHasher";
|
|
||||||
|
|
||||||
type VerifyCodeOptions = {
|
type VerifyCodeOptions = {
|
||||||
ctx: {
|
ctx: {
|
||||||
@@ -18,9 +18,22 @@ type VerifyCodeOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => {
|
export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => {
|
||||||
const { email, code } = input;
|
return verifyCode({
|
||||||
const { user } = ctx;
|
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 (!user || !email || !code) throw new TRPCError({ code: "BAD_REQUEST" });
|
||||||
|
|
||||||
if (!IS_PRODUCTION || process.env.NEXT_PUBLIC_IS_E2E) {
|
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",
|
"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",
|
"text-default rounded-lg text-sm font-medium leading-none",
|
||||||
"placeholder:text-muted disabled:cursor-not-allowed disabled:bg-transparent",
|
"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",
|
addOnSuffix && "pl-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
+2
-1
@@ -249,7 +249,8 @@
|
|||||||
"CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY",
|
"CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY",
|
||||||
"CAL_VIDEO_RECORDING_TOKEN_SECRET",
|
"CAL_VIDEO_RECORDING_TOKEN_SECRET",
|
||||||
"ORGANIZER_EMAIL_EXEMPT_DOMAINS",
|
"ORGANIZER_EMAIL_EXEMPT_DOMAINS",
|
||||||
"SLOTS_CACHE_TTL"
|
"SLOTS_CACHE_TTL",
|
||||||
|
"CSP_POLICY"
|
||||||
],
|
],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"@calcom/prisma#build": {
|
"@calcom/prisma#build": {
|
||||||
|
|||||||
Reference in New Issue
Block a user