Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea6e105e6 | ||
|
|
a082cf73a0 | ||
|
|
61268f36b9 | ||
|
|
99cd0c5c41 | ||
|
|
2266301469 | ||
|
|
30b0012e6a | ||
|
|
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,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": "*",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { TestingModule } from "@nestjs/testing";
|
|||||||
import { Test } from "@nestjs/testing";
|
import { Test } from "@nestjs/testing";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
@@ -33,6 +34,7 @@ describe("AppController", () => {
|
|||||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
|
||||||
let apiKeyString: string;
|
let apiKeyString: string;
|
||||||
|
|
||||||
@@ -108,6 +110,14 @@ describe("AppController", () => {
|
|||||||
organization: { connect: { id: organization.id } },
|
organization: { connect: { id: organization.id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: organization.id } },
|
||||||
|
role: "OWNER",
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
app = moduleRef.createNestApplication();
|
app = moduleRef.createNestApplication();
|
||||||
await app.init();
|
await app.init();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
|
|||||||
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
import { 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
|
|||||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
|
||||||
import { RedisModule } from "@/modules/redis/redis.module";
|
import { RedisModule } from "@/modules/redis/redis.module";
|
||||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||||
import { UsersModule } from "@/modules/users/users.module";
|
import { UsersModule } from "@/modules/users/users.module";
|
||||||
@@ -22,7 +21,6 @@ import { PassportModule } from "@nestjs/passport";
|
|||||||
MembershipsModule,
|
MembershipsModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
DeploymentsModule,
|
DeploymentsModule,
|
||||||
ProfilesModule,
|
|
||||||
],
|
],
|
||||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||||
exports: [NextAuthGuard, ApiAuthGuard],
|
exports: [NextAuthGuard, ApiAuthGuard],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
|||||||
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
|
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
|
||||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||||
import { JwtService } from "@/modules/jwt/jwt.service";
|
import { JwtService } from "@/modules/jwt/jwt.service";
|
||||||
|
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
@@ -20,6 +21,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
|||||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||||
import { createRequest } from "node-mocks-http";
|
import { createRequest } from "node-mocks-http";
|
||||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||||
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||||
@@ -45,6 +47,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||||
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
|
||||||
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
|
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
|
||||||
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
|
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
|
||||||
@@ -65,6 +68,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
}),
|
}),
|
||||||
ProfilesModule,
|
ProfilesModule,
|
||||||
TokensModule,
|
TokensModule,
|
||||||
|
MembershipsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
MockedRedisService,
|
MockedRedisService,
|
||||||
@@ -91,6 +95,7 @@ describe("ApiAuthStrategy", () => {
|
|||||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
||||||
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
||||||
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
||||||
|
membershipRepositoryFixture = new MembershipRepositoryFixture(module);
|
||||||
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
|
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
|
||||||
organizationTwo = await teamRepositoryFixture.create({
|
organizationTwo = await teamRepositoryFixture.create({
|
||||||
name: `api-auth-organization-2-${randomString()}`,
|
name: `api-auth-organization-2-${randomString()}`,
|
||||||
@@ -121,6 +126,20 @@ describe("ApiAuthStrategy", () => {
|
|||||||
organization: { connect: { id: organizationTwo.id } },
|
organization: { connect: { id: organizationTwo.id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
user: { connect: { id: validOAuthUser.id } },
|
||||||
|
team: { connect: { id: organization.id } },
|
||||||
|
role: "OWNER",
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
user: { connect: { id: validOAuthUser.id } },
|
||||||
|
team: { connect: { id: organizationTwo.id } },
|
||||||
|
role: "OWNER",
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
logo: "logo-url",
|
logo: "logo-url",
|
||||||
name: "name",
|
name: "name",
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
|||||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||||
|
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
|
||||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||||
import { UsersService } from "@/modules/users/services/users.service";
|
import { UsersService } from "@/modules/users/services/users.service";
|
||||||
@@ -45,8 +45,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
private readonly userRepository: UsersRepository,
|
private readonly userRepository: UsersRepository,
|
||||||
private readonly apiKeyRepository: ApiKeysRepository,
|
private readonly apiKeyRepository: ApiKeysRepository,
|
||||||
private readonly oauthRepository: OAuthClientRepository,
|
private readonly oauthRepository: OAuthClientRepository,
|
||||||
private readonly profilesRepository: ProfilesRepository,
|
private readonly usersService: UsersService,
|
||||||
private readonly usersService: UsersService
|
private readonly membershipsRepository: MembershipsRepository
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
|||||||
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
|
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
const platformCreatorId =
|
||||||
|
(await this.membershipsRepository.findPlatformOwnerUserId(client.organizationId)) ||
|
||||||
|
(await this.membershipsRepository.findPlatformAdminUserId(client.organizationId));
|
||||||
|
|
||||||
if (!platformCreatorId) {
|
if (!platformCreatorId) {
|
||||||
throw new UnauthorizedException(
|
throw new UnauthorizedException(
|
||||||
|
|||||||
@@ -20,6 +20,36 @@ export class MembershipsRepository {
|
|||||||
return membership;
|
return membership;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findPlatformOwnerUserId(organizationId: number): Promise<number | undefined> {
|
||||||
|
const owner = await this.dbRead.prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
teamId: organizationId,
|
||||||
|
role: "OWNER",
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return owner?.userId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findPlatformAdminUserId(organizationId: number): Promise<number | undefined> {
|
||||||
|
const admin = await this.dbRead.prisma.membership.findFirst({
|
||||||
|
where: {
|
||||||
|
teamId: organizationId,
|
||||||
|
role: "ADMIN",
|
||||||
|
accepted: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return admin?.userId ?? undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async findMembershipByTeamId(teamId: number, userId: number) {
|
async findMembershipByTeamId(teamId: number, userId: number) {
|
||||||
const membership = await this.dbRead.prisma.membership.findUnique({
|
const membership = await this.dbRead.prisma.membership.findUnique({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
+10
@@ -15,6 +15,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||||
import * as request from "supertest";
|
import * as request from "supertest";
|
||||||
|
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||||
@@ -63,6 +64,7 @@ describe("OAuthFlow Endpoints", () => {
|
|||||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||||
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
|
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
|
||||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||||
|
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||||
|
|
||||||
let user: User;
|
let user: User;
|
||||||
let organization: Team;
|
let organization: Team;
|
||||||
@@ -89,6 +91,7 @@ describe("OAuthFlow Endpoints", () => {
|
|||||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||||
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
|
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
|
||||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||||
|
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||||
|
|
||||||
user = await usersRepositoryFixtures.create({
|
user = await usersRepositoryFixtures.create({
|
||||||
email: userEmail,
|
email: userEmail,
|
||||||
@@ -104,6 +107,13 @@ describe("OAuthFlow Endpoints", () => {
|
|||||||
movedFromUser: { connect: { id: user.id } },
|
movedFromUser: { connect: { id: user.id } },
|
||||||
organization: { connect: { id: organization.id } },
|
organization: { connect: { id: organization.id } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await membershipRepositoryFixture.create({
|
||||||
|
user: { connect: { id: user.id } },
|
||||||
|
team: { connect: { id: organization.id } },
|
||||||
|
role: "OWNER",
|
||||||
|
accepted: true,
|
||||||
|
});
|
||||||
oAuthClient = await createOAuthClient(organization.id);
|
oAuthClient = await createOAuthClient(organization.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+7
@@ -39,6 +39,7 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
Query,
|
Query,
|
||||||
|
Logger,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||||
|
|
||||||
@@ -66,6 +67,8 @@ export type EventTypeHandlerResponse = {
|
|||||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||||
export class OrganizationsEventTypesController {
|
export class OrganizationsEventTypesController {
|
||||||
|
private readonly logger = new Logger("OrganizationsEventTypesController");
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly organizationsEventTypesService: OrganizationsEventTypesService,
|
private readonly organizationsEventTypesService: OrganizationsEventTypesService,
|
||||||
private readonly inputService: InputOrganizationsEventTypesService,
|
private readonly inputService: InputOrganizationsEventTypesService,
|
||||||
@@ -88,6 +91,10 @@ export class OrganizationsEventTypesController {
|
|||||||
teamId,
|
teamId,
|
||||||
bodyEventType
|
bodyEventType
|
||||||
);
|
);
|
||||||
|
this.logger.debug(
|
||||||
|
"nl debug - create org team event type - transformedBody",
|
||||||
|
JSON.stringify(transformedBody, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
const eventType = await this.organizationsEventTypesService.createOrganizationTeamEventType(
|
const eventType = await this.organizationsEventTypesService.createOrganizationTeamEventType(
|
||||||
user,
|
user,
|
||||||
|
|||||||
+4
@@ -47,6 +47,10 @@ export class OrganizationsEventTypesService {
|
|||||||
prisma: this.dbWrite.prisma,
|
prisma: this.dbWrite.prisma,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.logger.debug(
|
||||||
|
"nl debug - create org team event type - eventTypeCreated",
|
||||||
|
JSON.stringify(eventTypeCreated, null, 2)
|
||||||
|
);
|
||||||
|
|
||||||
return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user, true);
|
return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user, true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export class TeamsEventTypesService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId);
|
const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId);
|
||||||
|
this.logger.debug("nl debug - update team event type - eventType", JSON.stringify(eventType, null, 2));
|
||||||
|
|
||||||
if (!eventType) {
|
if (!eventType) {
|
||||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||||
import type { PageProps } from "app/_types";
|
import type { PageProps } from "app/_types";
|
||||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||||
|
|
||||||
|
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
import { validStatuses } from "~/bookings/lib/validStatuses";
|
import { validStatuses } from "~/bookings/lib/validStatuses";
|
||||||
import BookingsList from "~/bookings/views/bookings-listing-view";
|
import BookingsList from "~/bookings/views/bookings-listing-view";
|
||||||
|
|
||||||
@@ -26,10 +31,11 @@ const Page = async ({ params }: PageProps) => {
|
|||||||
redirect("/bookings/upcoming");
|
redirect("/bookings/upcoming");
|
||||||
}
|
}
|
||||||
const t = await getTranslate();
|
const t = await getTranslate();
|
||||||
|
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||||
<BookingsList status={parsed.data.status} />
|
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
|
||||||
</ShellMainAppDir>
|
</ShellMainAppDir>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -910,7 +910,7 @@ export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: Rerout
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||||
<DialogContent enableOverflow preventCloseOnOutsideClick>
|
<DialogContent preventCloseOnOutsideClick>
|
||||||
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
|
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
|
||||||
<RerouteDialogContentAndFooter
|
<RerouteDialogContentAndFooter
|
||||||
booking={teamEventTypeBooking}
|
booking={teamEventTypeBooking}
|
||||||
|
|||||||
@@ -3,42 +3,48 @@
|
|||||||
// 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";
|
||||||
|
|
||||||
Sentry.init({
|
if (process.env.NODE_ENV === "production") {
|
||||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
|
Sentry.init({
|
||||||
|
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
|
||||||
|
|
||||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||||
|
|
||||||
// Define how likely Replay events are sampled.
|
// Define how likely Replay events are sampled.
|
||||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||||
// in development and sample at a lower rate in production
|
// in development and sample at a lower rate in production
|
||||||
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
|
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||||
|
|
||||||
// Define how likely Replay events are sampled when an error occurs.
|
// Define how likely Replay events are sampled when an error occurs.
|
||||||
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
|
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||||
|
|
||||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||||
debug: !!process.env.SENTRY_DEBUG,
|
debug: !!process.env.SENTRY_DEBUG,
|
||||||
beforeSend(event) {
|
beforeSend(event) {
|
||||||
if (
|
if (
|
||||||
event.exception?.values?.some(
|
event.exception?.values?.some(
|
||||||
(e) =>
|
(e) =>
|
||||||
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
|
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
|
||||||
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
|
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
|
||||||
e.value?.includes("Non-Error promise rejection captured with") ||
|
e.value?.includes("Non-Error promise rejection captured with") ||
|
||||||
e.value?.includes("Object Not Found Matching Id")
|
e.value?.includes("Object Not Found Matching Id")
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
event.tags = {
|
event.tags = {
|
||||||
...event.tags,
|
...event.tags,
|
||||||
errorSource: "client",
|
errorSource: "client",
|
||||||
};
|
};
|
||||||
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.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
if (process.env.NODE_ENV === "production") {
|
||||||
await import("./sentry.server.config");
|
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
}
|
await import("./sentry.server.config");
|
||||||
|
}
|
||||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
|
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 });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams, usePathname } from "next/navigation";
|
||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
|
|
||||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ZMultiSelectFilterValue,
|
ZMultiSelectFilterValue,
|
||||||
ZDateRangeFilterValue,
|
ZDateRangeFilterValue,
|
||||||
ZTextFilterValue,
|
ZTextFilterValue,
|
||||||
|
type SystemFilterSegment,
|
||||||
} from "@calcom/features/data-table";
|
} from "@calcom/features/data-table";
|
||||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
@@ -55,11 +56,45 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
|
|||||||
|
|
||||||
type BookingsProps = {
|
type BookingsProps = {
|
||||||
status: (typeof validStatuses)[number];
|
status: (typeof validStatuses)[number];
|
||||||
|
userId?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function useSystemSegments(userId?: number) {
|
||||||
|
const { t } = useLocale();
|
||||||
|
|
||||||
|
const systemSegments: SystemFilterSegment[] = useMemo(() => {
|
||||||
|
if (!userId) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "my_bookings",
|
||||||
|
name: t("my_bookings"),
|
||||||
|
type: "system",
|
||||||
|
activeFilters: [
|
||||||
|
{
|
||||||
|
f: "userId",
|
||||||
|
v: {
|
||||||
|
type: ColumnFilterType.MULTI_SELECT,
|
||||||
|
data: [userId],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perPage: 10,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [userId, t]);
|
||||||
|
|
||||||
|
return systemSegments;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Bookings(props: BookingsProps) {
|
export default function Bookings(props: BookingsProps) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const systemSegments = useSystemSegments(props.userId);
|
||||||
return (
|
return (
|
||||||
<DataTableProvider useSegments={useSegments}>
|
<DataTableProvider
|
||||||
|
useSegments={useSegments}
|
||||||
|
systemSegments={systemSegments}
|
||||||
|
tableIdentifier={pathname || undefined}>
|
||||||
<BookingsContent {...props} />
|
<BookingsContent {...props} />
|
||||||
</DataTableProvider>
|
</DataTableProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -228,16 +228,20 @@ 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
|
||||||
eventTypeId: eventType.id,
|
? true
|
||||||
team: eventType.team,
|
: await shouldHideBrandingForEvent({
|
||||||
owner: eventType.users[0] ?? null,
|
eventTypeId: eventType.id,
|
||||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
team: eventType.team,
|
||||||
}),
|
owner: eventType.users[0] ?? null,
|
||||||
|
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||||
|
}),
|
||||||
profile,
|
profile,
|
||||||
eventType,
|
eventType,
|
||||||
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
|
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ const OnboardingPage = (props: PageProps) => {
|
|||||||
"dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand:#111827] dark:[--cal-brand:#FFFFFF]",
|
"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}>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ColumnFilterType,
|
ColumnFilterType,
|
||||||
type FilterableColumn,
|
type FilterableColumn,
|
||||||
} from "@calcom/features/data-table";
|
} from "@calcom/features/data-table";
|
||||||
|
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||||
import {
|
import {
|
||||||
AverageEventDurationChart,
|
AverageEventDurationChart,
|
||||||
BookingKPICards,
|
BookingKPICards,
|
||||||
@@ -31,7 +32,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
|||||||
|
|
||||||
export default function InsightsPage({ timeZone }: { timeZone: string }) {
|
export default function InsightsPage({ timeZone }: { timeZone: string }) {
|
||||||
return (
|
return (
|
||||||
<DataTableProvider timeZone={timeZone}>
|
<DataTableProvider useSegments={useSegments} timeZone={timeZone}>
|
||||||
<InsightsOrgTeamsProvider>
|
<InsightsOrgTeamsProvider>
|
||||||
<InsightsPageContent />
|
<InsightsPageContent />
|
||||||
</InsightsOrgTeamsProvider>
|
</InsightsOrgTeamsProvider>
|
||||||
|
|||||||
+490
-478
File diff suppressed because it is too large
Load Diff
@@ -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]");
|
|
||||||
@@ -31,6 +31,7 @@ export async function applySelectFilter(page: Page, columnId: string, value: str
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function selectOptionValue(page: Page, columnId: string, value: string) {
|
export async function selectOptionValue(page: Page, columnId: string, value: string) {
|
||||||
|
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).waitFor();
|
||||||
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click();
|
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,3 +177,71 @@ export async function listSegments(page: Page): Promise<string[]> {
|
|||||||
export function locateSelectedSegmentName(page: Page, expectedName: string) {
|
export function locateSelectedSegmentName(page: Page, expectedName: string) {
|
||||||
return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName });
|
return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a system segment is visible in the dropdown
|
||||||
|
*/
|
||||||
|
export async function expectSystemSegmentVisible(page: Page, segmentName: string) {
|
||||||
|
await page.getByTestId("filter-segment-select").click();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="filter-segment-select-content"]').getByText("Default")
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page
|
||||||
|
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
|
||||||
|
.filter({ hasText: segmentName })
|
||||||
|
).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that no segment is currently selected
|
||||||
|
*/
|
||||||
|
export async function expectSegmentCleared(page: Page) {
|
||||||
|
// Check that no segment is selected (button shows default text)
|
||||||
|
const segmentSelect = page.getByTestId("filter-segment-select");
|
||||||
|
const buttonText = await segmentSelect.textContent();
|
||||||
|
expect(buttonText?.trim()).toEqual("Segment");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that a specific segment is currently selected
|
||||||
|
*/
|
||||||
|
export async function expectSegmentSelected(page: Page, segmentName: string) {
|
||||||
|
await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Duplicate an existing segment
|
||||||
|
*/
|
||||||
|
export async function duplicateSegment(page: Page, originalName: string, newName: string) {
|
||||||
|
await openSegmentSubmenu(page, originalName);
|
||||||
|
await page.getByTestId("filter-segment-select-submenu-content").getByText("Duplicate").click();
|
||||||
|
await page.getByTestId("duplicate-segment-name").fill(newName);
|
||||||
|
await page.getByRole("button", { name: "Duplicate" }).click();
|
||||||
|
await expect(page.getByText("Filter segment duplicated")).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename an existing segment
|
||||||
|
*/
|
||||||
|
export async function renameSegment(page: Page, originalName: string, newName: string) {
|
||||||
|
await openSegmentSubmenu(page, originalName);
|
||||||
|
await page.getByTestId("filter-segment-select-submenu-content").getByText("Rename").click();
|
||||||
|
await page.getByTestId("rename-segment-name").fill(newName);
|
||||||
|
await page.getByRole("button", { name: "Save" }).click();
|
||||||
|
await expect(page.getByText("Filter segment updated")).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a segment group (like "Default" or "Personal") is visible
|
||||||
|
*/
|
||||||
|
export async function expectSegmentGroupVisible(page: Page, groupName: string) {
|
||||||
|
await page.getByTestId("filter-segment-select").click();
|
||||||
|
await expect(
|
||||||
|
page.locator('[data-testid="filter-segment-select-content"]').getByText(groupName)
|
||||||
|
).toBeVisible();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
|
|||||||
import { randomString } from "@calcom/lib/random";
|
import { randomString } from "@calcom/lib/random";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
import { addFilter, openFilter, clearFilters } from "./filter-helpers";
|
import { clearFilters, applySelectFilter } from "./filter-helpers";
|
||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
|
|
||||||
test.describe.configure({ mode: "parallel" });
|
test.describe.configure({ mode: "parallel" });
|
||||||
@@ -202,25 +202,11 @@ test.describe("Insights", async () => {
|
|||||||
await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click();
|
await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
// Choose User filter item from dropdown
|
await applySelectFilter(page, "userId", member.username || "");
|
||||||
await addFilter(page, "userId");
|
|
||||||
|
|
||||||
// Wait for the URL to include userId
|
|
||||||
await page.waitForURL((url) => url.toString().includes("userId"));
|
|
||||||
|
|
||||||
// Click User filter to see a user list
|
|
||||||
await openFilter(page, "userId");
|
|
||||||
|
|
||||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
|
|
||||||
|
|
||||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
|
|
||||||
|
|
||||||
// press escape button to close the filter
|
|
||||||
await page.keyboard.press("Escape");
|
|
||||||
|
|
||||||
await clearFilters(page);
|
await clearFilters(page);
|
||||||
|
|
||||||
await expect(page.url()).not.toContain("userId");
|
await expect(page).not.toHaveURL(/[?&]userId=/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should test download button", async ({ page, users }) => {
|
test("should test download button", async ({ page, users }) => {
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { expect } from "@playwright/test";
|
||||||
|
import type { Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { addFilter, selectSegment, expectSegmentCleared, expectSegmentSelected } from "./filter-helpers";
|
||||||
|
import { test } from "./lib/fixtures";
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "parallel" });
|
||||||
|
|
||||||
|
test.afterEach(async ({ users }) => {
|
||||||
|
await users.deleteAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate to bookings page with specific status
|
||||||
|
*/
|
||||||
|
async function navigateToBookings(page: Page, status = "upcoming") {
|
||||||
|
// Wait for the bookings API response like existing tests do
|
||||||
|
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||||
|
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||||
|
);
|
||||||
|
await page.goto(`/bookings/${status}`, { waitUntil: "domcontentloaded" });
|
||||||
|
await bookingsGetResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("System Segments", () => {
|
||||||
|
test.describe("Core Functionality", () => {
|
||||||
|
test("My Bookings system segment filters to current user's bookings only", async ({ page, users }) => {
|
||||||
|
const user1 = await users.create({ username: "user1" });
|
||||||
|
|
||||||
|
// Create some bookings for user (this would need booking setup)
|
||||||
|
// For now, we'll test the filter application
|
||||||
|
await user1.apiLogin();
|
||||||
|
await navigateToBookings(page);
|
||||||
|
|
||||||
|
await selectSegment(page, "My Bookings");
|
||||||
|
await expectSegmentSelected(page, "My Bookings");
|
||||||
|
|
||||||
|
// Verify the userId filter is applied (check URL or filter indicators)
|
||||||
|
const url = page.url();
|
||||||
|
expect(url).toContain("activeFilters");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("System segments show only Duplicate option in submenu", async ({ page, users }) => {
|
||||||
|
const user = await users.create();
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
await navigateToBookings(page);
|
||||||
|
|
||||||
|
await page.getByTestId("filter-segment-select").click();
|
||||||
|
await page
|
||||||
|
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
|
||||||
|
.filter({ hasText: "My Bookings" })
|
||||||
|
.locator('[data-testid="filter-segment-select-submenu-button"]')
|
||||||
|
.click();
|
||||||
|
|
||||||
|
const submenu = page.getByTestId("filter-segment-select-submenu-content");
|
||||||
|
await expect(submenu.getByText("Duplicate")).toBeVisible();
|
||||||
|
await expect(submenu.getByText("Rename")).toBeHidden();
|
||||||
|
await expect(submenu.getByText("Delete")).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("State Persistence", () => {
|
||||||
|
test("System segment selection persists across page visits", async ({ page, users }) => {
|
||||||
|
const owner = await users.create(undefined, { hasTeam: true });
|
||||||
|
await owner.apiLogin();
|
||||||
|
|
||||||
|
// Visit bookings page and select system segment
|
||||||
|
await navigateToBookings(page);
|
||||||
|
await selectSegment(page, "My Bookings");
|
||||||
|
await expectSegmentSelected(page, "My Bookings");
|
||||||
|
|
||||||
|
// Revisit the page - should preserve selection
|
||||||
|
await navigateToBookings(page);
|
||||||
|
await expectSegmentSelected(page, "My Bookings");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Cleared system segment state persists (no default selection after manual clear)", async ({
|
||||||
|
page,
|
||||||
|
users,
|
||||||
|
}) => {
|
||||||
|
const owner = await users.create(undefined, { hasTeam: true });
|
||||||
|
await owner.apiLogin();
|
||||||
|
|
||||||
|
await navigateToBookings(page);
|
||||||
|
|
||||||
|
// Select system segment
|
||||||
|
await selectSegment(page, "My Bookings");
|
||||||
|
await expectSegmentSelected(page, "My Bookings");
|
||||||
|
|
||||||
|
// Unselect system segment
|
||||||
|
await selectSegment(page, "My Bookings");
|
||||||
|
|
||||||
|
// Revisit the page - should NOT have any segment selected
|
||||||
|
await navigateToBookings(page);
|
||||||
|
await expectSegmentCleared(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe("Auto-clear Behavior", () => {
|
||||||
|
test("Manual filter clears system segment selection", async ({ page, users }) => {
|
||||||
|
const user = await users.create(undefined, { hasTeam: true });
|
||||||
|
await user.apiLogin();
|
||||||
|
|
||||||
|
await navigateToBookings(page);
|
||||||
|
|
||||||
|
// Select system segment
|
||||||
|
await selectSegment(page, "My Bookings");
|
||||||
|
await expectSegmentSelected(page, "My Bookings");
|
||||||
|
|
||||||
|
// Apply manual filter - this should clear system segment
|
||||||
|
await addFilter(page, "eventTypeId");
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expectSegmentCleared(page);
|
||||||
|
|
||||||
|
// Verify filter is still applied
|
||||||
|
await expect(page.getByTestId("filter-popover-trigger-eventTypeId")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,7 @@
|
|||||||
"webhook_metadata": "Metadata",
|
"webhook_metadata": "Metadata",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"booking_status": "Booking status",
|
"booking_status": "Booking status",
|
||||||
|
"my_bookings": "My Bookings",
|
||||||
|
"phone": "Phone",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"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,3 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* TODO: Consolidate this file with BookingLocationService and add tests
|
||||||
|
*/
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -58,7 +61,7 @@ export type EventLocationTypeFromApp = Ensure<
|
|||||||
export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp;
|
export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp;
|
||||||
|
|
||||||
export const DailyLocationType = "integrations:daily";
|
export const DailyLocationType = "integrations:daily";
|
||||||
|
export const CalVideoLocationType = DailyLocationType;
|
||||||
export const MeetLocationType = "integrations:google:meet";
|
export const MeetLocationType = "integrations:google:meet";
|
||||||
|
|
||||||
export const MSTeamsLocationType = "integrations:office365_video";
|
export const MSTeamsLocationType = "integrations:office365_video";
|
||||||
@@ -380,7 +383,7 @@ export const getLocationValueForDB = (
|
|||||||
eventLocations: LocationObject[]
|
eventLocations: LocationObject[]
|
||||||
) => {
|
) => {
|
||||||
let bookingLocation = bookingLocationTypeOrValue;
|
let bookingLocation = bookingLocationTypeOrValue;
|
||||||
let conferenceCredentialId = undefined;
|
let conferenceCredentialId: number | undefined = undefined;
|
||||||
|
|
||||||
eventLocations.forEach((location) => {
|
eventLocations.forEach((location) => {
|
||||||
if (location.type === bookingLocationTypeOrValue) {
|
if (location.type === bookingLocationTypeOrValue) {
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import { stringify } from "querystring";
|
import { stringify } from "querystring";
|
||||||
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
const params = {
|
const params = {
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
client_id,
|
client_id,
|
||||||
redirect_uri: `${WEBAPP_URL}/api/integrations/${config.slug}/callback`,
|
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`,
|
||||||
scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration"
|
scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration"
|
||||||
state: "",
|
state: "",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
|
|
||||||
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/
|
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/
|
||||||
|
|
||||||
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/${config.slug}/callback`);
|
const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`);
|
||||||
const params = new URLSearchParams([
|
const params = new URLSearchParams([
|
||||||
["grant_type", "authorization_code"],
|
["grant_type", "authorization_code"],
|
||||||
["client_id", client_id],
|
["client_id", client_id],
|
||||||
|
|||||||
@@ -404,23 +404,25 @@ const BookerComponent = ({
|
|||||||
src={orgBannerUrl}
|
src={orgBannerUrl}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<EventMeta
|
{!hideEventTypeDetails && (
|
||||||
selectedTimeslot={selectedTimeslot}
|
<EventMeta
|
||||||
classNames={{
|
selectedTimeslot={selectedTimeslot}
|
||||||
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
|
classNames={{
|
||||||
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
|
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
|
||||||
eventMetaTimezoneSelect:
|
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
|
||||||
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
|
eventMetaTimezoneSelect:
|
||||||
}}
|
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
|
||||||
event={event.data}
|
}}
|
||||||
isPending={event.isPending}
|
event={event.data}
|
||||||
isPlatform={isPlatform}
|
isPending={event.isPending}
|
||||||
isPrivateLink={!!hashedLink}
|
isPlatform={isPlatform}
|
||||||
locale={userLocale}
|
isPrivateLink={!!hashedLink}
|
||||||
timeZones={timeZones}
|
locale={userLocale}
|
||||||
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
|
timeZones={timeZones}
|
||||||
{eventMetaChildren}
|
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
|
||||||
</EventMeta>
|
{eventMetaChildren}
|
||||||
|
</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,
|
||||||
|
|||||||
@@ -3,9 +3,13 @@
|
|||||||
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import debounce from "lodash/debounce";
|
import debounce from "lodash/debounce";
|
||||||
|
// eslint-disable-next-line no-restricted-imports
|
||||||
|
import isEqual from "lodash/isEqual";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useQueryState } from "nuqs";
|
import { useQueryState } from "nuqs";
|
||||||
import { createContext, useCallback, useEffect, useRef, useMemo } from "react";
|
import { useState, createContext, useCallback, useEffect, useRef, useMemo } from "react";
|
||||||
|
|
||||||
|
import { useElementByClassName } from "@calcom/lib/hooks/useElementByClassName";
|
||||||
|
|
||||||
import { useSegmentsNoop } from "./hooks/useSegmentsNoop";
|
import { useSegmentsNoop } from "./hooks/useSegmentsNoop";
|
||||||
import {
|
import {
|
||||||
@@ -22,8 +26,12 @@ import {
|
|||||||
import {
|
import {
|
||||||
type FilterValue,
|
type FilterValue,
|
||||||
type FilterSegmentOutput,
|
type FilterSegmentOutput,
|
||||||
|
type SystemFilterSegment,
|
||||||
|
type CombinedFilterSegment,
|
||||||
|
type SegmentIdentifier,
|
||||||
type ActiveFilters,
|
type ActiveFilters,
|
||||||
type UseSegments,
|
type UseSegments,
|
||||||
|
SYSTEM_SEGMENT_PREFIX,
|
||||||
} from "./lib/types";
|
} from "./lib/types";
|
||||||
import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils";
|
import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils";
|
||||||
|
|
||||||
@@ -55,10 +63,10 @@ export type DataTableContextType = {
|
|||||||
offset: number;
|
offset: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
|
||||||
segments: FilterSegmentOutput[];
|
segments: CombinedFilterSegment[];
|
||||||
selectedSegment: FilterSegmentOutput | undefined;
|
selectedSegment: CombinedFilterSegment | undefined;
|
||||||
segmentId: number | undefined;
|
segmentId: SegmentIdentifier | null;
|
||||||
setSegmentId: (id: number | null) => void;
|
setSegmentId: (id: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => void;
|
||||||
canSaveSegment: boolean;
|
canSaveSegment: boolean;
|
||||||
isSegmentEnabled: boolean;
|
isSegmentEnabled: boolean;
|
||||||
|
|
||||||
@@ -78,7 +86,8 @@ interface DataTableProviderProps {
|
|||||||
defaultPageSize?: number;
|
defaultPageSize?: number;
|
||||||
segments?: FilterSegmentOutput[];
|
segments?: FilterSegmentOutput[];
|
||||||
timeZone?: string;
|
timeZone?: string;
|
||||||
preferredSegmentId?: number | null;
|
preferredSegmentId?: SegmentIdentifier | null;
|
||||||
|
systemSegments?: SystemFilterSegment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DataTableProvider({
|
export function DataTableProvider({
|
||||||
@@ -90,8 +99,18 @@ export function DataTableProvider({
|
|||||||
segments: providedSegments,
|
segments: providedSegments,
|
||||||
timeZone,
|
timeZone,
|
||||||
preferredSegmentId,
|
preferredSegmentId,
|
||||||
|
systemSegments,
|
||||||
}: DataTableProviderProps) {
|
}: DataTableProviderProps) {
|
||||||
|
const pathname = usePathname() as string | null;
|
||||||
|
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
|
||||||
|
if (!tableIdentifier) {
|
||||||
|
throw new Error("tableIdentifier is required");
|
||||||
|
}
|
||||||
|
|
||||||
const filterToOpen = useRef<string | undefined>(undefined);
|
const filterToOpen = useRef<string | undefined>(undefined);
|
||||||
|
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
|
||||||
|
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
|
||||||
|
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
|
||||||
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
||||||
const [sorting, setSorting] = useQueryState("sorting", sortingParser);
|
const [sorting, setSorting] = useQueryState("sorting", sortingParser);
|
||||||
const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>(
|
const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>(
|
||||||
@@ -99,34 +118,172 @@ export function DataTableProvider({
|
|||||||
columnVisibilityParser
|
columnVisibilityParser
|
||||||
);
|
);
|
||||||
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
|
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
|
||||||
const [segmentId, setSegmentId] = useQueryState(
|
const initialSegmentId = useMemo(
|
||||||
"segment",
|
() => (preferredSegmentId ? String(preferredSegmentId.id) : null),
|
||||||
segmentIdParser.withDefault(preferredSegmentId ?? -1)
|
[preferredSegmentId]
|
||||||
);
|
);
|
||||||
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
|
const [segmentId, _setSegmentId] = useQueryState(
|
||||||
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
|
"segment",
|
||||||
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
|
initialSegmentId ? segmentIdParser.withDefault(initialSegmentId) : segmentIdParser
|
||||||
|
);
|
||||||
|
const {
|
||||||
|
segments,
|
||||||
|
preferredSegmentId: fetchedPreferredSegmentId,
|
||||||
|
setPreference: setSegmentPreference,
|
||||||
|
isSegmentEnabled,
|
||||||
|
isSuccess: isSegmentFetchedSuccessfully,
|
||||||
|
} = useSegments({
|
||||||
|
tableIdentifier,
|
||||||
|
providedSegments,
|
||||||
|
systemSegments,
|
||||||
|
});
|
||||||
|
|
||||||
|
const findSelectedSegment = useCallback(
|
||||||
|
(segmentId: string) => {
|
||||||
|
return segments.find((segment) => {
|
||||||
|
if (
|
||||||
|
segment.type === "system" &&
|
||||||
|
segmentId &&
|
||||||
|
segmentId.startsWith(SYSTEM_SEGMENT_PREFIX) &&
|
||||||
|
segment.id === segmentId
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
} else if (segment.type === "user") {
|
||||||
|
const segmentIdNumber = parseInt(segmentId, 10);
|
||||||
|
return segment.id === segmentIdNumber;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[segments]
|
||||||
|
);
|
||||||
|
|
||||||
|
const segmentIdObject = useMemo(() => {
|
||||||
|
if (segmentId && segmentId.startsWith(SYSTEM_SEGMENT_PREFIX)) {
|
||||||
|
return {
|
||||||
|
id: segmentId,
|
||||||
|
type: "system" as const,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const segmentIdNumber = parseInt(segmentId, 10);
|
||||||
|
if (!Number.isNaN(segmentIdNumber)) {
|
||||||
|
return {
|
||||||
|
id: segmentIdNumber,
|
||||||
|
type: "user" as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [segmentId]);
|
||||||
|
|
||||||
|
const [selectedSegment, setSelectedSegment] = useState<CombinedFilterSegment | undefined>(
|
||||||
|
findSelectedSegment(segmentId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const setSegmentId = useCallback(
|
||||||
|
(segmentId: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => {
|
||||||
|
if (!segmentId) {
|
||||||
|
_setSegmentId(null);
|
||||||
|
setSelectedSegment(undefined);
|
||||||
|
setSegmentPreference({
|
||||||
|
tableIdentifier,
|
||||||
|
segmentId: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segment = providedSegment || findSelectedSegment(String(segmentId.id));
|
||||||
|
if (!segment) {
|
||||||
|
// If segmentId is invalid (or not found), clear the segmentId from the query params,
|
||||||
|
// but we still keep all the other states like activeFilters, etc.
|
||||||
|
// This is useful when someone shares a URL that is inaccessible to someone else.
|
||||||
|
_setSegmentId(null);
|
||||||
|
setSelectedSegment(undefined);
|
||||||
|
setSegmentPreference({
|
||||||
|
tableIdentifier,
|
||||||
|
segmentId: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_setSegmentId(String(segmentId.id));
|
||||||
|
setSelectedSegment(segment);
|
||||||
|
setSegmentPreference({
|
||||||
|
tableIdentifier,
|
||||||
|
segmentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// apply the segment
|
||||||
|
setActiveFilters(segment.activeFilters);
|
||||||
|
if (segment.sorting) {
|
||||||
|
setSorting(segment.sorting);
|
||||||
|
}
|
||||||
|
if (segment.columnVisibility) {
|
||||||
|
setColumnVisibility(segment.columnVisibility);
|
||||||
|
}
|
||||||
|
if (segment.columnSizing) {
|
||||||
|
setColumnSizing(segment.columnSizing);
|
||||||
|
}
|
||||||
|
if (segment.perPage !== undefined) {
|
||||||
|
setPageSize(segment.perPage);
|
||||||
|
}
|
||||||
|
if (segment.searchTerm !== undefined) {
|
||||||
|
setSearchTerm(segment.searchTerm);
|
||||||
|
}
|
||||||
|
setPageIndex(0);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
_setSegmentId,
|
||||||
|
setSelectedSegment,
|
||||||
|
setSegmentPreference,
|
||||||
|
tableIdentifier,
|
||||||
|
findSelectedSegment,
|
||||||
|
setActiveFilters,
|
||||||
|
setSorting,
|
||||||
|
setColumnVisibility,
|
||||||
|
setColumnSizing,
|
||||||
|
setPageSize,
|
||||||
|
setSearchTerm,
|
||||||
|
setPageIndex,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSegmentFetchedSuccessfully) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If the preferred segment id has been fetched
|
||||||
|
// and no segment id has been selected yet,
|
||||||
|
// then we set it.
|
||||||
|
if (fetchedPreferredSegmentId && !segmentId) {
|
||||||
|
setSegmentId(fetchedPreferredSegmentId);
|
||||||
|
} else if (segmentId) {
|
||||||
|
setSelectedSegment(findSelectedSegment(segmentId));
|
||||||
|
}
|
||||||
|
// We intentionally have only `isSegmentFetchedSuccessfully`
|
||||||
|
// in the dependency array.
|
||||||
|
}, [isSegmentFetchedSuccessfully]);
|
||||||
|
|
||||||
|
const clearSystemSegmentSelectionIfExists = useCallback(() => {
|
||||||
|
if (selectedSegment?.type === "system") {
|
||||||
|
setSegmentId(null);
|
||||||
|
}
|
||||||
|
}, [selectedSegment, setSegmentId]);
|
||||||
|
|
||||||
const setDebouncedSearchTerm = useMemo(
|
const setDebouncedSearchTerm = useMemo(
|
||||||
() => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500),
|
() => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500),
|
||||||
[setSearchTerm]
|
[setSearchTerm]
|
||||||
);
|
);
|
||||||
|
|
||||||
const pathname = usePathname() as string | null;
|
|
||||||
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
|
|
||||||
if (!tableIdentifier) {
|
|
||||||
throw new Error("tableIdentifier is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const addFilter = useCallback(
|
const addFilter = useCallback(
|
||||||
(columnId: string) => {
|
(columnId: string) => {
|
||||||
if (!activeFilters?.some((filter) => filter.f === columnId)) {
|
if (!activeFilters?.some((filter) => filter.f === columnId)) {
|
||||||
// do not reset the page to 0 here,
|
// do not reset the page to 0 here,
|
||||||
// because we don't have the filter value yet (`v: undefined`)
|
// because we don't have the filter value yet (`v: undefined`)
|
||||||
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
|
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
|
||||||
|
clearSystemSegmentSelectionIfExists();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[activeFilters, setActiveFilters]
|
[activeFilters, setActiveFilters, clearSystemSegmentSelectionIfExists]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setPageIndexWrapper = useCallback(
|
const setPageIndexWrapper = useCallback(
|
||||||
@@ -151,8 +308,9 @@ export function DataTableProvider({
|
|||||||
}
|
}
|
||||||
return newFilters;
|
return newFilters;
|
||||||
});
|
});
|
||||||
|
clearSystemSegmentSelectionIfExists();
|
||||||
},
|
},
|
||||||
[setActiveFilters, setPageIndex]
|
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeFilter = useCallback(
|
const removeFilter = useCallback(
|
||||||
@@ -162,61 +320,72 @@ export function DataTableProvider({
|
|||||||
const remainingFilters = prev.filter((filter) => filter.f !== columnId);
|
const remainingFilters = prev.filter((filter) => filter.f !== columnId);
|
||||||
return remainingFilters.length === 0 ? null : remainingFilters;
|
return remainingFilters.length === 0 ? null : remainingFilters;
|
||||||
});
|
});
|
||||||
|
clearSystemSegmentSelectionIfExists();
|
||||||
},
|
},
|
||||||
[setActiveFilters, setPageIndex]
|
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
|
||||||
);
|
);
|
||||||
|
|
||||||
const setPageSizeAndGoToFirstPage = useCallback(
|
const setPageSizeAndGoToFirstPage = useCallback(
|
||||||
(newPageSize: number | null) => {
|
(newPageSize: number | null) => {
|
||||||
setPageSize(newPageSize === defaultPageSize ? null : newPageSize);
|
setPageSize(newPageSize === defaultPageSize ? null : newPageSize);
|
||||||
setPageIndex(null);
|
setPageIndex(null);
|
||||||
|
clearSystemSegmentSelectionIfExists();
|
||||||
},
|
},
|
||||||
[setPageSize, setPageIndex, defaultPageSize]
|
[setPageSize, setPageIndex, defaultPageSize, clearSystemSegmentSelectionIfExists]
|
||||||
);
|
|
||||||
|
|
||||||
const { segments, selectedSegment, canSaveSegment, setAndPersistSegmentId, isSegmentEnabled } = useSegments(
|
|
||||||
{
|
|
||||||
tableIdentifier,
|
|
||||||
activeFilters,
|
|
||||||
sorting,
|
|
||||||
columnVisibility,
|
|
||||||
columnSizing,
|
|
||||||
pageSize,
|
|
||||||
searchTerm,
|
|
||||||
defaultPageSize,
|
|
||||||
segmentId,
|
|
||||||
setSegmentId,
|
|
||||||
setActiveFilters,
|
|
||||||
setSorting,
|
|
||||||
setColumnVisibility,
|
|
||||||
setColumnSizing,
|
|
||||||
setPageSize,
|
|
||||||
setPageIndex,
|
|
||||||
setSearchTerm,
|
|
||||||
segments: providedSegments,
|
|
||||||
preferredSegmentId,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearAll = useCallback(
|
const clearAll = useCallback(
|
||||||
(exclude?: string[]) => {
|
(exclude?: string[]) => {
|
||||||
setAndPersistSegmentId(null);
|
setSegmentId(null);
|
||||||
setPageIndex(null);
|
setPageIndex(null);
|
||||||
setActiveFilters((prev) => {
|
setActiveFilters((prev) => {
|
||||||
const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f));
|
const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f));
|
||||||
return remainingFilters.length === 0 ? null : remainingFilters;
|
return remainingFilters.length === 0 ? null : remainingFilters;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setActiveFilters, setPageIndex, setAndPersistSegmentId]
|
[setActiveFilters, setPageIndex, setSegmentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const ctaContainerRef = useRef<HTMLDivElement | null>(null);
|
// Check if current state differs from selected segment
|
||||||
useEffect(() => {
|
const hasStateChanged = useMemo(() => {
|
||||||
if (ctaContainerClassName) {
|
if (!selectedSegment) return false;
|
||||||
const element = document.getElementsByClassName(ctaContainerClassName)[0] as HTMLDivElement;
|
|
||||||
ctaContainerRef.current = element;
|
return (
|
||||||
|
!isEqual(activeFilters, selectedSegment.activeFilters) ||
|
||||||
|
!isEqual(sorting, selectedSegment.sorting) ||
|
||||||
|
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
|
||||||
|
!isEqual(columnSizing, selectedSegment.columnSizing) ||
|
||||||
|
!isEqual(pageSize, selectedSegment.perPage) ||
|
||||||
|
!isEqual(searchTerm || "", selectedSegment.searchTerm || "")
|
||||||
|
);
|
||||||
|
}, [selectedSegment, activeFilters, sorting, columnVisibility, columnSizing, pageSize, searchTerm]);
|
||||||
|
|
||||||
|
const canSaveSegment = useMemo(() => {
|
||||||
|
if (selectedSegment) {
|
||||||
|
return hasStateChanged;
|
||||||
}
|
}
|
||||||
}, [ctaContainerClassName]);
|
// if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
|
||||||
|
return (
|
||||||
|
activeFilters.length > 0 ||
|
||||||
|
sorting.length > 0 ||
|
||||||
|
Object.keys(columnVisibility).length > 0 ||
|
||||||
|
Object.keys(columnSizing).length > 0 ||
|
||||||
|
pageSize !== defaultPageSize ||
|
||||||
|
searchTerm?.length > 0
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
selectedSegment,
|
||||||
|
activeFilters,
|
||||||
|
sorting,
|
||||||
|
columnVisibility,
|
||||||
|
columnSizing,
|
||||||
|
pageSize,
|
||||||
|
searchTerm,
|
||||||
|
defaultPageSize,
|
||||||
|
hasStateChanged,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const ctaContainerRef = useElementByClassName<HTMLDivElement>(ctaContainerClassName);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DataTableContext.Provider
|
<DataTableContext.Provider
|
||||||
@@ -243,8 +412,8 @@ export function DataTableProvider({
|
|||||||
offset: pageIndex * pageSize,
|
offset: pageIndex * pageSize,
|
||||||
segments,
|
segments,
|
||||||
selectedSegment,
|
selectedSegment,
|
||||||
segmentId: segmentId || undefined,
|
segmentId: segmentIdObject,
|
||||||
setSegmentId: setAndPersistSegmentId,
|
setSegmentId,
|
||||||
canSaveSegment,
|
canSaveSegment,
|
||||||
isSegmentEnabled,
|
isSegmentEnabled,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function DeleteSegmentDialog({
|
|||||||
onSuccess: ({ id }) => {
|
onSuccess: ({ id }) => {
|
||||||
utils.viewer.filterSegments.list.invalidate();
|
utils.viewer.filterSegments.list.invalidate();
|
||||||
showToast(t("filter_segment_deleted"), "success");
|
showToast(t("filter_segment_deleted"), "success");
|
||||||
if (segmentId === id) {
|
if (segmentId && segmentId.type === "user" && segmentId.id === id) {
|
||||||
setSegmentId(null);
|
setSegmentId(null);
|
||||||
}
|
}
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Form, TextField } from "@calcom/ui/components/form";
|
|||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
|
||||||
import { useDataTable } from "../../hooks";
|
import { useDataTable } from "../../hooks";
|
||||||
import type { FilterSegmentOutput } from "../../lib/types";
|
import type { CombinedFilterSegment } from "../../lib/types";
|
||||||
|
|
||||||
type FormValues = {
|
type FormValues = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -20,7 +20,7 @@ export function DuplicateSegmentDialog({
|
|||||||
segment,
|
segment,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
segment: FilterSegmentOutput;
|
segment: CombinedFilterSegment;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -38,7 +38,7 @@ export function DuplicateSegmentDialog({
|
|||||||
onSuccess: ({ id }) => {
|
onSuccess: ({ id }) => {
|
||||||
utils.viewer.filterSegments.list.invalidate();
|
utils.viewer.filterSegments.list.invalidate();
|
||||||
showToast(t("filter_segment_duplicated"), "success");
|
showToast(t("filter_segment_duplicated"), "success");
|
||||||
setSegmentId(id);
|
setSegmentId({ id, type: "user" });
|
||||||
onClose();
|
onClose();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -50,16 +50,24 @@ export function DuplicateSegmentDialog({
|
|||||||
if (!segment) {
|
if (!segment) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const { id: _id, name: _name, team: _team, teamId, ...rest } = segment;
|
if (segment.type === "user") {
|
||||||
|
const { type: _type, id: _id, name: _name, team: _team, teamId, ...rest } = segment;
|
||||||
if (segment.scope === "TEAM" && isAdminOrOwner) {
|
if (segment.scope === "TEAM" && isAdminOrOwner) {
|
||||||
createSegment({
|
createSegment({
|
||||||
...rest,
|
...rest,
|
||||||
teamId: teamId ?? 0,
|
teamId: teamId ?? 0,
|
||||||
scope: "TEAM",
|
scope: "TEAM",
|
||||||
name: data.name,
|
name: data.name,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
createSegment({
|
||||||
|
...rest,
|
||||||
|
scope: "USER",
|
||||||
|
name: data.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (segment.type === "system") {
|
||||||
|
const { type: _type, ...rest } = segment;
|
||||||
createSegment({
|
createSegment({
|
||||||
...rest,
|
...rest,
|
||||||
scope: "USER",
|
scope: "USER",
|
||||||
@@ -80,7 +88,13 @@ export function DuplicateSegmentDialog({
|
|||||||
<DialogHeader title={t("duplicate_segment")} />
|
<DialogHeader title={t("duplicate_segment")} />
|
||||||
<Form form={form} handleSubmit={handleSubmit}>
|
<Form form={form} handleSubmit={handleSubmit}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TextField required type="text" label={t("name")} {...form.register("name")} />
|
<TextField
|
||||||
|
required
|
||||||
|
data-testid="duplicate-segment-name"
|
||||||
|
type="text"
|
||||||
|
label={t("name")}
|
||||||
|
{...form.register("name")}
|
||||||
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button color="minimal" onClick={onClose}>
|
<Button color="minimal" onClick={onClose}>
|
||||||
{t("cancel")}
|
{t("cancel")}
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import {
|
|||||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { useDataTable } from "../../hooks";
|
import { useDataTable } from "../../hooks";
|
||||||
import type { FilterSegmentOutput } from "../../lib/types";
|
import type {
|
||||||
|
FilterSegmentOutput,
|
||||||
|
CombinedFilterSegment,
|
||||||
|
SystemFilterSegmentInternal,
|
||||||
|
UserFilterSegment,
|
||||||
|
} from "../../lib/types";
|
||||||
import { DeleteSegmentDialog } from "./DeleteSegmentDialog";
|
import { DeleteSegmentDialog } from "./DeleteSegmentDialog";
|
||||||
import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog";
|
import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog";
|
||||||
import { RenameSegmentDialog } from "./RenameSegmentDialog";
|
import { RenameSegmentDialog } from "./RenameSegmentDialog";
|
||||||
@@ -24,23 +29,33 @@ import { RenameSegmentDialog } from "./RenameSegmentDialog";
|
|||||||
type SubmenuItem = {
|
type SubmenuItem = {
|
||||||
iconName: IconName;
|
iconName: IconName;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
onClick: (segment: FilterSegmentOutput) => void;
|
onClick: (segment: CombinedFilterSegment) => void;
|
||||||
isDestructive?: boolean;
|
isDestructive?: boolean;
|
||||||
adminOnly: boolean;
|
adminOnly: boolean;
|
||||||
|
enabledForSystemSegment?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FilterSegmentSelect() {
|
export function FilterSegmentSelect() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const session = useSession();
|
||||||
|
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
||||||
const { segments, selectedSegment, segmentId, setSegmentId, isSegmentEnabled } = useDataTable();
|
const { segments, selectedSegment, segmentId, setSegmentId, isSegmentEnabled } = useDataTable();
|
||||||
const [segmentToRename, setSegmentToRename] = useState<FilterSegmentOutput | undefined>();
|
const [segmentToRename, setSegmentToRename] = useState<FilterSegmentOutput | undefined>();
|
||||||
const [segmentToDuplicate, setSegmentToDuplicate] = useState<FilterSegmentOutput | undefined>();
|
const [segmentToDuplicate, setSegmentToDuplicate] = useState<CombinedFilterSegment | undefined>();
|
||||||
const [segmentToDelete, setSegmentToDelete] = useState<FilterSegmentOutput | undefined>();
|
const [segmentToDelete, setSegmentToDelete] = useState<FilterSegmentOutput | undefined>();
|
||||||
|
|
||||||
const submenuItems: SubmenuItem[] = [
|
const submenuItems: SubmenuItem[] = [
|
||||||
{
|
{
|
||||||
iconName: "square-pen",
|
iconName: "square-pen",
|
||||||
labelKey: "rename",
|
labelKey: "rename",
|
||||||
onClick: (segment) => setSegmentToRename(segment),
|
onClick: (segment) => {
|
||||||
|
if (segment.type === "system") {
|
||||||
|
setSegmentToRename(undefined);
|
||||||
|
} else {
|
||||||
|
const { type: _type, ...rest } = segment;
|
||||||
|
setSegmentToRename(rest);
|
||||||
|
}
|
||||||
|
},
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -48,55 +63,79 @@ export function FilterSegmentSelect() {
|
|||||||
labelKey: "duplicate",
|
labelKey: "duplicate",
|
||||||
onClick: (segment) => setSegmentToDuplicate(segment),
|
onClick: (segment) => setSegmentToDuplicate(segment),
|
||||||
adminOnly: false,
|
adminOnly: false,
|
||||||
|
enabledForSystemSegment: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
iconName: "trash-2",
|
iconName: "trash-2",
|
||||||
labelKey: "delete",
|
labelKey: "delete",
|
||||||
onClick: (segment) => setSegmentToDelete(segment),
|
onClick: (segment) => {
|
||||||
|
if (segment.type === "system") {
|
||||||
|
setSegmentToDelete(undefined);
|
||||||
|
} else {
|
||||||
|
const { type: _type, ...rest } = segment;
|
||||||
|
setSegmentToDelete(rest);
|
||||||
|
}
|
||||||
|
},
|
||||||
isDestructive: true,
|
isDestructive: true,
|
||||||
adminOnly: true,
|
adminOnly: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const segmentGroups = useMemo(() => {
|
const segmentGroups = useMemo(() => {
|
||||||
const sortFn = (a: FilterSegmentOutput, b: FilterSegmentOutput) => a.name.localeCompare(b.name);
|
const sortFn = (a: CombinedFilterSegment, b: CombinedFilterSegment) => a.name.localeCompare(b.name);
|
||||||
|
|
||||||
const personalSegments = segments?.filter((segment) => !segment.team) || [];
|
const systemSegments =
|
||||||
|
segments?.filter((s): s is SystemFilterSegmentInternal => s.type === "system") || [];
|
||||||
|
const personalSegments =
|
||||||
|
segments?.filter((s): s is UserFilterSegment => s.type === "user" && !s.team) || [];
|
||||||
const teamSegments =
|
const teamSegments =
|
||||||
segments?.filter(
|
segments?.filter(
|
||||||
(segment): segment is FilterSegmentOutput & { team: NonNullable<FilterSegmentOutput["team"]> } =>
|
(s): s is UserFilterSegment & { team: NonNullable<FilterSegmentOutput["team"]> } =>
|
||||||
segment.team !== null
|
s.type === "user" && s.team !== null
|
||||||
) || [];
|
) || [];
|
||||||
|
|
||||||
// Group team segments by team name
|
const groups = [];
|
||||||
const teamSegmentsByTeam = teamSegments.reduce<{ [teamName: string]: FilterSegmentOutput[] }>(
|
|
||||||
(acc, segment) => {
|
|
||||||
const teamName = segment.team.name;
|
|
||||||
if (!acc[teamName]) {
|
|
||||||
acc[teamName] = [];
|
|
||||||
}
|
|
||||||
acc[teamName].push(segment);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
if (systemSegments.length > 0) {
|
||||||
...(personalSegments.length > 0
|
groups.push({
|
||||||
? [
|
label: t("default"),
|
||||||
{
|
segments: systemSegments.sort(sortFn),
|
||||||
label: t("personal"),
|
isSystem: true,
|
||||||
segments: personalSegments.sort(sortFn),
|
});
|
||||||
},
|
}
|
||||||
]
|
|
||||||
: []),
|
// Personal segments
|
||||||
|
if (personalSegments.length > 0) {
|
||||||
|
groups.push({
|
||||||
|
label: t("personal"),
|
||||||
|
segments: personalSegments.sort(sortFn),
|
||||||
|
isSystem: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Team segments (existing grouping logic)
|
||||||
|
const teamSegmentsByTeam = teamSegments.reduce<{
|
||||||
|
[teamName: string]: UserFilterSegment[];
|
||||||
|
}>((acc, segment) => {
|
||||||
|
const teamName = segment.team!.name;
|
||||||
|
if (!acc[teamName]) {
|
||||||
|
acc[teamName] = [];
|
||||||
|
}
|
||||||
|
acc[teamName].push(segment);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
groups.push(
|
||||||
...Object.entries(teamSegmentsByTeam)
|
...Object.entries(teamSegmentsByTeam)
|
||||||
.map(([teamName, segments]) => ({
|
.map(([teamName, segments]) => ({
|
||||||
label: teamName,
|
label: teamName,
|
||||||
segments: segments.sort(sortFn),
|
segments: segments.sort(sortFn),
|
||||||
|
isSystem: false,
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
];
|
);
|
||||||
|
|
||||||
|
return groups;
|
||||||
}, [segments, t]);
|
}, [segments, t]);
|
||||||
|
|
||||||
if (!isSegmentEnabled) {
|
if (!isSegmentEnabled) {
|
||||||
@@ -128,22 +167,43 @@ export function FilterSegmentSelect() {
|
|||||||
{group.label && (
|
{group.label && (
|
||||||
<DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel>
|
<DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel>
|
||||||
)}
|
)}
|
||||||
{group.segments.map((segment) => (
|
{group.segments.map((segment) => {
|
||||||
<DropdownItemWithSubmenu
|
let items: SubmenuItem[] = [];
|
||||||
key={segment.id}
|
if (segment.type === "system") {
|
||||||
submenuItems={submenuItems}
|
items = submenuItems.filter((item) => item.enabledForSystemSegment);
|
||||||
segment={segment}
|
} else if (segment.type === "user" && segment.scope === "USER") {
|
||||||
onSelect={() => {
|
items = submenuItems;
|
||||||
if (segmentId === segment.id) {
|
} else if (segment.type === "user" && segment.scope === "TEAM") {
|
||||||
setSegmentId(null);
|
items = submenuItems.filter(
|
||||||
} else {
|
(item) =>
|
||||||
setSegmentId(segment.id);
|
// Team segments: show if not admin-only or if user is admin/owner
|
||||||
}
|
isAdminOrOwner || !item.adminOnly
|
||||||
}}>
|
);
|
||||||
{segment.id === segmentId && <Icon name="check" className="ml-3 h-4 w-4" />}
|
}
|
||||||
<span className="ml-3">{segment.name}</span>
|
|
||||||
</DropdownItemWithSubmenu>
|
return (
|
||||||
))}
|
<DropdownItemWithSubmenu
|
||||||
|
key={segment.id}
|
||||||
|
submenuItems={items}
|
||||||
|
segment={segment}
|
||||||
|
onSelect={() => {
|
||||||
|
if (segmentId && segmentId.type === segment.type && segmentId.id === segment.id) {
|
||||||
|
setSegmentId(null);
|
||||||
|
} else {
|
||||||
|
if (segment.type === "system") {
|
||||||
|
setSegmentId({ id: segment.id, type: "system" });
|
||||||
|
} else {
|
||||||
|
setSegmentId({ id: segment.id, type: "user" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
{segmentId && segmentId.type === segment.type && segmentId.id === segment.id && (
|
||||||
|
<Icon name="check" className="ml-3 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
<span className="ml-3">{segment.name}</span>
|
||||||
|
</DropdownItemWithSubmenu>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
@@ -175,25 +235,12 @@ function DropdownItemWithSubmenu({
|
|||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
segment: FilterSegmentOutput;
|
segment: CombinedFilterSegment;
|
||||||
submenuItems: SubmenuItem[];
|
submenuItems: SubmenuItem[];
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const session = useSession();
|
|
||||||
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
|
||||||
|
|
||||||
// Filter submenu items based on segment type and user role
|
|
||||||
const filteredSubmenuItems = submenuItems.filter((item) => {
|
|
||||||
if (!segment.team) {
|
|
||||||
// Personal segments: show all actions
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Team segments: show if not admin-only or if user is admin/owner
|
|
||||||
return !item.adminOnly || isAdminOrOwner;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem className="cursor-pointer" onSelect={onSelect}>
|
<DropdownMenuItem className="cursor-pointer" onSelect={onSelect}>
|
||||||
@@ -220,7 +267,7 @@ function DropdownItemWithSubmenu({
|
|||||||
side="right"
|
side="right"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
data-testid="filter-segment-select-submenu-content">
|
data-testid="filter-segment-select-submenu-content">
|
||||||
{filteredSubmenuItems.map((item, index) => (
|
{submenuItems.map((item, index) => (
|
||||||
<DropdownMenuItem key={index}>
|
<DropdownMenuItem key={index}>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
color={item.isDestructive ? "destructive" : undefined}
|
color={item.isDestructive ? "destructive" : undefined}
|
||||||
|
|||||||
@@ -71,7 +71,13 @@ export function RenameSegmentDialog({
|
|||||||
<DialogHeader title={t("rename_segment")} />
|
<DialogHeader title={t("rename_segment")} />
|
||||||
<Form form={form} handleSubmit={handleSubmit}>
|
<Form form={form} handleSubmit={handleSubmit}>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<TextField required type="text" label={t("name")} {...form.register("name")} />
|
<TextField
|
||||||
|
required
|
||||||
|
data-testid="rename-segment-name"
|
||||||
|
type="text"
|
||||||
|
label={t("name")}
|
||||||
|
{...form.register("name")}
|
||||||
|
/>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" loading={isPending}>
|
<Button type="submit" loading={isPending}>
|
||||||
{t("save")}
|
{t("save")}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function SaveFilterSegmentButton() {
|
|||||||
} = useDataTable();
|
} = useDataTable();
|
||||||
|
|
||||||
const [saveMode, setSaveMode] = useState<"create" | "update">(() =>
|
const [saveMode, setSaveMode] = useState<"create" | "update">(() =>
|
||||||
selectedSegment ? "update" : "create"
|
selectedSegment && selectedSegment.type === "user" ? "update" : "create"
|
||||||
);
|
);
|
||||||
|
|
||||||
// When the dialog is not open,
|
// When the dialog is not open,
|
||||||
@@ -66,16 +66,16 @@ export function SaveFilterSegmentButton() {
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaveMode(selectedSegment ? "update" : "create");
|
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
|
||||||
}, [selectedSegment, isOpen]);
|
}, [selectedSegment, isOpen]);
|
||||||
|
|
||||||
const { data: teams } = trpc.viewer.teams.list.useQuery();
|
const { data: teams } = trpc.viewer.teams.list.useQuery();
|
||||||
|
|
||||||
const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({
|
const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({
|
||||||
onSuccess: ({ id }) => {
|
onSuccess: (segment) => {
|
||||||
utils.viewer.filterSegments.list.invalidate();
|
utils.viewer.filterSegments.list.invalidate();
|
||||||
showToast(t("filter_segment_saved"), "success");
|
showToast(t("filter_segment_saved"), "success");
|
||||||
setSegmentId(id);
|
setSegmentId({ id: segment.id, type: "user" }, { type: "user", ...segment });
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
@@ -110,12 +110,7 @@ export function SaveFilterSegmentButton() {
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (saveMode === "update") {
|
if (saveMode === "update" && selectedSegment && selectedSegment.type === "user") {
|
||||||
if (!selectedSegment) {
|
|
||||||
// theoretically this should never happen
|
|
||||||
showToast(t("segment_not_found"), "error");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const scope = selectedSegment.scope;
|
const scope = selectedSegment.scope;
|
||||||
if (scope === "TEAM") {
|
if (scope === "TEAM") {
|
||||||
updateSegment({
|
updateSegment({
|
||||||
@@ -156,7 +151,7 @@ export function SaveFilterSegmentButton() {
|
|||||||
// Reset form state when dialog closes
|
// Reset form state when dialog closes
|
||||||
setIsTeamSegment(false);
|
setIsTeamSegment(false);
|
||||||
setSelectedTeamId(undefined);
|
setSelectedTeamId(undefined);
|
||||||
setSaveMode(selectedSegment ? "update" : "create");
|
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
|
||||||
form.reset();
|
form.reset();
|
||||||
}
|
}
|
||||||
setIsOpen(open);
|
setIsOpen(open);
|
||||||
@@ -184,7 +179,7 @@ export function SaveFilterSegmentButton() {
|
|||||||
<DialogContent data-testid="save-filter-segment-dialog">
|
<DialogContent data-testid="save-filter-segment-dialog">
|
||||||
<DialogHeader title={t("save_segment")} />
|
<DialogHeader title={t("save_segment")} />
|
||||||
<Form form={form} handleSubmit={onSubmit}>
|
<Form form={form} handleSubmit={onSubmit}>
|
||||||
{selectedSegment ? (
|
{selectedSegment && selectedSegment.type === "user" ? (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
defaultValue="update"
|
defaultValue="update"
|
||||||
|
|||||||
@@ -1,35 +1,13 @@
|
|||||||
// eslint-disable-next-line no-restricted-imports
|
import { useMemo } from "react";
|
||||||
import { isEqual } from "lodash";
|
|
||||||
import { useCallback, useMemo, useEffect } from "react";
|
|
||||||
|
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
import { recalculateDateRange } from "../lib/dateRange";
|
import { recalculateDateRange } from "../lib/dateRange";
|
||||||
import { type UseSegments } from "../lib/types";
|
import { type UseSegments, SYSTEM_SEGMENT_PREFIX } from "../lib/types";
|
||||||
import { isDateRangeFilterValue } from "../lib/utils";
|
import { isDateRangeFilterValue } from "../lib/utils";
|
||||||
|
|
||||||
export const useSegments: UseSegments = ({
|
export const useSegments: UseSegments = ({ tableIdentifier, providedSegments, systemSegments }) => {
|
||||||
tableIdentifier,
|
const { data: rawSegments, isSuccess } = trpc.viewer.filterSegments.list.useQuery(
|
||||||
activeFilters,
|
|
||||||
sorting,
|
|
||||||
columnVisibility,
|
|
||||||
columnSizing,
|
|
||||||
pageSize,
|
|
||||||
searchTerm,
|
|
||||||
defaultPageSize,
|
|
||||||
segmentId,
|
|
||||||
setSegmentId,
|
|
||||||
setActiveFilters,
|
|
||||||
setSorting,
|
|
||||||
setColumnVisibility,
|
|
||||||
setColumnSizing,
|
|
||||||
setPageSize,
|
|
||||||
setPageIndex,
|
|
||||||
setSearchTerm,
|
|
||||||
segments: providedSegments,
|
|
||||||
preferredSegmentId,
|
|
||||||
}) => {
|
|
||||||
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery(
|
|
||||||
{
|
{
|
||||||
tableIdentifier,
|
tableIdentifier,
|
||||||
},
|
},
|
||||||
@@ -37,16 +15,19 @@ export const useSegments: UseSegments = ({
|
|||||||
enabled: !providedSegments, // Only fetch if segments are not provided
|
enabled: !providedSegments, // Only fetch if segments are not provided
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation();
|
const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation();
|
||||||
|
|
||||||
// Recalculate date ranges based on the current timestamp
|
const preferredSegmentId = useMemo(() => rawSegments?.preferredSegmentId || null, [rawSegments]);
|
||||||
const segments = useMemo(() => {
|
|
||||||
const segmentsSource = providedSegments || rawSegments?.segments;
|
|
||||||
if (!segmentsSource) return [];
|
|
||||||
|
|
||||||
return segmentsSource.map((segment) => ({
|
const segments = useMemo(() => {
|
||||||
|
const userSegments = providedSegments || rawSegments?.segments || [];
|
||||||
|
|
||||||
|
const processedSystemSegments = (systemSegments || []).map((segment) => ({
|
||||||
|
perPage: 10,
|
||||||
|
tableIdentifier,
|
||||||
...segment,
|
...segment,
|
||||||
|
id: `${SYSTEM_SEGMENT_PREFIX}${segment.id}`,
|
||||||
|
type: "system" as const,
|
||||||
activeFilters: segment.activeFilters.map((filter) => {
|
activeFilters: segment.activeFilters.map((filter) => {
|
||||||
if (isDateRangeFilterValue(filter.v)) {
|
if (isDateRangeFilterValue(filter.v)) {
|
||||||
return {
|
return {
|
||||||
@@ -57,106 +38,29 @@ export const useSegments: UseSegments = ({
|
|||||||
return filter;
|
return filter;
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
}, [rawSegments, providedSegments]);
|
|
||||||
|
|
||||||
const selectedSegment = useMemo(() => {
|
const processedUserSegments = userSegments.map((segment) => ({
|
||||||
return segments?.find((segment) => segment.id === segmentId);
|
...segment,
|
||||||
}, [segments, segmentId]);
|
type: "user" as const,
|
||||||
|
activeFilters: segment.activeFilters.map((filter) => {
|
||||||
|
if (isDateRangeFilterValue(filter.v)) {
|
||||||
|
return {
|
||||||
|
...filter,
|
||||||
|
v: recalculateDateRange(filter.v),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return filter;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
return [...processedSystemSegments, ...processedUserSegments];
|
||||||
if (segments && segmentId > 0 && !isFetchingSegments) {
|
}, [rawSegments, providedSegments, systemSegments, tableIdentifier]);
|
||||||
const segment = segments.find((segment) => segment.id === segmentId);
|
|
||||||
if (!segment) {
|
|
||||||
// If segmentId is invalid (or not found), clear the segmentId from the query params,
|
|
||||||
// but we still keep all the other states like activeFilters, etc.
|
|
||||||
// This is useful when someone shares a URL that is inaccessible to someone else.
|
|
||||||
setSegmentId(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [segments, segmentId, setSegmentId, isFetchingSegments]);
|
|
||||||
|
|
||||||
const memoizedPreferredSegmentId = useMemo(
|
|
||||||
() => preferredSegmentId ?? rawSegments?.preferredSegmentId,
|
|
||||||
[preferredSegmentId, rawSegments]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (memoizedPreferredSegmentId) {
|
|
||||||
setSegmentId(memoizedPreferredSegmentId);
|
|
||||||
}
|
|
||||||
}, [memoizedPreferredSegmentId, setSegmentId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedSegment) {
|
|
||||||
// segment is selected, so we apply the filters, sorting, etc. from the segment
|
|
||||||
setActiveFilters(selectedSegment.activeFilters);
|
|
||||||
setSorting(selectedSegment.sorting);
|
|
||||||
setColumnVisibility(selectedSegment.columnVisibility);
|
|
||||||
setColumnSizing(selectedSegment.columnSizing);
|
|
||||||
setPageSize(selectedSegment.perPage);
|
|
||||||
setSearchTerm(selectedSegment.searchTerm);
|
|
||||||
setPageIndex(0);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
selectedSegment,
|
|
||||||
setActiveFilters,
|
|
||||||
setSorting,
|
|
||||||
setColumnVisibility,
|
|
||||||
setColumnSizing,
|
|
||||||
setPageSize,
|
|
||||||
setPageIndex,
|
|
||||||
setSearchTerm,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const canSaveSegment = useMemo(() => {
|
|
||||||
if (!selectedSegment) {
|
|
||||||
// if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
|
|
||||||
return (
|
|
||||||
activeFilters.length > 0 ||
|
|
||||||
sorting.length > 0 ||
|
|
||||||
Object.keys(columnVisibility).length > 0 ||
|
|
||||||
Object.keys(columnSizing).length > 0 ||
|
|
||||||
pageSize !== defaultPageSize ||
|
|
||||||
searchTerm?.length > 0
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// if a segment is selected, we can save the segment if the active filters, sorting, etc. are different from the segment
|
|
||||||
return (
|
|
||||||
!isEqual(activeFilters, selectedSegment.activeFilters) ||
|
|
||||||
!isEqual(sorting, selectedSegment.sorting) ||
|
|
||||||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
|
|
||||||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
|
|
||||||
!isEqual(pageSize, selectedSegment.perPage) ||
|
|
||||||
!isEqual(searchTerm, selectedSegment.searchTerm)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
selectedSegment,
|
|
||||||
activeFilters,
|
|
||||||
sorting,
|
|
||||||
columnVisibility,
|
|
||||||
columnSizing,
|
|
||||||
pageSize,
|
|
||||||
searchTerm,
|
|
||||||
defaultPageSize,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const setAndPersistSegmentId = useCallback(
|
|
||||||
(segmentId: number | null) => {
|
|
||||||
setSegmentId(segmentId);
|
|
||||||
setPreference({
|
|
||||||
tableIdentifier,
|
|
||||||
segmentId,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[tableIdentifier, setSegmentId, setPreference]
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
segments: segments ?? [],
|
segments,
|
||||||
selectedSegment,
|
preferredSegmentId,
|
||||||
canSaveSegment,
|
isSuccess: Boolean(providedSegments) || isSuccess,
|
||||||
setAndPersistSegmentId,
|
setPreference,
|
||||||
isSegmentEnabled: true,
|
isSegmentEnabled: true,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { type UseSegments } from "../lib/types";
|
|||||||
export const useSegmentsNoop: UseSegments = ({}) => {
|
export const useSegmentsNoop: UseSegments = ({}) => {
|
||||||
return {
|
return {
|
||||||
segments: [],
|
segments: [],
|
||||||
selectedSegment: undefined,
|
preferredSegmentId: null,
|
||||||
canSaveSegment: false,
|
isSuccess: false,
|
||||||
setAndPersistSegmentId: noop,
|
setPreference: noop,
|
||||||
isSegmentEnabled: false,
|
isSegmentEnabled: false,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withD
|
|||||||
DEFAULT_COLUMN_VISIBILITY
|
DEFAULT_COLUMN_VISIBILITY
|
||||||
);
|
);
|
||||||
export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING);
|
export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING);
|
||||||
export const segmentIdParser = parseAsInteger.withDefault(-1);
|
export const segmentIdParser = parseAsString.withDefault("");
|
||||||
export const pageIndexParser = parseAsInteger.withDefault(0);
|
export const pageIndexParser = parseAsInteger.withDefault(0);
|
||||||
export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE);
|
export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE);
|
||||||
export const searchTermParser = parseAsString.withDefault("");
|
export const searchTermParser = parseAsString.withDefault("");
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { SortingState, ColumnSort, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
import type { SortingState, ColumnSort } from "@tanstack/react-table";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { IconName } from "@calcom/ui/components/icon";
|
import type { IconName } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
export type { SortingState } from "@tanstack/react-table";
|
export type { SortingState } from "@tanstack/react-table";
|
||||||
|
|
||||||
|
export const SYSTEM_SEGMENT_PREFIX = "system_";
|
||||||
|
|
||||||
export enum ColumnFilterType {
|
export enum ColumnFilterType {
|
||||||
SINGLE_SELECT = "ss",
|
SINGLE_SELECT = "ss",
|
||||||
MULTI_SELECT = "ms",
|
MULTI_SELECT = "ms",
|
||||||
@@ -255,11 +257,31 @@ export type FilterSegmentOutput = {
|
|||||||
team: { id: number; name: string } | null;
|
team: { id: number; name: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FilterSegmentsListResponse = {
|
export type SystemFilterSegment = {
|
||||||
segments: FilterSegmentOutput[];
|
id: string;
|
||||||
preferredSegmentId: number | null;
|
name: string;
|
||||||
|
activeFilters: ActiveFilters;
|
||||||
|
sorting?: SortingState;
|
||||||
|
columnVisibility?: Record<string, boolean>;
|
||||||
|
columnSizing?: Record<string, number>;
|
||||||
|
perPage?: number;
|
||||||
|
searchTerm?: string | null;
|
||||||
|
type: "system";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type SystemFilterSegmentInternal = Omit<SystemFilterSegment, "perPage"> & {
|
||||||
|
tableIdentifier: string;
|
||||||
|
perPage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserFilterSegment = FilterSegmentOutput & {
|
||||||
|
type: "user";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CombinedFilterSegment = SystemFilterSegmentInternal | UserFilterSegment;
|
||||||
|
|
||||||
|
export type SegmentIdentifier = { id: string; type: "system" } | { id: number; type: "user" };
|
||||||
|
|
||||||
export type SegmentStorage = {
|
export type SegmentStorage = {
|
||||||
[tableIdentifier: string]: {
|
[tableIdentifier: string]: {
|
||||||
segmentId: number;
|
segmentId: number;
|
||||||
@@ -277,30 +299,20 @@ export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn;
|
|||||||
|
|
||||||
export type UseSegmentsProps = {
|
export type UseSegmentsProps = {
|
||||||
tableIdentifier: string;
|
tableIdentifier: string;
|
||||||
activeFilters: ActiveFilters;
|
providedSegments?: FilterSegmentOutput[];
|
||||||
sorting: SortingState;
|
systemSegments?: SystemFilterSegment[];
|
||||||
columnVisibility: VisibilityState;
|
|
||||||
columnSizing: ColumnSizingState;
|
|
||||||
pageSize: number;
|
|
||||||
searchTerm: string;
|
|
||||||
defaultPageSize: number;
|
|
||||||
segmentId: number;
|
|
||||||
setSegmentId: (segmentId: number | null) => void;
|
|
||||||
setActiveFilters: (activeFilters: ActiveFilters) => void;
|
|
||||||
setSorting: (sorting: SortingState) => void;
|
|
||||||
setColumnVisibility: (columnVisibility: VisibilityState) => void;
|
|
||||||
setColumnSizing: (columnSizing: ColumnSizingState) => void;
|
|
||||||
setPageSize: (pageSize: number) => void;
|
|
||||||
setPageIndex: (pageIndex: number) => void;
|
|
||||||
setSearchTerm: (searchTerm: string | null) => void;
|
|
||||||
segments?: FilterSegmentOutput[];
|
|
||||||
preferredSegmentId?: number | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UseSegmentsReturn = {
|
export type UseSegmentsReturn = {
|
||||||
segments: FilterSegmentOutput[];
|
segments: CombinedFilterSegment[];
|
||||||
selectedSegment: FilterSegmentOutput | undefined;
|
preferredSegmentId: SegmentIdentifier | null;
|
||||||
canSaveSegment: boolean;
|
isSuccess: boolean;
|
||||||
setAndPersistSegmentId: (segmentId: number | null) => void;
|
setPreference: ({
|
||||||
|
tableIdentifier,
|
||||||
|
segmentId,
|
||||||
|
}: {
|
||||||
|
tableIdentifier: string;
|
||||||
|
segmentId: SegmentIdentifier | null;
|
||||||
|
}) => void;
|
||||||
isSegmentEnabled: boolean;
|
isSegmentEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -73,6 +73,17 @@ export const handleRescheduleEventManager = async ({
|
|||||||
|
|
||||||
const results = updateManager.results ?? [];
|
const results = updateManager.results ?? [];
|
||||||
|
|
||||||
|
const calVideoResult = results.find((result) => result.type === "daily_video");
|
||||||
|
// Check if Cal Video Creation Failed - That is the fallback for Cal.com and is expected to always work
|
||||||
|
if (calVideoResult && !calVideoResult.success) {
|
||||||
|
handleRescheduleEventManager.error("Cal Video creation failed", {
|
||||||
|
error: calVideoResult.error,
|
||||||
|
bookingLocation,
|
||||||
|
});
|
||||||
|
// This happens only when Cal Video is down
|
||||||
|
throw new Error("Failed to set video conferencing link, but the meeting has been rescheduled");
|
||||||
|
}
|
||||||
|
|
||||||
const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
|
const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
|
||||||
results: results,
|
results: results,
|
||||||
});
|
});
|
||||||
@@ -80,7 +91,6 @@ export const handleRescheduleEventManager = async ({
|
|||||||
let videoCallUrl = _videoCallUrl;
|
let videoCallUrl = _videoCallUrl;
|
||||||
let metadata: AdditionalInformation = {};
|
let metadata: AdditionalInformation = {};
|
||||||
metadata = videoMetadata;
|
metadata = videoMetadata;
|
||||||
|
|
||||||
if (results.length) {
|
if (results.length) {
|
||||||
// Handle Google Meet results
|
// Handle Google Meet results
|
||||||
if (bookingLocation === MeetLocationType) {
|
if (bookingLocation === MeetLocationType) {
|
||||||
@@ -154,6 +164,7 @@ export const handleRescheduleEventManager = async ({
|
|||||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newReferencesToCreate = structuredClone(updateManager.referencesToCreate);
|
const newReferencesToCreate = structuredClone(updateManager.referencesToCreate);
|
||||||
|
|
||||||
await BookingReferenceRepository.replaceBookingReferences({
|
await BookingReferenceRepository.replaceBookingReferences({
|
||||||
|
|||||||
@@ -17,65 +17,238 @@ import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAn
|
|||||||
|
|
||||||
import { describe, vi, expect } from "vitest";
|
import { describe, vi, expect } from "vitest";
|
||||||
|
|
||||||
|
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
|
||||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||||
import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
|
import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
|
||||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||||
|
|
||||||
vi.mock("@calcom/lib/EventManager");
|
vi.mock("@calcom/lib/EventManager");
|
||||||
|
vi.mock("@calcom/app-store/utils", () => ({
|
||||||
|
getAppFromSlug: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const testDestinationCalendar = {
|
// Type definitions
|
||||||
integration: "test-calendar",
|
type TestUserMetadata = {
|
||||||
externalId: "test-calendar",
|
defaultConferencingApp?: {
|
||||||
|
appSlug: string;
|
||||||
|
appLink?: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const testUsers = [
|
type TestUser = {
|
||||||
{
|
id: number;
|
||||||
id: 1,
|
name: string;
|
||||||
name: "user-1",
|
timeZone: string;
|
||||||
timeZone: "Asia/Kolkata",
|
username: string;
|
||||||
username: "host-1",
|
email: string;
|
||||||
email: "host1@test.com",
|
schedules: (typeof TestData.schedules.IstWorkHours)[];
|
||||||
schedules: [TestData.schedules.IstWorkHours],
|
destinationCalendar?: { integration: string; externalId: string };
|
||||||
destinationCalendar: testDestinationCalendar,
|
metadata?: TestUserMetadata;
|
||||||
},
|
};
|
||||||
{
|
|
||||||
id: 2,
|
type MockBookingAttendee = ReturnType<typeof getMockBookingAttendee>;
|
||||||
name: "user-2",
|
|
||||||
timeZone: "Asia/Kolkata",
|
// Test Data Builders
|
||||||
username: "host-2",
|
const createTestUser = (overrides?: {
|
||||||
email: "host2@test.com",
|
id?: number;
|
||||||
schedules: [TestData.schedules.IstWorkHours],
|
name?: string;
|
||||||
},
|
username?: string;
|
||||||
{
|
email?: string;
|
||||||
id: 3,
|
destinationCalendar?: { integration: string; externalId: string };
|
||||||
name: "user-3",
|
metadata?: TestUserMetadata;
|
||||||
timeZone: "Asia/Kolkata",
|
}) => ({
|
||||||
username: "host-3",
|
id: overrides?.id ?? 1,
|
||||||
email: "host3@test.com",
|
name: overrides?.name ?? `user-${overrides?.id ?? 1}`,
|
||||||
schedules: [TestData.schedules.IstWorkHours],
|
timeZone: "Asia/Kolkata",
|
||||||
},
|
username: overrides?.username ?? `host-${overrides?.id ?? 1}`,
|
||||||
];
|
email: overrides?.email ?? `host${overrides?.id ?? 1}@test.com`,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
destinationCalendar: overrides?.destinationCalendar,
|
||||||
|
metadata: overrides?.metadata,
|
||||||
|
defaultScheduleId: 1,
|
||||||
|
credentials: [],
|
||||||
|
selectedCalendars: [],
|
||||||
|
weekStart: "Monday" as const,
|
||||||
|
teams: [],
|
||||||
|
availability: [],
|
||||||
|
profiles: [],
|
||||||
|
outOfOffice: undefined,
|
||||||
|
smsLockState: undefined,
|
||||||
|
completedOnboarding: true,
|
||||||
|
locked: false,
|
||||||
|
organizationId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestDestinationCalendar = (overrides?: { integration?: string; externalId?: string }) => ({
|
||||||
|
integration: overrides?.integration ?? "test-calendar",
|
||||||
|
externalId: overrides?.externalId ?? "test-calendar",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createTestBooking = async (params: {
|
||||||
|
eventTypeId: number;
|
||||||
|
userId: number;
|
||||||
|
bookingId: number;
|
||||||
|
bookingUid: string;
|
||||||
|
attendees?: MockBookingAttendee[];
|
||||||
|
location?: string;
|
||||||
|
}) => {
|
||||||
|
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: params.bookingId,
|
||||||
|
eventTypeId: params.eventTypeId,
|
||||||
|
userId: params.userId,
|
||||||
|
uid: params.bookingUid,
|
||||||
|
status: BookingStatus.ACCEPTED,
|
||||||
|
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||||
|
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
||||||
|
location: params.location ?? null,
|
||||||
|
attendees: params.attendees ?? [
|
||||||
|
getMockBookingAttendee({
|
||||||
|
id: 2,
|
||||||
|
name: "attendee",
|
||||||
|
email: "attendee@test.com",
|
||||||
|
locale: "en",
|
||||||
|
timeZone: "Asia/Kolkata",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRoundRobinEventType = (params: {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
users: TestUser[];
|
||||||
|
hosts?: { userId: number; isFixed: boolean }[];
|
||||||
|
locations?: { type: string; credentialId?: number }[];
|
||||||
|
teamId?: number;
|
||||||
|
schedulingType?: SchedulingType;
|
||||||
|
parentId?: number;
|
||||||
|
}) => ({
|
||||||
|
id: params.id,
|
||||||
|
slug: params.slug,
|
||||||
|
schedulingType: params.schedulingType ?? SchedulingType.ROUND_ROBIN,
|
||||||
|
length: 45,
|
||||||
|
users: params.users.map((user) => ({ id: user.id })),
|
||||||
|
hosts: params.hosts ?? params.users.map((user) => ({ userId: user.id, isFixed: false })),
|
||||||
|
locations: params.locations,
|
||||||
|
teamId: params.teamId,
|
||||||
|
parentId: params.parentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assertion Helpers
|
||||||
|
const expectEventManagerCalledWith = (
|
||||||
|
spy: ReturnType<typeof vi.spyOn>,
|
||||||
|
expectedParams: {
|
||||||
|
location?: string;
|
||||||
|
uid: string;
|
||||||
|
changedOrganizer: boolean;
|
||||||
|
destinationCalendars?: Array<{ integration: string; externalId: string }>;
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
expect(spy).toHaveBeenCalledTimes(1);
|
||||||
|
expect(spy).toHaveBeenCalledWith(
|
||||||
|
expectedParams.location
|
||||||
|
? expect.objectContaining({ location: expectedParams.location })
|
||||||
|
: expect.any(Object),
|
||||||
|
expectedParams.uid,
|
||||||
|
undefined,
|
||||||
|
expectedParams.changedOrganizer,
|
||||||
|
expectedParams.destinationCalendars ?? expect.any(Array)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock Helpers
|
||||||
|
type MockEventManagerConfig = {
|
||||||
|
failConferencing?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConferenceResult = {
|
||||||
|
type: string;
|
||||||
|
success: boolean;
|
||||||
|
uid: string;
|
||||||
|
originalEvent: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
updatedEvent?: {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockEventManagerReschedule = async (config?: MockEventManagerConfig) => {
|
||||||
|
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const spy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
spy.mockImplementation(async (event: any) => {
|
||||||
|
const location = event.location;
|
||||||
|
const results: ConferenceResult[] = [];
|
||||||
|
|
||||||
|
// Check if location is a static link (starts with http:// or https://)
|
||||||
|
const isStaticLink = location?.startsWith("http://") || location?.startsWith("https://");
|
||||||
|
|
||||||
|
if (isStaticLink) {
|
||||||
|
// For static links, no conferencing result is generated
|
||||||
|
return { referencesToCreate: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle conferencing based on location type
|
||||||
|
if (location?.includes("integrations:")) {
|
||||||
|
const conferenceType = location.split(":")[1];
|
||||||
|
|
||||||
|
if (config?.failConferencing) {
|
||||||
|
results.push({
|
||||||
|
type: `${conferenceType}_video`,
|
||||||
|
success: false,
|
||||||
|
error: `Failed to create ${conferenceType} room`,
|
||||||
|
uid: event.uid || "test-uid",
|
||||||
|
originalEvent: {},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
results.push({
|
||||||
|
type: `${conferenceType}_video`,
|
||||||
|
success: true,
|
||||||
|
uid: event.uid || "test-uid",
|
||||||
|
originalEvent: {},
|
||||||
|
updatedEvent: {
|
||||||
|
url:
|
||||||
|
conferenceType === "zoom"
|
||||||
|
? "https://zoom.us/j/123456789"
|
||||||
|
: conferenceType === "google"
|
||||||
|
? "https://meet.google.com/test-meeting"
|
||||||
|
: `https://${conferenceType}.co/test-room`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { results, referencesToCreate: [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
return spy;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockGetAppFromSlug = async () => {
|
||||||
|
const { getAppFromSlug } = await import("@calcom/app-store/utils");
|
||||||
|
return vi.mocked(getAppFromSlug);
|
||||||
|
};
|
||||||
|
|
||||||
describe("roundRobinManualReassignment test", () => {
|
describe("roundRobinManualReassignment test", () => {
|
||||||
setupAndTeardown();
|
setupAndTeardown();
|
||||||
|
|
||||||
test("manually reassign round robin organizer", async ({ emails }) => {
|
test("should reassign round robin booking to new host and update workflows", async ({ emails }) => {
|
||||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||||
|
|
||||||
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
const testDestinationCalendar = createTestDestinationCalendar();
|
||||||
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
|
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||||
|
const newHost = createTestUser({ id: 2 });
|
||||||
|
const users = [originalHost, newHost, createTestUser({ id: 3 })];
|
||||||
|
|
||||||
const users = testUsers;
|
|
||||||
const originalHost = users[0];
|
|
||||||
const newHost = users[1];
|
|
||||||
|
|
||||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
|
||||||
const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 });
|
const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 });
|
||||||
|
|
||||||
const bookingToReassignUid = "booking-to-reassign";
|
const bookingToReassignUid = "booking-to-reassign";
|
||||||
|
|
||||||
const bookingData = await createBookingScenario(
|
await createBookingScenario(
|
||||||
getScenarioData({
|
getScenarioData({
|
||||||
workflows: [
|
workflows: [
|
||||||
{
|
{
|
||||||
@@ -83,48 +256,34 @@ describe("roundRobinManualReassignment test", () => {
|
|||||||
trigger: "NEW_EVENT",
|
trigger: "NEW_EVENT",
|
||||||
action: "EMAIL_HOST",
|
action: "EMAIL_HOST",
|
||||||
template: "REMINDER",
|
template: "REMINDER",
|
||||||
activeEventTypeId: 1,
|
activeOn: [1],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
{
|
createRoundRobinEventType({
|
||||||
id: 1,
|
id: 1,
|
||||||
slug: "round-robin-event",
|
slug: "round-robin-event",
|
||||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
users,
|
||||||
length: 45,
|
}),
|
||||||
users: users.map((user) => ({ id: user.id })),
|
|
||||||
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
bookings: [
|
bookings: [
|
||||||
{
|
await createTestBooking({
|
||||||
id: 123,
|
|
||||||
eventTypeId: 1,
|
eventTypeId: 1,
|
||||||
userId: originalHost.id,
|
userId: originalHost.id,
|
||||||
uid: bookingToReassignUid,
|
bookingId: 123,
|
||||||
status: BookingStatus.ACCEPTED,
|
bookingUid: bookingToReassignUid,
|
||||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
}),
|
||||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
|
||||||
attendees: [
|
|
||||||
getMockBookingAttendee({
|
|
||||||
id: 2,
|
|
||||||
name: "attendee",
|
|
||||||
email: "attendee@test.com",
|
|
||||||
locale: "en",
|
|
||||||
timeZone: "Asia/Kolkata",
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
organizer: originalHost,
|
organizer: originalHost,
|
||||||
usersApartFromOrganizer: users.slice(1),
|
usersApartFromOrganizer: users.slice(1),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
await addWorkflowReminders([
|
await addWorkflowReminders([
|
||||||
{
|
{
|
||||||
bookingUid: bookingToReassignUid,
|
bookingUid: bookingToReassignUid,
|
||||||
method: WorkflowMethods.EMAIL,
|
method: WorkflowMethods.EMAIL,
|
||||||
scheduledDate: dateStringPlusTwo,
|
scheduledDate: new Date(dateStringPlusTwo),
|
||||||
scheduled: true,
|
scheduled: true,
|
||||||
workflowStepId: 1,
|
workflowStepId: 1,
|
||||||
workflowId: 1,
|
workflowId: 1,
|
||||||
@@ -135,16 +294,14 @@ describe("roundRobinManualReassignment test", () => {
|
|||||||
bookingId: 123,
|
bookingId: 123,
|
||||||
newUserId: newHost.id,
|
newUserId: newHost.id,
|
||||||
orgId: null,
|
orgId: null,
|
||||||
|
reassignedById: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(eventManagerSpy).toBeCalledTimes(1);
|
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
|
||||||
expect(eventManagerSpy).toHaveBeenCalledWith(
|
uid: bookingToReassignUid,
|
||||||
expect.any(Object),
|
changedOrganizer: true,
|
||||||
bookingToReassignUid,
|
destinationCalendars: expect.arrayContaining([expect.objectContaining(testDestinationCalendar)]),
|
||||||
undefined,
|
});
|
||||||
true,
|
|
||||||
expect.arrayContaining([expect.objectContaining(testDestinationCalendar)])
|
|
||||||
);
|
|
||||||
|
|
||||||
expectBookingToBeInDatabase({
|
expectBookingToBeInDatabase({
|
||||||
uid: bookingToReassignUid,
|
uid: bookingToReassignUid,
|
||||||
@@ -160,21 +317,73 @@ describe("roundRobinManualReassignment test", () => {
|
|||||||
expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails });
|
expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Manually reassign round robin host with fixed host as organizer", async () => {
|
test("should include conferenceCredentialId when reassigning booking with with fixed host as organizer", async () => {
|
||||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||||
|
|
||||||
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
const testDestinationCalendar = createTestDestinationCalendar();
|
||||||
|
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||||
|
const newHost = createTestUser({ id: 2 });
|
||||||
|
const users = [originalHost, newHost];
|
||||||
|
|
||||||
const users = testUsers;
|
const bookingToReassignUid = "booking-with-conference-credential";
|
||||||
|
const conferenceCredentialId = 999;
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
createRoundRobinEventType({
|
||||||
|
id: 1,
|
||||||
|
slug: "round-robin-event",
|
||||||
|
users,
|
||||||
|
locations: [
|
||||||
|
{
|
||||||
|
type: "integrations:zoom",
|
||||||
|
credentialId: conferenceCredentialId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
await createTestBooking({
|
||||||
|
eventTypeId: 1,
|
||||||
|
userId: originalHost.id,
|
||||||
|
bookingId: 123,
|
||||||
|
bookingUid: bookingToReassignUid,
|
||||||
|
location: "integrations:zoom",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
organizer: originalHost,
|
||||||
|
usersApartFromOrganizer: users.slice(1),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await roundRobinManualReassignment({
|
||||||
|
bookingId: 123,
|
||||||
|
newUserId: newHost.id,
|
||||||
|
orgId: null,
|
||||||
|
reassignedById: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify that EventManager.reschedule was called with an event containing conferenceCredentialId
|
||||||
|
expect(eventManagerRescheduleSpy).toHaveBeenCalledTimes(1);
|
||||||
|
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
expect((calledEvent as any).conferenceCredentialId).toBe(conferenceCredentialId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should reassign round robin attendee while keeping fixed host as organizer", async () => {
|
||||||
|
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||||
|
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||||
|
|
||||||
|
const testDestinationCalendar = createTestDestinationCalendar();
|
||||||
|
const fixedHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||||
|
const currentRRHost = createTestUser({ id: 2 });
|
||||||
|
const newHost = createTestUser({ id: 3 });
|
||||||
|
const users = [fixedHost, currentRRHost, newHost];
|
||||||
|
|
||||||
const bookingToReassignUid = "booking-to-reassign";
|
const bookingToReassignUid = "booking-to-reassign";
|
||||||
|
|
||||||
const fixedHost = users[0];
|
|
||||||
const currentRRHost = users[1];
|
|
||||||
const newHost = users[2];
|
|
||||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
|
||||||
|
|
||||||
await createBookingScenario(
|
await createBookingScenario(
|
||||||
getScenarioData({
|
getScenarioData({
|
||||||
workflows: [
|
workflows: [
|
||||||
@@ -186,27 +395,22 @@ describe("roundRobinManualReassignment test", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
{
|
createRoundRobinEventType({
|
||||||
id: 1,
|
id: 1,
|
||||||
slug: "round-robin-event",
|
slug: "round-robin-event",
|
||||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
users,
|
||||||
length: 45,
|
|
||||||
users: users.map((user) => ({ id: user.id })),
|
|
||||||
hosts: users.map((user) => ({
|
hosts: users.map((user) => ({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
isFixed: user.id === fixedHost.id,
|
isFixed: user.id === fixedHost.id,
|
||||||
})),
|
})),
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
bookings: [
|
bookings: [
|
||||||
{
|
await createTestBooking({
|
||||||
id: 123,
|
|
||||||
eventTypeId: 1,
|
eventTypeId: 1,
|
||||||
userId: fixedHost.id,
|
userId: fixedHost.id,
|
||||||
uid: bookingToReassignUid,
|
bookingId: 123,
|
||||||
status: BookingStatus.ACCEPTED,
|
bookingUid: bookingToReassignUid,
|
||||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
|
||||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
|
||||||
attendees: [
|
attendees: [
|
||||||
getMockBookingAttendee({
|
getMockBookingAttendee({
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -223,7 +427,7 @@ describe("roundRobinManualReassignment test", () => {
|
|||||||
timeZone: currentRRHost.timeZone,
|
timeZone: currentRRHost.timeZone,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
organizer: fixedHost,
|
organizer: fixedHost,
|
||||||
usersApartFromOrganizer: users.slice(1),
|
usersApartFromOrganizer: users.slice(1),
|
||||||
@@ -234,65 +438,59 @@ describe("roundRobinManualReassignment test", () => {
|
|||||||
bookingId: 123,
|
bookingId: 123,
|
||||||
newUserId: newHost.id,
|
newUserId: newHost.id,
|
||||||
orgId: null,
|
orgId: null,
|
||||||
|
reassignedById: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(eventManagerSpy).toBeCalledTimes(1);
|
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
|
||||||
|
uid: bookingToReassignUid,
|
||||||
|
changedOrganizer: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Ensure organizer stays the same
|
|
||||||
expectBookingToBeInDatabase({
|
expectBookingToBeInDatabase({
|
||||||
uid: bookingToReassignUid,
|
uid: bookingToReassignUid,
|
||||||
userId: fixedHost.id,
|
userId: fixedHost.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookingRepo = new BookingRepository(prismaMock);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const bookingRepo = new BookingRepository(prismaMock as any);
|
||||||
const attendees = await bookingRepo.getBookingAttendees(123);
|
const attendees = await bookingRepo.getBookingAttendees(123);
|
||||||
|
|
||||||
expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
|
expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
|
||||||
expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true);
|
expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("sends cancellation email to previous RR host when reassigning", async ({ emails }) => {
|
test("should send cancellation email to previous round robin attendee when reassigning", async () => {
|
||||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
await mockEventManagerReschedule();
|
||||||
|
|
||||||
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
|
||||||
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
|
|
||||||
|
|
||||||
const sendRoundRobinCancelledEmailsAndSMSSpy = vi.spyOn(
|
const sendRoundRobinCancelledEmailsAndSMSSpy = vi.spyOn(
|
||||||
await import("@calcom/emails"),
|
await import("@calcom/emails"),
|
||||||
"sendRoundRobinCancelledEmailsAndSMS"
|
"sendRoundRobinCancelledEmailsAndSMS"
|
||||||
);
|
);
|
||||||
|
|
||||||
const users = testUsers;
|
const testDestinationCalendar = createTestDestinationCalendar();
|
||||||
const originalHost = users[0];
|
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||||
const previousRRHost = users[1];
|
const previousRRHost = createTestUser({ id: 2 });
|
||||||
const newHost = users[2];
|
const newHost = createTestUser({ id: 3 });
|
||||||
|
const users = [originalHost, previousRRHost, newHost];
|
||||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
|
||||||
|
|
||||||
const bookingToReassignUid = "booking-to-reassign-with-previous-rr-host";
|
const bookingToReassignUid = "booking-to-reassign-with-previous-rr-host";
|
||||||
|
|
||||||
await createBookingScenario(
|
await createBookingScenario(
|
||||||
getScenarioData({
|
getScenarioData({
|
||||||
eventTypes: [
|
eventTypes: [
|
||||||
{
|
createRoundRobinEventType({
|
||||||
id: 1,
|
id: 1,
|
||||||
slug: "round-robin-event",
|
slug: "round-robin-event",
|
||||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
users,
|
||||||
length: 45,
|
}),
|
||||||
users: users.map((user) => ({ id: user.id })),
|
|
||||||
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
bookings: [
|
bookings: [
|
||||||
{
|
await createTestBooking({
|
||||||
id: 124,
|
|
||||||
eventTypeId: 1,
|
eventTypeId: 1,
|
||||||
userId: originalHost.id,
|
userId: originalHost.id,
|
||||||
uid: bookingToReassignUid,
|
bookingId: 124,
|
||||||
status: BookingStatus.ACCEPTED,
|
bookingUid: bookingToReassignUid,
|
||||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
|
||||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
|
||||||
attendees: [
|
attendees: [
|
||||||
getMockBookingAttendee({
|
getMockBookingAttendee({
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -309,7 +507,7 @@ describe("roundRobinManualReassignment test", () => {
|
|||||||
timeZone: previousRRHost.timeZone,
|
timeZone: previousRRHost.timeZone,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
],
|
],
|
||||||
organizer: originalHost,
|
organizer: originalHost,
|
||||||
usersApartFromOrganizer: users.slice(1),
|
usersApartFromOrganizer: users.slice(1),
|
||||||
@@ -320,8 +518,358 @@ describe("roundRobinManualReassignment test", () => {
|
|||||||
bookingId: 124,
|
bookingId: 124,
|
||||||
newUserId: newHost.id,
|
newUserId: newHost.id,
|
||||||
orgId: null,
|
orgId: null,
|
||||||
|
reassignedById: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(sendRoundRobinCancelledEmailsAndSMSSpy).toHaveBeenCalledTimes(1);
|
expect(sendRoundRobinCancelledEmailsAndSMSSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("roundRobinManualReassignment - Location Changes", () => {
|
||||||
|
setupAndTeardown();
|
||||||
|
|
||||||
|
const createConferencingUsers = () => {
|
||||||
|
return {
|
||||||
|
googleMeetHost: createTestUser({
|
||||||
|
id: 1,
|
||||||
|
destinationCalendar: {
|
||||||
|
integration: "google_calendar",
|
||||||
|
externalId: "test-calendar",
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
defaultConferencingApp: {
|
||||||
|
appSlug: "google-meet",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zoomHost: createTestUser({
|
||||||
|
id: 2,
|
||||||
|
metadata: {
|
||||||
|
defaultConferencingApp: {
|
||||||
|
appSlug: "zoom",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
noDefaultConferencingAppHost: createTestUser({
|
||||||
|
id: 3,
|
||||||
|
metadata: {},
|
||||||
|
}),
|
||||||
|
staticVideoLinkHost: createTestUser({
|
||||||
|
id: 4,
|
||||||
|
metadata: {
|
||||||
|
defaultConferencingApp: {
|
||||||
|
appSlug: "custom-video",
|
||||||
|
appLink: "https://custom-video.com/room123",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAppStore = async (config?: { googleMeet?: boolean; zoom?: boolean; customVideo?: boolean }) => {
|
||||||
|
const getAppFromSlug = await mockGetAppFromSlug();
|
||||||
|
getAppFromSlug.mockImplementation((slug?: string) => {
|
||||||
|
if (slug === "google-meet" && config?.googleMeet !== false) {
|
||||||
|
return {
|
||||||
|
appData: {
|
||||||
|
location: {
|
||||||
|
type: "integrations:google:meet",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
if (slug === "zoom" && config?.zoom !== false) {
|
||||||
|
return {
|
||||||
|
appData: {
|
||||||
|
location: {
|
||||||
|
type: "integrations:zoom",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
if (slug === "custom-video" && config?.customVideo !== false) {
|
||||||
|
return {
|
||||||
|
appData: {
|
||||||
|
location: {
|
||||||
|
type: "integrations:custom-video",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
return getAppFromSlug;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("when event has Organizer's Default Conferencing App location", () => {
|
||||||
|
test("should update location from Google Meet to Zoom when reassigning between hosts with different default conferencing apps", async () => {
|
||||||
|
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||||
|
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||||
|
|
||||||
|
await mockAppStore({ googleMeet: true, zoom: true });
|
||||||
|
|
||||||
|
const users = createConferencingUsers();
|
||||||
|
const googleMeetHost = users.googleMeetHost;
|
||||||
|
const zoomHost = users.zoomHost;
|
||||||
|
|
||||||
|
const bookingToReassignUid = "booking-location-change-test";
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
createRoundRobinEventType({
|
||||||
|
id: 1,
|
||||||
|
slug: "round-robin-event",
|
||||||
|
teamId: 1,
|
||||||
|
users: Object.values(users),
|
||||||
|
locations: [{ type: OrganizerDefaultConferencingAppType }, { type: "integrations:daily" }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
await createTestBooking({
|
||||||
|
eventTypeId: 1,
|
||||||
|
userId: googleMeetHost.id,
|
||||||
|
bookingId: 125,
|
||||||
|
bookingUid: bookingToReassignUid,
|
||||||
|
location: "integrations:google:meet",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
organizer: googleMeetHost,
|
||||||
|
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await roundRobinManualReassignment({
|
||||||
|
bookingId: 125,
|
||||||
|
newUserId: zoomHost.id,
|
||||||
|
orgId: null,
|
||||||
|
reassignReason: "Host unavailable",
|
||||||
|
reassignedById: 999,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
|
||||||
|
location: "integrations:zoom",
|
||||||
|
uid: bookingToReassignUid,
|
||||||
|
changedOrganizer: true,
|
||||||
|
destinationCalendars: expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
integration: "google_calendar",
|
||||||
|
externalId: "test-calendar",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
expectBookingToBeInDatabase({
|
||||||
|
uid: bookingToReassignUid,
|
||||||
|
userId: zoomHost.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should fallback to first available event location in Event Type when new host has no default conferencing app", async () => {
|
||||||
|
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||||
|
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||||
|
|
||||||
|
// (await mockGetAppFromSlug()).mockReturnValue(undefined);
|
||||||
|
|
||||||
|
const users = createConferencingUsers();
|
||||||
|
const googleMeetHost = users.googleMeetHost;
|
||||||
|
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
|
||||||
|
|
||||||
|
const bookingToReassignUid = "booking-fallback-location-test";
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
createRoundRobinEventType({
|
||||||
|
id: 2,
|
||||||
|
slug: "round-robin-event-fallback",
|
||||||
|
users: Object.values(users),
|
||||||
|
locations: [
|
||||||
|
{ type: OrganizerDefaultConferencingAppType },
|
||||||
|
{ type: "integrations:first-available" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
await createTestBooking({
|
||||||
|
eventTypeId: 2,
|
||||||
|
userId: googleMeetHost.id,
|
||||||
|
bookingId: 126,
|
||||||
|
bookingUid: bookingToReassignUid,
|
||||||
|
location: "integrations:google:meet",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
organizer: googleMeetHost,
|
||||||
|
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await roundRobinManualReassignment({
|
||||||
|
bookingId: 126,
|
||||||
|
newUserId: noDefaultConferencingAppHost.id,
|
||||||
|
orgId: null,
|
||||||
|
reassignReason: "Host unavailable",
|
||||||
|
reassignedById: 999,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
expect((calledEvent as any).location).toBe("integrations:first-available");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update location from Google Meet to static video link when new host has static video link app", async () => {
|
||||||
|
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||||
|
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||||
|
|
||||||
|
await mockAppStore({ customVideo: true });
|
||||||
|
|
||||||
|
const users = createConferencingUsers();
|
||||||
|
const googleMeetHost = users.googleMeetHost;
|
||||||
|
const staticVideoLinkHost = users.staticVideoLinkHost;
|
||||||
|
|
||||||
|
const bookingToReassignUid = "booking-static-link-test";
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
createRoundRobinEventType({
|
||||||
|
id: 4,
|
||||||
|
slug: "managed-event",
|
||||||
|
// This is a managed event type child which allows Organizer's Default Conferencing App
|
||||||
|
parentId: 1,
|
||||||
|
users: Object.values(users),
|
||||||
|
locations: [{ type: OrganizerDefaultConferencingAppType }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
await createTestBooking({
|
||||||
|
eventTypeId: 4,
|
||||||
|
userId: googleMeetHost.id,
|
||||||
|
bookingId: 128,
|
||||||
|
bookingUid: bookingToReassignUid,
|
||||||
|
location: "integrations:google:meet",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
organizer: googleMeetHost,
|
||||||
|
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await roundRobinManualReassignment({
|
||||||
|
bookingId: 128,
|
||||||
|
newUserId: staticVideoLinkHost.id,
|
||||||
|
orgId: null,
|
||||||
|
reassignReason: "Host unavailable",
|
||||||
|
reassignedById: 999,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
expect((calledEvent as any).location).toBe("https://custom-video.com/room123");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should update location from Static Video Link to Google Meet when new host has Google Meet app", async () => {
|
||||||
|
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||||
|
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||||
|
|
||||||
|
await mockAppStore({ googleMeet: true });
|
||||||
|
|
||||||
|
const users = createConferencingUsers();
|
||||||
|
const staticVideoLinkHost = users.staticVideoLinkHost;
|
||||||
|
const googleMeetHost = users.googleMeetHost;
|
||||||
|
|
||||||
|
const bookingToReassignUid = "booking-static-link-to-google-meet-test";
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
createRoundRobinEventType({
|
||||||
|
id: 3,
|
||||||
|
slug: "round-robin-event-error",
|
||||||
|
users: Object.values(users),
|
||||||
|
teamId: 1,
|
||||||
|
locations: [{ type: OrganizerDefaultConferencingAppType }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
await createTestBooking({
|
||||||
|
eventTypeId: 3,
|
||||||
|
userId: staticVideoLinkHost.id,
|
||||||
|
bookingId: 127,
|
||||||
|
bookingUid: bookingToReassignUid,
|
||||||
|
location: "https://custom-video.com/room123",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
organizer: staticVideoLinkHost,
|
||||||
|
usersApartFromOrganizer: Object.values(users).filter((user) => user.id !== staticVideoLinkHost.id),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await roundRobinManualReassignment({
|
||||||
|
bookingId: 127,
|
||||||
|
newUserId: googleMeetHost.id,
|
||||||
|
orgId: null,
|
||||||
|
reassignReason: "Host unavailable",
|
||||||
|
reassignedById: 999,
|
||||||
|
});
|
||||||
|
|
||||||
|
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
expect((calledEvent as any).location).toBe("integrations:google:meet");
|
||||||
|
|
||||||
|
await expectBookingToBeInDatabase({
|
||||||
|
uid: bookingToReassignUid,
|
||||||
|
userId: googleMeetHost.id,
|
||||||
|
location: "integrations:google:meet",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should throw error when Cal Video fallback fails", async () => {
|
||||||
|
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||||
|
await mockEventManagerReschedule({ failConferencing: true });
|
||||||
|
|
||||||
|
const users = createConferencingUsers();
|
||||||
|
const googleMeetHost = users.googleMeetHost;
|
||||||
|
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
|
||||||
|
|
||||||
|
const bookingToReassignUid = "booking-calvideo-error-test";
|
||||||
|
|
||||||
|
await createBookingScenario(
|
||||||
|
getScenarioData({
|
||||||
|
eventTypes: [
|
||||||
|
createRoundRobinEventType({
|
||||||
|
id: 3,
|
||||||
|
slug: "round-robin-event-error",
|
||||||
|
users: Object.values(users),
|
||||||
|
locations: [{ type: OrganizerDefaultConferencingAppType }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
bookings: [
|
||||||
|
await createTestBooking({
|
||||||
|
eventTypeId: 3,
|
||||||
|
userId: googleMeetHost.id,
|
||||||
|
bookingId: 127,
|
||||||
|
bookingUid: bookingToReassignUid,
|
||||||
|
location: "integrations:google:meet",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
organizer: googleMeetHost,
|
||||||
|
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
roundRobinManualReassignment({
|
||||||
|
bookingId: 127,
|
||||||
|
newUserId: noDefaultConferencingAppHost.id,
|
||||||
|
orgId: null,
|
||||||
|
reassignReason: "Host unavailable",
|
||||||
|
reassignedById: 999,
|
||||||
|
})
|
||||||
|
).rejects.toThrow("Failed to set video conferencing link, but the meeting has been rescheduled");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { cloneDeep } from "lodash";
|
import { cloneDeep } from "lodash";
|
||||||
|
|
||||||
import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations";
|
|
||||||
import dayjs from "@calcom/dayjs";
|
import dayjs from "@calcom/dayjs";
|
||||||
import {
|
import {
|
||||||
sendRoundRobinCancelledEmailsAndSMS,
|
sendRoundRobinCancelledEmailsAndSMS,
|
||||||
@@ -28,10 +27,10 @@ import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKey
|
|||||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
|
import { BookingLocationService } from "@calcom/lib/server/service/bookingLocationService";
|
||||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
|
||||||
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
|
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
@@ -126,7 +125,6 @@ export const roundRobinManualReassignment = async ({
|
|||||||
|
|
||||||
const originalOrganizer = booking.user;
|
const originalOrganizer = booking.user;
|
||||||
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
|
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
|
||||||
|
|
||||||
const newUser = newUserHost.user;
|
const newUser = newUserHost.user;
|
||||||
const newUserT = await getTranslation(newUser.locale || "en", "common");
|
const newUserT = await getTranslation(newUser.locale || "en", "common");
|
||||||
const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common");
|
const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common");
|
||||||
@@ -149,7 +147,7 @@ export const roundRobinManualReassignment = async ({
|
|||||||
|
|
||||||
const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common");
|
const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common");
|
||||||
let bookingLocation = booking.location;
|
let bookingLocation = booking.location;
|
||||||
|
let conferenceCredentialId: number | null = null;
|
||||||
if (hasOrganizerChanged) {
|
if (hasOrganizerChanged) {
|
||||||
const bookingResponses = booking.responses;
|
const bookingResponses = booking.responses;
|
||||||
const responseSchema = getBookingResponsesSchema({
|
const responseSchema = getBookingResponsesSchema({
|
||||||
@@ -159,15 +157,20 @@ export const roundRobinManualReassignment = async ({
|
|||||||
const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
|
const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
|
||||||
const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
|
const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
|
||||||
|
|
||||||
if (eventType.locations.some((location) => location.type === OrganizerDefaultConferencingAppType)) {
|
// Determine the location for the new host
|
||||||
const newUserMetadataSafeParse = userMetadataSchema.safeParse(newUser.metadata);
|
const isManagedEventType = !!eventType.parentId;
|
||||||
const defaultLocationUrl = newUserMetadataSafeParse.success
|
const isTeamEventType = !!eventType.teamId;
|
||||||
? newUserMetadataSafeParse?.data?.defaultConferencingApp?.appLink
|
|
||||||
: undefined;
|
const locationResult = BookingLocationService.getLocationForHost({
|
||||||
const currentBookingLocation = booking.location || "integrations:daily";
|
hostMetadata: newUser.metadata,
|
||||||
bookingLocation =
|
eventTypeLocations: eventType.locations,
|
||||||
defaultLocationUrl ||
|
isManagedEventType,
|
||||||
getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation;
|
isTeamEventType,
|
||||||
|
});
|
||||||
|
|
||||||
|
bookingLocation = locationResult.bookingLocation;
|
||||||
|
if (locationResult.requiresActualLink) {
|
||||||
|
conferenceCredentialId = locationResult.conferenceCredentialId;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newBookingTitle = getEventName({
|
const newBookingTitle = getEventName({
|
||||||
@@ -219,6 +222,18 @@ export const roundRobinManualReassignment = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When organizer hasn't changed, still extract conferenceCredentialId from event type locations
|
||||||
|
if (!hasOrganizerChanged && bookingLocation) {
|
||||||
|
const { conferenceCredentialId: extractedCredentialId } =
|
||||||
|
BookingLocationService.getLocationDetailsFromType({
|
||||||
|
locationType: bookingLocation,
|
||||||
|
eventTypeLocations: eventType.locations || [],
|
||||||
|
});
|
||||||
|
if (extractedCredentialId) {
|
||||||
|
conferenceCredentialId = extractedCredentialId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const destinationCalendar = await getDestinationCalendar({
|
const destinationCalendar = await getDestinationCalendar({
|
||||||
eventType,
|
eventType,
|
||||||
booking,
|
booking,
|
||||||
@@ -290,6 +305,7 @@ export const roundRobinManualReassignment = async ({
|
|||||||
customReplyToEmail: eventType?.customReplyToEmail,
|
customReplyToEmail: eventType?.customReplyToEmail,
|
||||||
location: bookingLocation,
|
location: bookingLocation,
|
||||||
...(platformClientParams ? platformClientParams : {}),
|
...(platformClientParams ? platformClientParams : {}),
|
||||||
|
conferenceCredentialId: conferenceCredentialId ?? undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const credentials = await prisma.credential.findMany({
|
const credentials = await prisma.credential.findMany({
|
||||||
|
|||||||
@@ -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) => {
|
||||||
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
|
const tooltipContent = (
|
||||||
<Badge variant="gray" className="whitespace-nowrap" title={attendee.email}>
|
<div className="space-y-1">
|
||||||
{attendee.name}
|
<div>
|
||||||
</Badge>
|
{t("email")}: {attendee.email}
|
||||||
</CellWithOverflowX>
|
</div>
|
||||||
))}
|
{attendee.phoneNumber && (
|
||||||
|
<div>
|
||||||
|
{t("phone")}: {attendee.phoneNumber}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CellWithOverflowX key={`${cellId}-${attendee.email}-${rowId}`} className="w-[200px]">
|
||||||
|
<Tooltip content={tooltipContent || undefined}>
|
||||||
|
<Badge variant="gray" className="whitespace-nowrap">
|
||||||
|
{attendee.name}
|
||||||
|
</Badge>
|
||||||
|
</Tooltip>
|
||||||
|
</CellWithOverflowX>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</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, {
|
||||||
{
|
staleTime: 30000,
|
||||||
teamId,
|
trpc: {
|
||||||
startDate,
|
context: { skipBatch: true },
|
||||||
endDate,
|
|
||||||
userId,
|
|
||||||
memberUserIds,
|
|
||||||
isAll,
|
|
||||||
routingFormId,
|
|
||||||
columnFilters,
|
|
||||||
},
|
},
|
||||||
{
|
});
|
||||||
staleTime: 30000,
|
|
||||||
trpc: {
|
|
||||||
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[] = [];
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useElementByClassName<T extends HTMLElement = HTMLDivElement>(
|
||||||
|
className?: string
|
||||||
|
): React.RefObject<T> {
|
||||||
|
const elementRef = useRef<T | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (className) {
|
||||||
|
const element = document.getElementsByClassName(className)[0] as T;
|
||||||
|
elementRef.current = element || null;
|
||||||
|
} else {
|
||||||
|
elementRef.current = null;
|
||||||
|
}
|
||||||
|
}, [className]);
|
||||||
|
|
||||||
|
return elementRef;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@formbricks/api": "^1.7.0",
|
"@formbricks/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,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -296,7 +296,7 @@ describe("FilterSegmentRepository.get()", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.segments).toHaveLength(1);
|
expect(result.segments).toHaveLength(1);
|
||||||
expect(result.preferredSegmentId).toBe(segment.id);
|
expect(result.preferredSegmentId).toEqual({ id: segment.id, type: "user" });
|
||||||
expect(result.segments[0].name).toBe("Important Contacts");
|
expect(result.segments[0].name).toBe("Important Contacts");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types";
|
import type { FilterSegmentOutput, SegmentIdentifier } from "@calcom/features/data-table/lib/types";
|
||||||
import {
|
import {
|
||||||
ZActiveFilters,
|
ZActiveFilters,
|
||||||
ZSortingState,
|
ZSortingState,
|
||||||
@@ -6,14 +6,14 @@ import {
|
|||||||
ZColumnVisibility,
|
ZColumnVisibility,
|
||||||
} from "@calcom/features/data-table/lib/types";
|
} from "@calcom/features/data-table/lib/types";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
import type { FilterSegment, UserFilterSegmentPreference } from "@calcom/prisma/client";
|
import type { UserFilterSegmentPreference } from "@calcom/prisma/client";
|
||||||
|
|
||||||
import type { TCreateFilterSegmentInputSchema, TUpdateFilterSegmentInputSchema } from "./filterSegment.type";
|
import type { TCreateFilterSegmentInputSchema, TUpdateFilterSegmentInputSchema } from "./filterSegment.type";
|
||||||
|
|
||||||
export interface IFilterSegmentRepository {
|
export interface IFilterSegmentRepository {
|
||||||
get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }): Promise<{
|
get({ userId, tableIdentifier }: { userId: number; tableIdentifier: string }): Promise<{
|
||||||
segments: FilterSegmentOutput[];
|
segments: FilterSegmentOutput[];
|
||||||
preferredSegmentId: number | null;
|
preferredSegmentId: SegmentIdentifier | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
create({
|
create({
|
||||||
@@ -22,7 +22,7 @@ export interface IFilterSegmentRepository {
|
|||||||
}: {
|
}: {
|
||||||
userId: number;
|
userId: number;
|
||||||
input: TCreateFilterSegmentInputSchema;
|
input: TCreateFilterSegmentInputSchema;
|
||||||
}): Promise<FilterSegment>;
|
}): Promise<FilterSegmentOutput>;
|
||||||
|
|
||||||
update({
|
update({
|
||||||
userId,
|
userId,
|
||||||
@@ -30,7 +30,7 @@ export interface IFilterSegmentRepository {
|
|||||||
}: {
|
}: {
|
||||||
userId: number;
|
userId: number;
|
||||||
input: TUpdateFilterSegmentInputSchema;
|
input: TUpdateFilterSegmentInputSchema;
|
||||||
}): Promise<FilterSegment>;
|
}): Promise<FilterSegmentOutput>;
|
||||||
|
|
||||||
delete({ userId, id }: { userId: number; id: number }): Promise<void>;
|
delete({ userId, id }: { userId: number; id: number }): Promise<void>;
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ export interface IFilterSegmentRepository {
|
|||||||
}: {
|
}: {
|
||||||
userId: number;
|
userId: number;
|
||||||
tableIdentifier: string;
|
tableIdentifier: string;
|
||||||
segmentId: number | null;
|
segmentId: SegmentIdentifier | null;
|
||||||
}): Promise<UserFilterSegmentPreference | null>;
|
}): Promise<UserFilterSegmentPreference | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,12 +124,19 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
|||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
segmentId: true,
|
segmentId: true,
|
||||||
|
systemSegmentId: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const preferredSegmentId = preference?.segmentId
|
||||||
|
? { id: preference.segmentId, type: "user" as const }
|
||||||
|
: preference?.systemSegmentId
|
||||||
|
? { id: preference.systemSegmentId, type: "system" as const }
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
segments: parsedSegments,
|
segments: parsedSegments,
|
||||||
preferredSegmentId: preference?.segmentId || null,
|
preferredSegmentId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,9 +181,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
|||||||
...(scope === "TEAM" ? { teamId } : {}),
|
...(scope === "TEAM" ? { teamId } : {}),
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
tableIdentifier: true,
|
||||||
|
scope: true,
|
||||||
|
activeFilters: true,
|
||||||
|
sorting: true,
|
||||||
|
columnVisibility: true,
|
||||||
|
columnSizing: true,
|
||||||
|
perPage: true,
|
||||||
|
searchTerm: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
userId: true,
|
||||||
|
teamId: true,
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return filterSegment;
|
// Parse the segment with Zod schemas
|
||||||
|
const parsedSegment: FilterSegmentOutput = {
|
||||||
|
...filterSegment,
|
||||||
|
activeFilters: ZActiveFilters.catch([]).parse(filterSegment.activeFilters),
|
||||||
|
sorting: ZSortingState.catch([]).parse(filterSegment.sorting),
|
||||||
|
columnVisibility: ZColumnVisibility.catch({}).parse(filterSegment.columnVisibility),
|
||||||
|
columnSizing: ZColumnSizing.catch({}).parse(filterSegment.columnSizing),
|
||||||
|
};
|
||||||
|
|
||||||
|
return parsedSegment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update({ userId, input }: { userId: number; input: TUpdateFilterSegmentInputSchema }) {
|
async update({ userId, input }: { userId: number; input: TUpdateFilterSegmentInputSchema }) {
|
||||||
@@ -228,9 +266,40 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
|||||||
perPage,
|
perPage,
|
||||||
searchTerm,
|
searchTerm,
|
||||||
},
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
tableIdentifier: true,
|
||||||
|
scope: true,
|
||||||
|
activeFilters: true,
|
||||||
|
sorting: true,
|
||||||
|
columnVisibility: true,
|
||||||
|
columnSizing: true,
|
||||||
|
perPage: true,
|
||||||
|
searchTerm: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
userId: true,
|
||||||
|
teamId: true,
|
||||||
|
team: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return updatedSegment;
|
// Parse the segment with Zod schemas
|
||||||
|
const parsedSegment: FilterSegmentOutput = {
|
||||||
|
...updatedSegment,
|
||||||
|
activeFilters: ZActiveFilters.catch([]).parse(updatedSegment.activeFilters),
|
||||||
|
sorting: ZSortingState.catch([]).parse(updatedSegment.sorting),
|
||||||
|
columnVisibility: ZColumnVisibility.catch({}).parse(updatedSegment.columnVisibility),
|
||||||
|
columnSizing: ZColumnSizing.catch({}).parse(updatedSegment.columnSizing),
|
||||||
|
};
|
||||||
|
|
||||||
|
return parsedSegment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete({ userId, id }: { userId: number; id: number }) {
|
async delete({ userId, id }: { userId: number; id: number }) {
|
||||||
@@ -282,7 +351,7 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
|||||||
}: {
|
}: {
|
||||||
userId: number;
|
userId: number;
|
||||||
tableIdentifier: string;
|
tableIdentifier: string;
|
||||||
segmentId: number | null;
|
segmentId: SegmentIdentifier | null;
|
||||||
}) {
|
}) {
|
||||||
if (segmentId === null) {
|
if (segmentId === null) {
|
||||||
await prisma.userFilterSegmentPreference.deleteMany({
|
await prisma.userFilterSegmentPreference.deleteMany({
|
||||||
@@ -294,6 +363,18 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSystemSegment = segmentId.type === "system";
|
||||||
|
|
||||||
|
const updateData = isSystemSegment
|
||||||
|
? {
|
||||||
|
segmentId: null,
|
||||||
|
systemSegmentId: segmentId.id,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
segmentId: segmentId.id,
|
||||||
|
systemSegmentId: null,
|
||||||
|
};
|
||||||
|
|
||||||
const preference = await prisma.userFilterSegmentPreference.upsert({
|
const preference = await prisma.userFilterSegmentPreference.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_tableIdentifier: {
|
userId_tableIdentifier: {
|
||||||
@@ -301,13 +382,11 @@ export class FilterSegmentRepository implements IFilterSegmentRepository {
|
|||||||
tableIdentifier,
|
tableIdentifier,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: updateData,
|
||||||
segmentId,
|
|
||||||
},
|
|
||||||
create: {
|
create: {
|
||||||
userId,
|
userId,
|
||||||
tableIdentifier,
|
tableIdentifier,
|
||||||
segmentId,
|
...updateData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import type { FilterSegmentOutput } from "@calcom/features/data-table/lib/types";
|
|
||||||
import {
|
import {
|
||||||
ZActiveFilters,
|
ZActiveFilters,
|
||||||
ZSortingState,
|
ZSortingState,
|
||||||
@@ -8,11 +7,6 @@ import {
|
|||||||
ZColumnVisibility,
|
ZColumnVisibility,
|
||||||
} from "@calcom/features/data-table/lib/types";
|
} from "@calcom/features/data-table/lib/types";
|
||||||
|
|
||||||
export type FilterSegmentsListResponse = {
|
|
||||||
segments: FilterSegmentOutput[];
|
|
||||||
preferredSegmentId: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ZListFilterSegmentsInputSchema = z.object({
|
export const ZListFilterSegmentsInputSchema = z.object({
|
||||||
tableIdentifier: z.string(),
|
tableIdentifier: z.string(),
|
||||||
});
|
});
|
||||||
@@ -58,9 +52,14 @@ export const ZCreateFilterSegmentInputSchema = z.discriminatedUnion("scope", [
|
|||||||
|
|
||||||
export type TCreateFilterSegmentInputSchema = z.infer<typeof ZCreateFilterSegmentInputSchema>;
|
export type TCreateFilterSegmentInputSchema = z.infer<typeof ZCreateFilterSegmentInputSchema>;
|
||||||
|
|
||||||
|
const ZSegmentIdentifier = z.discriminatedUnion("type", [
|
||||||
|
z.object({ id: z.string(), type: z.literal("system") }),
|
||||||
|
z.object({ id: z.number(), type: z.literal("user") }),
|
||||||
|
]);
|
||||||
|
|
||||||
export const ZSetFilterSegmentPreferenceInputSchema = z.object({
|
export const ZSetFilterSegmentPreferenceInputSchema = z.object({
|
||||||
tableIdentifier: z.string(),
|
tableIdentifier: z.string(),
|
||||||
segmentId: z.number().nullable(),
|
segmentId: ZSegmentIdentifier.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TSetFilterSegmentPreferenceInputSchema = z.infer<typeof ZSetFilterSegmentPreferenceInputSchema>;
|
export type TSetFilterSegmentPreferenceInputSchema = z.infer<typeof ZSetFilterSegmentPreferenceInputSchema>;
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user