Compare commits
49
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 | ||
|
|
e6959f3c7c | ||
|
|
67af8b4973 | ||
|
|
7794f2596e | ||
|
|
167cf6e352 | ||
|
|
3d778ba481 | ||
|
|
b8f0000b59 | ||
|
|
618ef63017 | ||
|
|
ae8e633716 | ||
|
|
4a23832388 | ||
|
|
02e486d757 | ||
|
|
af6e24f433 | ||
|
|
f9e24f51a7 | ||
|
|
9e9f815809 | ||
|
|
a91561f3c2 | ||
|
|
ff79064ca7 | ||
|
|
1a573e1619 | ||
|
|
511be7386c |
@@ -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"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"functions": {
|
||||
"pages/api/slots/*.ts": {
|
||||
"memory": 512
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.314",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.320",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
"@calcom/prisma": "*",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { TestingModule } from "@nestjs/testing";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
@@ -33,6 +34,7 @@ describe("AppController", () => {
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let apiKeyString: string;
|
||||
|
||||
@@ -108,6 +110,14 @@ describe("AppController", () => {
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
await app.init();
|
||||
});
|
||||
|
||||
+233
-3
@@ -15,11 +15,11 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
CreateEventTypeInput_2024_06_14,
|
||||
EventTypeOutput_2024_06_14,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
|
||||
@@ -50,9 +51,9 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let schedulesRepositoryFixture: SchedulesRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
|
||||
const platformAdminEmail = `managed-users-bookings-2024-08-13-admin-${randomString()}@api.com`;
|
||||
let platformAdmin: User;
|
||||
@@ -67,6 +68,10 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
let thirdManagedUser: CreateManagedUserData;
|
||||
|
||||
let firstManagedUserEventTypeId: number;
|
||||
let eventTypeRequiresConfirmationId: number;
|
||||
|
||||
const orgAdminManagedUserEmail = `managed-user-bookings-2024-08-13-org-admin-${randomString()}@api.com`;
|
||||
let orgAdminManagedUser: CreateManagedUserData;
|
||||
|
||||
let firstManagedUserBookingsCount = 0;
|
||||
let secondManagedUserBookingsCount = 0;
|
||||
@@ -85,9 +90,9 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
schedulesRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
|
||||
platformAdmin = await userRepositoryFixture.create({ email: platformAdminEmail });
|
||||
|
||||
@@ -95,6 +100,13 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
name: `oauth-client-users-organization-${randomString()}`,
|
||||
isPlatform: true,
|
||||
isOrganization: true,
|
||||
platformBilling: {
|
||||
create: {
|
||||
customerId: "cus_999",
|
||||
plan: "SCALE",
|
||||
subscriptionId: "sub_999",
|
||||
},
|
||||
},
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
@@ -243,6 +255,59 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
thirdManagedUser = responseBody.data;
|
||||
});
|
||||
|
||||
it(`should create org admin managed user`, async () => {
|
||||
const requestBody: CreateManagedUserInput = {
|
||||
email: orgAdminManagedUserEmail,
|
||||
timeZone: managedUsersTimeZone,
|
||||
weekStart: "Monday",
|
||||
timeFormat: 24,
|
||||
locale: Locales.FR,
|
||||
name: "Org Admin Smith",
|
||||
avatarUrl: "https://cal.com/api/avatar/2b735186-b01b-46d3-87da-019b8f61776b.png",
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v2/oauth-clients/${oAuthClient.id}/users`)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send(requestBody)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: CreateManagedUserOutput = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.user.email).toEqual(
|
||||
OAuthClientUsersService.getOAuthUserEmail(oAuthClient.id, requestBody.email)
|
||||
);
|
||||
expect(responseBody.data.accessToken).toBeDefined();
|
||||
expect(responseBody.data.refreshToken).toBeDefined();
|
||||
|
||||
orgAdminManagedUser = responseBody.data;
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/organizations/${organization.id}/memberships`)
|
||||
.set("x-cal-client-id", oAuthClient.id)
|
||||
.set("x-cal-secret-key", oAuthClient.secret)
|
||||
.send({
|
||||
userId: orgAdminManagedUser.user.id,
|
||||
role: "ADMIN",
|
||||
accepted: true,
|
||||
})
|
||||
.expect(201);
|
||||
});
|
||||
|
||||
it("should create an event type requiring confirmation for first managed user", async () => {
|
||||
const eventTypeRequiresConfirmation = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
|
||||
slug: `managed-user-bookings-event-type-requires-confirmation-${randomString()}`,
|
||||
length: 60,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
firstManagedUser.user.id
|
||||
);
|
||||
eventTypeRequiresConfirmationId = eventTypeRequiresConfirmation.id;
|
||||
});
|
||||
|
||||
describe("bookings using original emails", () => {
|
||||
it("managed user should be booked by managed user attendee and booking shows up in both users' bookings", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
@@ -543,10 +608,175 @@ describe("Managed user bookings 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("booking confirmation by org admin", () => {
|
||||
it("should allow org admin managed user to confirm booking using access token", async () => {
|
||||
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: firstManagedUser.user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2030, 0, 9, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)),
|
||||
title: "Booking requiring confirmation",
|
||||
uid: `booking-requiring-confirmation-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeRequiresConfirmationId,
|
||||
},
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
status: "PENDING",
|
||||
responses: {
|
||||
name: secondManagedUser.user.name,
|
||||
email: secondManagedUserEmail,
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: secondManagedUserEmail,
|
||||
name: secondManagedUser.user.name!,
|
||||
locale: secondManagedUser.user.locale,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/confirm`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||
expect(bookingData.status).toEqual("accepted");
|
||||
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
|
||||
|
||||
const confirmedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
|
||||
expect(confirmedBooking?.status).toEqual("ACCEPTED");
|
||||
});
|
||||
|
||||
it("should allow org admin managed user to reject booking using access token", async () => {
|
||||
const bookingRequiringConfirmation = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: firstManagedUser.user.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2030, 0, 9, 15, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2030, 0, 9, 16, 0, 0)),
|
||||
title: "Booking requiring rejection",
|
||||
uid: `booking-requiring-rejection-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: eventTypeRequiresConfirmationId,
|
||||
},
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
status: "PENDING",
|
||||
responses: {
|
||||
name: secondManagedUser.user.name,
|
||||
email: secondManagedUserEmail,
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: secondManagedUserEmail,
|
||||
name: secondManagedUser.user.name!,
|
||||
locale: secondManagedUser.user.locale,
|
||||
timeZone: secondManagedUser.user.timeZone,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${bookingRequiringConfirmation.uid}/decline`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: GetBookingOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const bookingData = responseBody.data as BookingOutput_2024_08_13;
|
||||
expect(bookingData.status).toEqual("rejected");
|
||||
expect(bookingData.uid).toEqual(bookingRequiringConfirmation.uid);
|
||||
|
||||
const rejectedBooking = await bookingsRepositoryFixture.getByUid(bookingRequiringConfirmation.uid);
|
||||
expect(rejectedBooking?.status).toEqual("REJECTED");
|
||||
});
|
||||
|
||||
it("should return unauthorized when org admin tries to confirm regular user's booking", async () => {
|
||||
const regularUser = await userRepositoryFixture.create({
|
||||
email: `managed-user-bookings-2024-08-13-regular-user-${randomString()}@api.com`,
|
||||
name: "Regular User",
|
||||
});
|
||||
|
||||
const regularUserEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `regular-user-event-type-requires-confirmation-${randomString()}`,
|
||||
slug: `regular-user-event-type-requires-confirmation-${randomString()}`,
|
||||
length: 60,
|
||||
requiresConfirmation: true,
|
||||
},
|
||||
regularUser.id
|
||||
);
|
||||
|
||||
const regularUserBooking = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: regularUser.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2030, 0, 9, 17, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2030, 0, 9, 18, 0, 0)),
|
||||
title: "Regular user booking requiring confirmation",
|
||||
uid: `regular-user-booking-${randomString()}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: regularUserEventType.id,
|
||||
},
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
status: "PENDING",
|
||||
responses: {
|
||||
name: "External Attendee",
|
||||
email: "external@example.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "external@example.com",
|
||||
name: "External Attendee",
|
||||
locale: "en",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${regularUserBooking.uid}/confirm`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${orgAdminManagedUser.accessToken}`)
|
||||
.expect(401);
|
||||
|
||||
await userRepositoryFixture.delete(regularUser.id);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.delete(firstManagedUser.user.id);
|
||||
await userRepositoryFixture.delete(secondManagedUser.user.id);
|
||||
await userRepositoryFixture.delete(thirdManagedUser.user.id);
|
||||
await userRepositoryFixture.delete(orgAdminManagedUser.user.id);
|
||||
await userRepositoryFixture.delete(platformAdmin.id);
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
@@ -18,7 +18,6 @@ import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repo
|
||||
import { BookingSeatRepositoryFixture } from "test/fixtures/repository/booking-seat.repository.fixture";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
@@ -27,7 +26,6 @@ import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@cal
|
||||
import {
|
||||
CancelBookingInput_2024_08_13,
|
||||
CancelSeatedBookingInput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
CreateSeatedBookingOutput_2024_08_13,
|
||||
GetBookingOutput_2024_08_13,
|
||||
GetBookingsOutput_2024_08_13,
|
||||
@@ -35,11 +33,9 @@ import {
|
||||
RescheduleSeatedBookingInput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
import { Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const googleMeetUrl = "https://meet.google.com/abc-def-ghi";
|
||||
|
||||
describe("Seated bookings", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
@@ -48,7 +44,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let bookingSeatRepositoryFixture: BookingSeatRepositoryFixture;
|
||||
@@ -58,7 +53,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
let apiKeyString: string;
|
||||
|
||||
let seatedEventTypeId: number;
|
||||
const maxRecurrenceCount = 3;
|
||||
|
||||
const seatedEventSlug = `seated-bookings-event-type-${randomString()}`;
|
||||
|
||||
@@ -83,7 +77,6 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
@@ -190,6 +183,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
guests: [],
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
@@ -264,6 +258,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
absent: false,
|
||||
seatUid: createdSeatedBooking.seatUid,
|
||||
bookingFieldsResponses: {
|
||||
guests: [],
|
||||
name: createdSeatedBooking.attendees[0].name,
|
||||
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
|
||||
},
|
||||
@@ -278,6 +273,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
guests: [],
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
@@ -517,6 +513,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
absent: false,
|
||||
seatUid,
|
||||
bookingFieldsResponses: {
|
||||
guests: [],
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
|
||||
+76
-1
@@ -21,7 +21,12 @@ import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import {
|
||||
CreateBookingInput_2024_08_13,
|
||||
BookingOutput_2024_08_13,
|
||||
CreateRecurringBookingInput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
@@ -44,6 +49,8 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
const VARIABLE_LENGTH_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
|
||||
let normalEventType: EventType;
|
||||
const NORMAL_EVENT_TYPE_SLUG = `variable-length-bookings-2024-08-13-event-type-${randomString()}`;
|
||||
let variableLengthRecurringEventType: EventType;
|
||||
const VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG = `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
@@ -105,6 +112,17 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
user.id
|
||||
);
|
||||
|
||||
variableLengthRecurringEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `variable-length-recurring-bookings-2024-08-13-event-type-${randomString()}`,
|
||||
slug: VARIABLE_LENGTH_RECURRING_EVENT_TYPE_SLUG,
|
||||
length: 15,
|
||||
metadata: { multipleDuration: [15, 30, 60] },
|
||||
recurringEvent: { freq: 2, count: 3, interval: 1 }, // Weekly, 3 times, every 1 week
|
||||
},
|
||||
user.id
|
||||
);
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
@@ -246,6 +264,63 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("create recurring bookings", () => {
|
||||
it("should create recurring bookings with custom lengthInMinutes", async () => {
|
||||
const lengthInMinutes = 30;
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 1, 1, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: variableLengthRecurringEventType.id,
|
||||
lengthInMinutes,
|
||||
recurrenceCount: 2,
|
||||
attendee: {
|
||||
name: "Mr Recurring",
|
||||
email: "mr_recurring@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201)
|
||||
.then(async (response) => {
|
||||
const responseBody: { status: string; data: RecurringBookingOutput_2024_08_13[] } = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data).toHaveLength(2);
|
||||
|
||||
const firstBooking = responseBody.data[0];
|
||||
const secondBooking = responseBody.data[1];
|
||||
expect(firstBooking.duration).toEqual(lengthInMinutes);
|
||||
expect(secondBooking.duration).toEqual(lengthInMinutes);
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject recurring booking with invalid lengthInMinutes", async () => {
|
||||
const body: CreateRecurringBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 3, 1, 10, 0, 0)).toISOString(),
|
||||
eventTypeId: variableLengthRecurringEventType.id,
|
||||
lengthInMinutes: 45, // Not in allowed [15, 30, 60]
|
||||
recurrenceCount: 2,
|
||||
attendee: {
|
||||
name: "Mr Invalid",
|
||||
email: "mr_invalid@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
@@ -420,6 +420,9 @@ export class InputBookingsService_2024_08_13 {
|
||||
throw new NotFoundException(`Event type with id=${inputBooking.eventTypeId} is not a recurring event`);
|
||||
}
|
||||
|
||||
this.validateBookingLengthInMinutes(inputBooking, eventType);
|
||||
const lengthInMinutes = inputBooking.lengthInMinutes ?? eventType.length;
|
||||
|
||||
const occurrence = recurringEventSchema.parse(eventType.recurringEvent);
|
||||
const repeatsEvery = occurrence.interval;
|
||||
|
||||
@@ -456,7 +459,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
const location = inputLocation ? this.transformLocation(inputLocation) : undefined;
|
||||
|
||||
for (let i = 0; i < repeatsTimes; i++) {
|
||||
const endTime = startTime.plus({ minutes: eventType.length });
|
||||
const endTime = startTime.plus({ minutes: lengthInMinutes });
|
||||
|
||||
events.push({
|
||||
start: startTime.toISO(),
|
||||
@@ -702,6 +705,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
beforeUpdatedDate: queryParams.beforeUpdatedAt,
|
||||
afterCreatedDate: queryParams.afterCreatedAt,
|
||||
beforeCreatedDate: queryParams.beforeCreatedAt,
|
||||
bookingUid: queryParams.bookingUid,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypeOwnershipGuard } from "@/modules/event-types/guards/event-type-ownership.guard";
|
||||
import { OAuthClientModule } from "@/modules/oauth-clients/oauth-client.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
+45
@@ -2147,6 +2147,51 @@ describe("Event types Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventType Hidden Property", () => {
|
||||
let createdEventTypeId: number;
|
||||
|
||||
it("should create an event type with hidden=true", async () => {
|
||||
const createPayload = {
|
||||
title: "Hidden Event",
|
||||
slug: "hidden-event",
|
||||
lengthInMinutes: 30,
|
||||
hidden: true,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/api/v2/event-types")
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(createPayload)
|
||||
.expect(201);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const createdEventType = responseBody.data;
|
||||
expect(createdEventType).toHaveProperty("id");
|
||||
expect(createdEventType.title).toEqual(createPayload.title);
|
||||
expect(createdEventType.slug).toEqual(createPayload.slug);
|
||||
expect(createdEventType.lengthInMinutes).toEqual(createPayload.lengthInMinutes);
|
||||
expect(createdEventType.hidden).toBe(true);
|
||||
|
||||
createdEventTypeId = createdEventType.id;
|
||||
});
|
||||
|
||||
it("should update the hidden property to false", async () => {
|
||||
const updatePayload = {
|
||||
hidden: false,
|
||||
};
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.patch(`/api/v2/event-types/${createdEventTypeId}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_06_14)
|
||||
.send(updatePayload)
|
||||
.expect(200);
|
||||
|
||||
const responseBody: ApiSuccessResponse<EventTypeOutput_2024_06_14> = response.body;
|
||||
const updatedEventType = responseBody.data;
|
||||
+expect(updatedEventType.hidden).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await oauthClientRepositoryFixture.delete(oAuthClient.id);
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
+3
@@ -89,6 +89,7 @@ type Input = Pick<
|
||||
| "hideCalendarEventDetails"
|
||||
| "hideOrganizerEmail"
|
||||
| "calVideoSettings"
|
||||
| "hidden"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@@ -127,6 +128,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
calVideoSettings,
|
||||
hidden,
|
||||
} = databaseEventType;
|
||||
|
||||
const locations = this.transformLocations(databaseEventType.locations);
|
||||
@@ -203,6 +205,7 @@ export class OutputEventTypesService_2024_06_14 {
|
||||
hideCalendarEventDetails,
|
||||
hideOrganizerEmail,
|
||||
calVideoSettings,
|
||||
hidden,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -229,4 +229,19 @@ export class SchedulesRepository_2024_06_11 {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getSchedulesByUserIds(userIds: number[], skip: number, take: number) {
|
||||
return this.dbRead.prisma.schedule.findMany({
|
||||
where: {
|
||||
userId: {
|
||||
in: userIds,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
availability: true,
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { AtomsConferencingAppsController } from "@/modules/atoms/controllers/ato
|
||||
import { AtomsController } from "@/modules/atoms/controllers/atoms.controller";
|
||||
import { AtomsEventTypesController } from "@/modules/atoms/controllers/atoms.event-types.controller";
|
||||
import { AtomsSchedulesController } from "@/modules/atoms/controllers/atoms.schedules.controller";
|
||||
import { AtomsVerificationController } from "@/modules/atoms/controllers/atoms.verification.controller";
|
||||
import { AttributesAtomsService } from "@/modules/atoms/services/attributes-atom.service";
|
||||
import { ConferencingAtomsService } from "@/modules/atoms/services/conferencing-atom.service";
|
||||
import { EventTypesAtomService } from "@/modules/atoms/services/event-types-atom.service";
|
||||
import { SchedulesAtomsService } from "@/modules/atoms/services/schedules-atom.service";
|
||||
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OrganizationsModule } from "@/modules/organizations/organizations.module";
|
||||
@@ -32,6 +34,7 @@ import { Module } from "@nestjs/common";
|
||||
AtomsRepository,
|
||||
UsersService,
|
||||
SchedulesAtomsService,
|
||||
VerificationAtomsService,
|
||||
RedisService,
|
||||
],
|
||||
exports: [EventTypesAtomService],
|
||||
@@ -40,6 +43,7 @@ import { Module } from "@nestjs/common";
|
||||
AtomsEventTypesController,
|
||||
AtomsConferencingAppsController,
|
||||
AtomsSchedulesController,
|
||||
AtomsVerificationController,
|
||||
],
|
||||
})
|
||||
export class AtomsModule {}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { Throttle } from "@/lib/endpoint-throttler-decorator";
|
||||
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
||||
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
||||
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
||||
import { SendVerificationEmailOutput } from "@/modules/atoms/outputs/send-verification-email.output";
|
||||
import { VerifyEmailCodeOutput } from "@/modules/atoms/outputs/verify-email-code.output";
|
||||
import { VerificationAtomsService } from "@/modules/atoms/services/verification-atom.service";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Version,
|
||||
VERSION_NEUTRAL,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Get,
|
||||
Query,
|
||||
} from "@nestjs/common";
|
||||
import { ApiTags as DocsTags, ApiExcludeController as DocsExcludeController } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/atoms",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@DocsTags("Atoms - verification endpoints for atoms")
|
||||
@DocsExcludeController(true)
|
||||
export class AtomsVerificationController {
|
||||
constructor(private readonly verificationService: VerificationAtomsService) {}
|
||||
|
||||
@Post("/verification/email/send-code")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ limit: 3, ttl: 60000, blockDuration: 60000, name: "atoms_verification_email_send_code" })
|
||||
async sendEmailVerificationCode(
|
||||
@Body() body: SendVerificationEmailInput
|
||||
): Promise<SendVerificationEmailOutput> {
|
||||
const result = await this.verificationService.sendEmailVerificationCode({
|
||||
email: body.email,
|
||||
username: body.username,
|
||||
language: body.language,
|
||||
isVerifyingEmail: body.isVerifyingEmail,
|
||||
});
|
||||
|
||||
return {
|
||||
data: { sent: result.ok && !result.skipped },
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Get("/verification/email/check")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async checkEmailVerificationRequired(
|
||||
@Query() query: CheckEmailVerificationRequiredParams
|
||||
): Promise<ApiResponse<boolean>> {
|
||||
const required = await this.verificationService.checkEmailVerificationRequired({
|
||||
email: query.email,
|
||||
userSessionEmail: query.userSessionEmail,
|
||||
});
|
||||
|
||||
return {
|
||||
data: required,
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/verification/email/verify-code")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyEmailCode(@Body() body: VerifyEmailCodeInput): Promise<VerifyEmailCodeOutput> {
|
||||
const verified = await this.verificationService.verifyEmailCodeUnAuthenticated({
|
||||
email: body.email,
|
||||
code: body.code,
|
||||
});
|
||||
|
||||
return {
|
||||
data: { verified },
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
|
||||
@Post("/verification/email/verify-code-authenticated")
|
||||
@Version(VERSION_NEUTRAL)
|
||||
@UseGuards(ApiAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async verifyEmailCodeAuthenticated(
|
||||
@Body() body: VerifyEmailCodeInput,
|
||||
@GetUser() user: UserWithProfile
|
||||
): Promise<VerifyEmailCodeOutput> {
|
||||
const verified = await this.verificationService.verifyEmailCodeAuthenticated(user, {
|
||||
email: body.email,
|
||||
code: body.code,
|
||||
});
|
||||
|
||||
return {
|
||||
data: { verified },
|
||||
status: SUCCESS_STATUS,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEmail, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CheckEmailVerificationRequiredParams {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "user@example.com" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
userSessionEmail?: string;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsEmail, IsOptional, IsString, IsBoolean } from "class-validator";
|
||||
|
||||
export class SendVerificationEmailInput {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "johndoe" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
username?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: "en" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
language?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isVerifyingEmail?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { IsEmail, IsString } from "class-validator";
|
||||
|
||||
export class VerifyEmailCodeInput {
|
||||
@ApiProperty({ example: "user@example.com" })
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiProperty({ example: "123456" })
|
||||
@IsString()
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested, IsBoolean } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class SendVerificationEmailData {
|
||||
@ApiProperty({ example: true })
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly sent!: boolean;
|
||||
}
|
||||
|
||||
export class SendVerificationEmailOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => SendVerificationEmailData)
|
||||
@Expose()
|
||||
@ApiProperty({ type: SendVerificationEmailData })
|
||||
readonly data!: SendVerificationEmailData;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ApiProperty } from "@nestjs/swagger";
|
||||
import { Expose, Type } from "class-transformer";
|
||||
import { IsString, ValidateNested, IsBoolean } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
export class VerifyEmailCodeData {
|
||||
@ApiProperty({ example: true })
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
readonly verified!: boolean;
|
||||
}
|
||||
|
||||
export class VerifyEmailCodeOutput {
|
||||
@ApiProperty({ example: SUCCESS_STATUS, enum: [SUCCESS_STATUS, ERROR_STATUS] })
|
||||
@IsString()
|
||||
@Expose()
|
||||
readonly status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => VerifyEmailCodeData)
|
||||
@Expose()
|
||||
@ApiProperty({ type: VerifyEmailCodeData })
|
||||
readonly data!: VerifyEmailCodeData;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { CheckEmailVerificationRequiredParams } from "@/modules/atoms/inputs/check-email-verification-required-params";
|
||||
import { SendVerificationEmailInput } from "@/modules/atoms/inputs/send-verification-email.input";
|
||||
import { VerifyEmailCodeInput } from "@/modules/atoms/inputs/verify-email-code.input";
|
||||
import { UserWithProfile } from "@/modules/users/users.repository";
|
||||
import { Injectable, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
|
||||
import {
|
||||
verifyCodeUnAuthenticated,
|
||||
verifyCodeAuthenticated,
|
||||
sendEmailVerificationByCode,
|
||||
checkEmailVerificationRequired,
|
||||
} from "@calcom/platform-libraries";
|
||||
|
||||
@Injectable()
|
||||
export class VerificationAtomsService {
|
||||
async checkEmailVerificationRequired(input: CheckEmailVerificationRequiredParams) {
|
||||
return await checkEmailVerificationRequired(input);
|
||||
}
|
||||
|
||||
async verifyEmailCodeUnAuthenticated(input: VerifyEmailCodeInput) {
|
||||
try {
|
||||
return await verifyCodeUnAuthenticated(input);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "invalid_code") {
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
if (error.message === "BAD_REQUEST") {
|
||||
throw new BadRequestException("Email and code are required");
|
||||
}
|
||||
}
|
||||
throw new BadRequestException("Verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async verifyEmailCodeAuthenticated(user: UserWithProfile, input: VerifyEmailCodeInput) {
|
||||
try {
|
||||
return await verifyCodeAuthenticated({
|
||||
user,
|
||||
email: input.email,
|
||||
code: input.code,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === "invalid_code") {
|
||||
throw new BadRequestException("Invalid verification code");
|
||||
}
|
||||
if (error.message === "BAD_REQUEST") {
|
||||
throw new BadRequestException("Email, code, and user ID are required");
|
||||
}
|
||||
}
|
||||
throw new UnauthorizedException("Verification failed");
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmailVerificationCode(input: SendVerificationEmailInput) {
|
||||
return await sendEmailVerificationByCode({
|
||||
email: input.email,
|
||||
username: input.username,
|
||||
language: input.language,
|
||||
isVerifyingEmail: input.isVerifyingEmail,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { NextAuthStrategy } from "@/modules/auth/strategies/next-auth/next-auth.
|
||||
import { DeploymentsModule } from "@/modules/deployments/deployments.module";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { ProfilesModule } from "@/modules/profiles/profiles.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -22,7 +21,6 @@ import { PassportModule } from "@nestjs/passport";
|
||||
MembershipsModule,
|
||||
TokensModule,
|
||||
DeploymentsModule,
|
||||
ProfilesModule,
|
||||
],
|
||||
providers: [NextAuthGuard, NextAuthStrategy, ApiAuthGuard, ApiAuthStrategy, OAuthFlowService],
|
||||
exports: [NextAuthGuard, ApiAuthGuard],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { DeploymentsRepository } from "@/modules/deployments/deployments.repository";
|
||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||
import { JwtService } from "@/modules/jwt/jwt.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
@@ -20,6 +21,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import { createRequest } from "node-mocks-http";
|
||||
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
@@ -45,6 +47,7 @@ describe("ApiAuthStrategy", () => {
|
||||
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
|
||||
let oAuthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
const validApiKeyEmail = `api-auth-api-key-user-${randomString()}@api.com`;
|
||||
const validAccessTokenEmail = `api-auth-access-token-user-${randomString()}@api.com`;
|
||||
@@ -65,6 +68,7 @@ describe("ApiAuthStrategy", () => {
|
||||
}),
|
||||
ProfilesModule,
|
||||
TokensModule,
|
||||
MembershipsModule,
|
||||
],
|
||||
providers: [
|
||||
MockedRedisService,
|
||||
@@ -91,6 +95,7 @@ describe("ApiAuthStrategy", () => {
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(module);
|
||||
oAuthClientRepositoryFixture = new OAuthClientRepositoryFixture(module);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(module);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(module);
|
||||
organization = await teamRepositoryFixture.create({ name: `api-auth-organization-1-${randomString()}` });
|
||||
organizationTwo = await teamRepositoryFixture.create({
|
||||
name: `api-auth-organization-2-${randomString()}`,
|
||||
@@ -121,6 +126,20 @@ describe("ApiAuthStrategy", () => {
|
||||
organization: { connect: { id: organizationTwo.id } },
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: validOAuthUser.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: validOAuthUser.id } },
|
||||
team: { connect: { id: organizationTwo.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
|
||||
@@ -4,9 +4,9 @@ import { isOriginAllowed } from "@/lib/is-origin-allowed/is-origin-allowed";
|
||||
import { BaseStrategy } from "@/lib/passport/strategies/types";
|
||||
import { ApiKeysRepository } from "@/modules/api-keys/api-keys-repository";
|
||||
import { DeploymentsService } from "@/modules/deployments/deployments.service";
|
||||
import { MembershipsRepository } from "@/modules/memberships/memberships.repository";
|
||||
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
|
||||
import { OAuthFlowService } from "@/modules/oauth-clients/services/oauth-flow.service";
|
||||
import { ProfilesRepository } from "@/modules/profiles/profiles.repository";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { TokensService } from "@/modules/tokens/tokens.service";
|
||||
import { UsersService } from "@/modules/users/services/users.service";
|
||||
@@ -45,8 +45,8 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
private readonly userRepository: UsersRepository,
|
||||
private readonly apiKeyRepository: ApiKeysRepository,
|
||||
private readonly oauthRepository: OAuthClientRepository,
|
||||
private readonly profilesRepository: ProfilesRepository,
|
||||
private readonly usersService: UsersService
|
||||
private readonly usersService: UsersService,
|
||||
private readonly membershipsRepository: MembershipsRepository
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -172,7 +172,9 @@ export class ApiAuthStrategy extends PassportStrategy(BaseStrategy, "api-auth")
|
||||
throw new UnauthorizedException("ApiAuthStrategy - oAuth client - Invalid client secret");
|
||||
}
|
||||
|
||||
const platformCreatorId = await this.profilesRepository.getPlatformOwnerUserId(client.organizationId);
|
||||
const platformCreatorId =
|
||||
(await this.membershipsRepository.findPlatformOwnerUserId(client.organizationId)) ||
|
||||
(await this.membershipsRepository.findPlatformAdminUserId(client.organizationId));
|
||||
|
||||
if (!platformCreatorId) {
|
||||
throw new UnauthorizedException(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OrganizationsTeamsBookingsModule } from "@/modules/organizations/teams/
|
||||
import { OrganizationsUsersBookingsModule } from "@/modules/organizations/users/bookings/organizations-users-bookings.module";
|
||||
import { RouterModule } from "@/modules/router/router.module";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsSchedulesModule } from "@/modules/teams/schedules/teams-schedules.module";
|
||||
import { TimezoneModule } from "@/modules/timezones/timezones.module";
|
||||
import { VerifiedResourcesModule } from "@/modules/verified-resources/verified-resources.module";
|
||||
import type { MiddlewareConsumer, NestModule } from "@nestjs/common";
|
||||
@@ -38,6 +39,7 @@ import { WebhooksModule } from "./webhooks/webhooks.module";
|
||||
OrganizationsRoutingFormsModule,
|
||||
VerifiedResourcesModule,
|
||||
RouterModule,
|
||||
TeamsSchedulesModule,
|
||||
],
|
||||
})
|
||||
export class EndpointsModule implements NestModule {
|
||||
|
||||
@@ -20,6 +20,36 @@ export class MembershipsRepository {
|
||||
return membership;
|
||||
}
|
||||
|
||||
async findPlatformOwnerUserId(organizationId: number): Promise<number | undefined> {
|
||||
const owner = await this.dbRead.prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return owner?.userId ?? undefined;
|
||||
}
|
||||
|
||||
async findPlatformAdminUserId(organizationId: number): Promise<number | undefined> {
|
||||
const admin = await this.dbRead.prisma.membership.findFirst({
|
||||
where: {
|
||||
teamId: organizationId,
|
||||
role: "ADMIN",
|
||||
accepted: true,
|
||||
},
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
return admin?.userId ?? undefined;
|
||||
}
|
||||
|
||||
async findMembershipByTeamId(teamId: number, userId: number) {
|
||||
const membership = await this.dbRead.prisma.membership.findUnique({
|
||||
where: {
|
||||
|
||||
+10
@@ -15,6 +15,7 @@ import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PlatformOAuthClient, Team, User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
|
||||
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
|
||||
@@ -63,6 +64,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
|
||||
let oAuthClientsRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let profilesRepositoryFixture: ProfileRepositoryFixture;
|
||||
let membershipRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let user: User;
|
||||
let organization: Team;
|
||||
@@ -89,6 +91,7 @@ describe("OAuthFlow Endpoints", () => {
|
||||
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
|
||||
usersRepositoryFixtures = new UserRepositoryFixture(moduleRef);
|
||||
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
|
||||
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await usersRepositoryFixtures.create({
|
||||
email: userEmail,
|
||||
@@ -104,6 +107,13 @@ describe("OAuthFlow Endpoints", () => {
|
||||
movedFromUser: { connect: { id: user.id } },
|
||||
organization: { connect: { id: organization.id } },
|
||||
});
|
||||
|
||||
await membershipRepositoryFixture.create({
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: organization.id } },
|
||||
role: "OWNER",
|
||||
accepted: true,
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
});
|
||||
|
||||
|
||||
+128
-1
@@ -66,6 +66,8 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
|
||||
let orgEventTypeId2: number;
|
||||
let nonOrgEventTypeId: number;
|
||||
|
||||
let collectiveOrgBookingUid: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
orgUserEmail2,
|
||||
@@ -91,12 +93,17 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
|
||||
organization = await organizationsRepositoryFixture.create({ name: "organization bookings" });
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
team1 = await teamRepositoryFixture.create({
|
||||
name: "team orgs booking 1",
|
||||
isOrganization: false,
|
||||
parent: { connect: { id: organization.id } },
|
||||
createdByOAuthClient: {
|
||||
connect: {
|
||||
id: oAuthClient.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
oAuthClient = await createOAuthClient(organization.id);
|
||||
|
||||
nonOrgUser1 = await userRepositoryFixture.create({
|
||||
email: nonOrgUserEmail1,
|
||||
@@ -315,6 +322,7 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
|
||||
|
||||
if (responseDataIsBooking(responseBody.data)) {
|
||||
const data: BookingOutput_2024_08_13 = responseBody.data;
|
||||
collectiveOrgBookingUid = data.uid;
|
||||
expect(data.id).toBeDefined();
|
||||
expect(data.uid).toBeDefined();
|
||||
expect(data.hosts.length).toEqual(1);
|
||||
@@ -669,6 +677,125 @@ describe("Organizations Bookings Endpoints 2024-08-13", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get by bookingUid param", () => {
|
||||
it("should get a specific booking by bookingUid query param", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${collectiveOrgBookingUid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
|
||||
const data: (
|
||||
| BookingOutput_2024_08_13
|
||||
| RecurringBookingOutput_2024_08_13
|
||||
| GetSeatedBookingOutput_2024_08_13
|
||||
)[] = responseBody.data;
|
||||
expect(data.length).toEqual(1);
|
||||
expect(responseDataIsBooking(data[0])).toBe(true);
|
||||
|
||||
if (responseDataIsBooking(data[0])) {
|
||||
const booking: BookingOutput_2024_08_13 = data[0];
|
||||
expect(booking.uid).toEqual(collectiveOrgBookingUid);
|
||||
expect(booking.eventTypeId).toEqual(orgEventTypeId);
|
||||
expect(booking.status).toEqual("accepted");
|
||||
expect(booking.attendees.length).toEqual(2);
|
||||
expect(booking.attendees[0].name).toEqual("alice");
|
||||
expect(booking.attendees[0].email).toEqual("alice@gmail.com");
|
||||
} else {
|
||||
throw new Error("Expected single booking but received different response type");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array for non-existent booking UID query param", async () => {
|
||||
const nonExistentUid = "non-existent-booking-uid";
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${nonExistentUid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty array for booking UID not belonging to the organization", async () => {
|
||||
const regularUser = await userRepositoryFixture.create({
|
||||
email: `org-bookings-regular-user-${Math.floor(Math.random() * 10000)}@api.com`,
|
||||
name: "Regular User",
|
||||
});
|
||||
|
||||
const regularUserEventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `regular-user-event-type-${Math.floor(Math.random() * 10000)}`,
|
||||
slug: `regular-user-event-type-${Math.floor(Math.random() * 10000)}`,
|
||||
length: 60,
|
||||
bookingFields: [],
|
||||
locations: [],
|
||||
},
|
||||
regularUser.id
|
||||
);
|
||||
|
||||
const regularUserBooking = await bookingsRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: regularUser.id,
|
||||
},
|
||||
},
|
||||
startTime: new Date(Date.UTC(2030, 0, 15, 13, 0, 0)),
|
||||
endTime: new Date(Date.UTC(2030, 0, 15, 14, 0, 0)),
|
||||
title: "Regular user booking",
|
||||
uid: `regular-user-booking-${Math.floor(Math.random() * 10000)}`,
|
||||
eventType: {
|
||||
connect: {
|
||||
id: regularUserEventType.id,
|
||||
},
|
||||
},
|
||||
location: "https://meet.google.com/regular-user",
|
||||
customInputs: {},
|
||||
metadata: {},
|
||||
status: "ACCEPTED",
|
||||
responses: {
|
||||
name: "Regular Attendee",
|
||||
email: "regular@example.com",
|
||||
},
|
||||
attendees: {
|
||||
create: {
|
||||
email: "regular@example.com",
|
||||
name: "Regular Attendee",
|
||||
locale: "en",
|
||||
timeZone: "Europe/Rome",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${organization.id}/bookings?bookingUid=${regularUserBooking.uid}`)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set(X_CAL_CLIENT_ID, oAuthClient.id)
|
||||
.set(X_CAL_SECRET_KEY, oAuthClient.secret)
|
||||
.expect(200)
|
||||
.then(async (response) => {
|
||||
const responseBody: GetBookingsOutput_2024_08_13 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(responseBody.data).toBeDefined();
|
||||
expect(responseBody.data.length).toEqual(0);
|
||||
|
||||
await userRepositoryFixture.delete(regularUser.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function createOAuthClient(organizationId: number) {
|
||||
|
||||
+7
@@ -39,6 +39,7 @@ import {
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Query,
|
||||
Logger,
|
||||
} from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
@@ -66,6 +67,8 @@ export type EventTypeHandlerResponse = {
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsEventTypesController {
|
||||
private readonly logger = new Logger("OrganizationsEventTypesController");
|
||||
|
||||
constructor(
|
||||
private readonly organizationsEventTypesService: OrganizationsEventTypesService,
|
||||
private readonly inputService: InputOrganizationsEventTypesService,
|
||||
@@ -88,6 +91,10 @@ export class OrganizationsEventTypesController {
|
||||
teamId,
|
||||
bodyEventType
|
||||
);
|
||||
this.logger.debug(
|
||||
"nl debug - create org team event type - transformedBody",
|
||||
JSON.stringify(transformedBody, null, 2)
|
||||
);
|
||||
|
||||
const eventType = await this.organizationsEventTypesService.createOrganizationTeamEventType(
|
||||
user,
|
||||
|
||||
+4
@@ -47,6 +47,10 @@ export class OrganizationsEventTypesService {
|
||||
prisma: this.dbWrite.prisma,
|
||||
},
|
||||
});
|
||||
this.logger.debug(
|
||||
"nl debug - create org team event type - eventTypeCreated",
|
||||
JSON.stringify(eventTypeCreated, null, 2)
|
||||
);
|
||||
|
||||
return this.teamsEventTypesService.updateTeamEventType(eventTypeCreated.id, teamId, body, user, true);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ type Input = Pick<
|
||||
| "hideOrganizerEmail"
|
||||
| "team"
|
||||
| "calVideoSettings"
|
||||
| "hidden"
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
|
||||
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { SchedulesModule_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.module";
|
||||
import { InputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/input-schedules.service";
|
||||
import { SchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/schedules.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { ConferencingRepository } from "@/modules/conferencing/repositories/conferencing.repository";
|
||||
import { ConferencingService } from "@/modules/conferencing/services/conferencing.service";
|
||||
@@ -18,13 +22,10 @@ import { OrganizationAttributesService } from "@/modules/organizations/attribute
|
||||
import { OrganizationAttributeOptionRepository } from "@/modules/organizations/attributes/options/organization-attribute-options.repository";
|
||||
import { OrganizationsAttributesOptionsController } from "@/modules/organizations/attributes/options/organizations-attributes-options.controller";
|
||||
import { OrganizationAttributeOptionService } from "@/modules/organizations/attributes/options/services/organization-attributes-option.service";
|
||||
import { OrganizationsConferencingController } from "@/modules/organizations/conferencing/organizations-conferencing.controller";
|
||||
import { OrganizationsConferencingModule } from "@/modules/organizations/conferencing/organizations-conferencing.module";
|
||||
import { OrganizationsConferencingService } from "@/modules/organizations/conferencing/services/organizations-conferencing.service";
|
||||
import { OrganizationsDelegationCredentialModule } from "@/modules/organizations/delegation-credentials/organizations-delegation-credential.module";
|
||||
import { OrganizationsEventTypesController } from "@/modules/organizations/event-types/organizations-event-types.controller";
|
||||
import { EventTypesPrivateLinksModule } from "@/ee/event-types-private-links/event-types-private-links.module";
|
||||
import { OrganizationsEventTypesPrivateLinksController } from "@/ee/event-types-private-links/controllers/organizations-event-types-private-links.controller";
|
||||
import { OrganizationsEventTypesRepository } from "@/modules/organizations/event-types/organizations-event-types.repository";
|
||||
import { OutputTeamEventTypesResponsePipe } from "@/modules/organizations/event-types/pipes/team-event-types-response.transformer";
|
||||
import { InputOrganizationsEventTypesService } from "@/modules/organizations/event-types/services/input.service";
|
||||
@@ -38,7 +39,6 @@ import { OrganizationsMembershipOutputService } from "@/modules/organizations/me
|
||||
import { OrganizationsMembershipService } from "@/modules/organizations/memberships/services/organizations-membership.service";
|
||||
import { OrganizationsOrganizationsModule } from "@/modules/organizations/organizations/organizations-organizations.module";
|
||||
import { OrganizationsSchedulesController } from "@/modules/organizations/schedules/organizations-schedules.controller";
|
||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
|
||||
import { OrganizationsSchedulesService } from "@/modules/organizations/schedules/services/organizations-schedules.service";
|
||||
import { OrganizationsStripeModule } from "@/modules/organizations/stripe/organizations-stripe.module";
|
||||
import { OrganizationsStripeService } from "@/modules/organizations/stripe/services/organizations-stripe.service";
|
||||
@@ -65,6 +65,7 @@ import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { RedisService } from "@/modules/redis/redis.service";
|
||||
import { StripeModule } from "@/modules/stripe/stripe.module";
|
||||
import { TeamsEventTypesModule } from "@/modules/teams/event-types/teams-event-types.module";
|
||||
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
|
||||
import { TeamsModule } from "@/modules/teams/teams/teams.module";
|
||||
import { TokensRepository } from "@/modules/tokens/tokens.repository";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
@@ -102,7 +103,6 @@ import { Module } from "@nestjs/common";
|
||||
OrganizationsTeamsService,
|
||||
MembershipsRepository,
|
||||
OrganizationsSchedulesService,
|
||||
OrganizationSchedulesRepository,
|
||||
OrganizationsUsersRepository,
|
||||
OrganizationsUsersService,
|
||||
EmailService,
|
||||
@@ -144,6 +144,9 @@ import { Module } from "@nestjs/common";
|
||||
TeamWorkflowsService,
|
||||
WorkflowsInputService,
|
||||
WorkflowsOutputService,
|
||||
TeamsSchedulesService,
|
||||
SchedulesService_2024_06_11,
|
||||
InputSchedulesService_2024_06_11,
|
||||
],
|
||||
exports: [
|
||||
OrganizationsService,
|
||||
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
|
||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
@@ -8,8 +8,8 @@ import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
@Injectable()
|
||||
export class OrganizationsSchedulesService {
|
||||
constructor(
|
||||
private readonly organizationSchedulesService: OrganizationSchedulesRepository,
|
||||
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11,
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
|
||||
private readonly usersRepository: UsersRepository
|
||||
) {}
|
||||
|
||||
@@ -17,7 +17,7 @@ export class OrganizationsSchedulesService {
|
||||
const users = await this.usersRepository.getOrganizationUsers(organizationId);
|
||||
const usersIds = users.map((user) => user.id);
|
||||
|
||||
const schedules = await this.organizationSchedulesService.getSchedulesByUserIds(usersIds, skip, take);
|
||||
const schedules = await this.schedulesRepository.getSchedulesByUserIds(usersIds, skip, take);
|
||||
|
||||
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
|
||||
|
||||
|
||||
+28
-3
@@ -14,11 +14,12 @@ import { IsOrgGuard } from "@/modules/auth/guards/organizations/is-org.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { IsTeamInOrg } from "@/modules/auth/guards/teams/is-team-in-org.guard";
|
||||
import { IsUserInOrgTeam } from "@/modules/auth/guards/users/is-user-in-org-team.guard";
|
||||
import { Controller, UseGuards, Get, Param, ParseIntPipe } from "@nestjs/common";
|
||||
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
|
||||
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/organizations/:orgId/teams/:teamId",
|
||||
@@ -29,7 +30,31 @@ import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
|
||||
@ApiHeader(OPTIONAL_API_KEY_HEADER)
|
||||
export class OrganizationsTeamsSchedulesController {
|
||||
constructor(private schedulesService: SchedulesService_2024_06_11) {}
|
||||
constructor(
|
||||
private schedulesService: SchedulesService_2024_06_11,
|
||||
|
||||
private teamsSchedulesService: TeamsSchedulesService
|
||||
) {}
|
||||
|
||||
@UseGuards(IsTeamInOrg)
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
@Get("/schedules")
|
||||
@DocsTags("Orgs / Teams / Schedules")
|
||||
@ApiOperation({ summary: "Get all team member schedules" })
|
||||
async getTeamSchedules(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const { skip, take } = queryParams;
|
||||
|
||||
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedules,
|
||||
};
|
||||
}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@PlatformPlan("ESSENTIALS")
|
||||
|
||||
+39
@@ -18,6 +18,7 @@ import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { ApiSuccessResponse, ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { Team, Schedule } from "@calcom/prisma/client";
|
||||
|
||||
@@ -36,6 +37,8 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
let org: Team;
|
||||
let orgTeam: Team;
|
||||
let nonOrgTeam: Team;
|
||||
|
||||
let userSchedule: Schedule;
|
||||
let user2Schedule: Schedule;
|
||||
|
||||
const userEmail = `organizations-teams-schedules-admin-${randomString()}@api.com`;
|
||||
@@ -73,6 +76,16 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
username: userEmail2,
|
||||
});
|
||||
|
||||
userSchedule = await scheduleRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
name: `organizations-teams-schedules-user-schedule-${randomString()}`,
|
||||
timeZone: "America/New_York",
|
||||
});
|
||||
|
||||
user2Schedule = await scheduleRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
@@ -214,6 +227,32 @@ describe("Organizations Teams Schedules Endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should get all the schedules of members in a team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/organizations/${org.id}/teams/${orgTeam.id}/schedules`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
|
||||
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
|
||||
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
|
||||
|
||||
expect(userOneSchedule).toBeDefined();
|
||||
expect(userTwoSchedule).toBeDefined();
|
||||
|
||||
expect(userOneSchedule?.id).toEqual(userSchedule.id);
|
||||
expect(userOneSchedule?.name).toEqual(userSchedule.name);
|
||||
expect(userOneSchedule?.timeZone).toEqual(userSchedule.timeZone);
|
||||
|
||||
expect(userTwoSchedule?.id).toEqual(user2Schedule.id);
|
||||
expect(userTwoSchedule?.name).toEqual(user2Schedule.name);
|
||||
expect(userOneSchedule?.timeZone).toEqual(user2Schedule.timeZone);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(userToInviteViaApi.email);
|
||||
|
||||
@@ -133,6 +133,7 @@ export class TeamsEventTypesService {
|
||||
});
|
||||
|
||||
const eventType = await this.teamsEventTypesRepository.getEventTypeById(eventTypeId);
|
||||
this.logger.debug("nl debug - update team event type - eventType", JSON.stringify(eventType, null, 2));
|
||||
|
||||
if (!eventType) {
|
||||
throw new NotFoundException(`Event type with id ${eventTypeId} not found`);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { RolesGuard } from "@/modules/auth/guards/roles/roles.guard";
|
||||
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
|
||||
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query } from "@nestjs/common";
|
||||
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetSchedulesOutput_2024_06_11, SkipTakePagination } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/teams/:teamId/schedules",
|
||||
version: API_VERSIONS_VALUES,
|
||||
})
|
||||
@UseGuards(ApiAuthGuard, RolesGuard)
|
||||
@DocsTags("Teams / Schedules")
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
export class TeamsSchedulesController {
|
||||
constructor(private teamsSchedulesService: TeamsSchedulesService) {}
|
||||
|
||||
@Roles("TEAM_ADMIN")
|
||||
@Get("/")
|
||||
@ApiOperation({
|
||||
summary: "Get all team member schedules",
|
||||
})
|
||||
async getTeamSchedules(
|
||||
@Param("teamId", ParseIntPipe) teamId: number,
|
||||
@Query() queryParams: SkipTakePagination
|
||||
): Promise<GetSchedulesOutput_2024_06_11> {
|
||||
const { skip, take } = queryParams;
|
||||
|
||||
const schedules = await this.teamsSchedulesService.getTeamSchedules(teamId, skip, take);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: schedules,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { TokensModule } from "@/modules/tokens/tokens.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import * as request from "supertest";
|
||||
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
|
||||
import { SchedulesRepositoryFixture } from "test/fixtures/repository/schedules.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { GetSchedulesOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
import { Team, Schedule } from "@calcom/prisma/client";
|
||||
|
||||
describe("Teams Schedules Endpoints", () => {
|
||||
describe("User Authentication - User is Team Admin", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
|
||||
let teamsRepositoryFixture: TeamRepositoryFixture;
|
||||
let scheduleRepositoryFixture: SchedulesRepositoryFixture;
|
||||
|
||||
let membershipsRepositoryFixture: MembershipRepositoryFixture;
|
||||
|
||||
let nonOrgTeam: Team;
|
||||
|
||||
let userSchedule: Schedule;
|
||||
let user2Schedule: Schedule;
|
||||
|
||||
const userEmail = `teams-schedules-admin-${randomString()}@api.com`;
|
||||
const userEmail2 = `teams-schedules-member-${randomString()}@api.com`;
|
||||
|
||||
let user: User;
|
||||
let user2: User;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await withApiAuth(
|
||||
userEmail,
|
||||
Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
|
||||
})
|
||||
).compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
teamsRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
|
||||
scheduleRepositoryFixture = new SchedulesRepositoryFixture(moduleRef);
|
||||
membershipsRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
|
||||
|
||||
user = await userRepositoryFixture.create({
|
||||
email: userEmail,
|
||||
username: userEmail,
|
||||
});
|
||||
user2 = await userRepositoryFixture.create({
|
||||
email: userEmail2,
|
||||
username: userEmail2,
|
||||
});
|
||||
|
||||
userSchedule = await scheduleRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user.id,
|
||||
},
|
||||
},
|
||||
name: `teams-schedules-user-schedule-${randomString()}`,
|
||||
timeZone: "America/New_York",
|
||||
});
|
||||
|
||||
user2Schedule = await scheduleRepositoryFixture.create({
|
||||
user: {
|
||||
connect: {
|
||||
id: user2.id,
|
||||
},
|
||||
},
|
||||
name: `teams-schedules-user2-schedule-${randomString()}`,
|
||||
timeZone: "America/New_York",
|
||||
});
|
||||
|
||||
nonOrgTeam = await teamsRepositoryFixture.create({
|
||||
name: `teams-schedules-non-org-team-${randomString()}`,
|
||||
isOrganization: false,
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "ADMIN",
|
||||
user: { connect: { id: user.id } },
|
||||
team: { connect: { id: nonOrgTeam.id } },
|
||||
});
|
||||
|
||||
await membershipsRepositoryFixture.create({
|
||||
role: "MEMBER",
|
||||
user: { connect: { id: user2.id } },
|
||||
team: { connect: { id: nonOrgTeam.id } },
|
||||
});
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
it("should get all the schedules of members in a team", async () => {
|
||||
return request(app.getHttpServer())
|
||||
.get(`/v2/teams/${nonOrgTeam.id}/schedules`)
|
||||
.expect(200)
|
||||
.then((response) => {
|
||||
const responseBody: GetSchedulesOutput_2024_06_11 = response.body;
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
expect(Array.isArray(responseBody.data)).toBe(true);
|
||||
expect(responseBody.data.length).toEqual(2);
|
||||
|
||||
const userOneSchedule = responseBody.data.find((schedule) => schedule.id === userSchedule.id);
|
||||
const userTwoSchedule = responseBody.data.find((schedule) => schedule.id === user2Schedule.id);
|
||||
|
||||
expect(userOneSchedule).toBeDefined();
|
||||
expect(userTwoSchedule).toBeDefined();
|
||||
|
||||
expect(userOneSchedule?.id).toEqual(userSchedule.id);
|
||||
expect(userOneSchedule?.name).toEqual(userSchedule.name);
|
||||
expect(userOneSchedule?.timeZone).toEqual(userSchedule.timeZone);
|
||||
|
||||
expect(userTwoSchedule?.id).toEqual(user2Schedule.id);
|
||||
expect(userTwoSchedule?.name).toEqual(user2Schedule.name);
|
||||
expect(userOneSchedule?.timeZone).toEqual(user2Schedule.timeZone);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(user.email);
|
||||
await userRepositoryFixture.deleteByEmail(user2.email);
|
||||
await teamsRepositoryFixture.delete(nonOrgTeam.id);
|
||||
await app.close();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
|
||||
import { ScheduleOutput_2024_06_11 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsSchedulesService {
|
||||
constructor(
|
||||
private readonly teamsRepository: TeamsRepository,
|
||||
private readonly schedulesRepository: SchedulesRepository_2024_06_11,
|
||||
private readonly outputSchedulesService: OutputSchedulesService_2024_06_11
|
||||
) {}
|
||||
|
||||
async getTeamSchedules(teamId: number, skip = 0, take = 250) {
|
||||
const userIds = await this.teamsRepository.getTeamUsersIds(teamId);
|
||||
const schedules = await this.schedulesRepository.getSchedulesByUserIds(userIds, skip, take);
|
||||
|
||||
const responseSchedules: ScheduleOutput_2024_06_11[] = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
responseSchedules.push(await this.outputSchedulesService.getResponseSchedule(schedule));
|
||||
}
|
||||
|
||||
return responseSchedules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { SchedulesRepository_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/schedules.repository";
|
||||
import { OutputSchedulesService_2024_06_11 } from "@/ee/schedules/schedules_2024_06_11/services/output-schedules.service";
|
||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
|
||||
import { OrganizationSchedulesRepository } from "@/modules/organizations/schedules/organizations-schedules.repository";
|
||||
import { OrganizationsTeamsRepository } from "@/modules/organizations/teams/index/organizations-teams.repository";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { RedisModule } from "@/modules/redis/redis.module";
|
||||
import { StripeService } from "@/modules/stripe/stripe.service";
|
||||
import { TeamsMembershipsService } from "@/modules/teams/memberships/services/teams-memberships.service";
|
||||
import { TeamsMembershipsRepository } from "@/modules/teams/memberships/teams-memberships.repository";
|
||||
import { TeamsSchedulesController } from "@/modules/teams/schedules/controllers/teams-schedules.controller";
|
||||
import { TeamsSchedulesService } from "@/modules/teams/schedules/services/teams-schedules.service";
|
||||
import { TeamsController } from "@/modules/teams/teams/controllers/teams.controller";
|
||||
import { TeamsService } from "@/modules/teams/teams/services/teams.service";
|
||||
import { TeamsRepository } from "@/modules/teams/teams/teams.repository";
|
||||
import { UsersRepository } from "@/modules/users/users.repository";
|
||||
import { Module } from "@nestjs/common";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, MembershipsModule, RedisModule],
|
||||
providers: [
|
||||
TeamsRepository,
|
||||
TeamsService,
|
||||
TeamsMembershipsRepository,
|
||||
TeamsMembershipsService,
|
||||
OutputSchedulesService_2024_06_11,
|
||||
OrganizationSchedulesRepository,
|
||||
StripeService,
|
||||
UsersRepository,
|
||||
AppsRepository,
|
||||
CredentialsRepository,
|
||||
OrganizationsRepository,
|
||||
TeamsSchedulesService,
|
||||
OrganizationsTeamsRepository,
|
||||
SchedulesRepository_2024_06_11,
|
||||
],
|
||||
controllers: [TeamsController, TeamsSchedulesController],
|
||||
exports: [TeamsRepository],
|
||||
})
|
||||
export class TeamsSchedulesModule {}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CustomI18nProvider } from "app/CustomI18nProvider";
|
||||
import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps, SearchParams } from "app/_types";
|
||||
import type { PageProps, Params, SearchParams } from "app/_types";
|
||||
import { generateMeetingMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
|
||||
@@ -15,20 +15,34 @@ import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
import LegacyPage from "~/team/type-view";
|
||||
import type { PageProps as LegacyPageProps } from "~/team/type-view";
|
||||
|
||||
import CachedTeamBooker, { generateMetadata as generateCachedMetadata } from "./pageWithCachedData";
|
||||
import CachedTeamBooker, {
|
||||
generateMetadata as generateCachedMetadata,
|
||||
getOrgContext,
|
||||
} from "./pageWithCachedData";
|
||||
import { getTeamId } from "./queries";
|
||||
|
||||
async function isCachedTeamBookingEnabled(params: Params, searchParams: SearchParams): Promise<boolean> {
|
||||
if (searchParams.experimentalTeamBookingPageCache !== "true") return false;
|
||||
|
||||
const { teamSlug, currentOrgDomain, isValidOrgDomain } = await getOrgContext(params);
|
||||
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
|
||||
const teamId = await getTeamId(teamSlug, orgSlug);
|
||||
|
||||
if (!teamId) return false;
|
||||
|
||||
async function isCachedTeamBookingEnabled(searchParams: SearchParams): Promise<boolean> {
|
||||
const featuresRepository = new FeaturesRepository(prisma);
|
||||
const isGloballyEnabled = await featuresRepository.checkIfFeatureIsEnabledGlobally(
|
||||
const isTeamFeatureEnabled = await featuresRepository.checkIfTeamHasFeature(
|
||||
teamId,
|
||||
"team-booking-page-cache"
|
||||
);
|
||||
return isGloballyEnabled && searchParams.experimentalTeamBookingPageCache === "true";
|
||||
return isTeamFeatureEnabled;
|
||||
}
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
if (await isCachedTeamBookingEnabled(await searchParams)) {
|
||||
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
|
||||
return await generateCachedMetadata({ params, searchParams });
|
||||
}
|
||||
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
const props = await getData(legacyCtx);
|
||||
const { booking, isSEOIndexable, eventData, isBrandingHidden } = props;
|
||||
@@ -68,7 +82,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const getData = withAppDirSsr<LegacyPageProps>(getServerSideProps);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: PageProps) => {
|
||||
if (await isCachedTeamBookingEnabled(await searchParams)) {
|
||||
if (await isCachedTeamBookingEnabled(await params, await searchParams)) {
|
||||
return await CachedTeamBooker({ params, searchParams });
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ const _getTeamMetadataForBooking = (teamData: NonNullable<TeamData>, eventTypeId
|
||||
};
|
||||
};
|
||||
|
||||
async function _getOrgContext(params: Params) {
|
||||
export async function getOrgContext(params: Params) {
|
||||
const result = paramsSchema.safeParse({
|
||||
slug: params?.slug,
|
||||
type: params?.type,
|
||||
@@ -74,7 +74,7 @@ const _getMultipleDurationValue = (
|
||||
};
|
||||
|
||||
export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
|
||||
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
|
||||
|
||||
const teamData = await getCachedTeamData(teamSlug, currentOrgDomain);
|
||||
if (!teamData) return {}; // should never happen
|
||||
@@ -123,7 +123,7 @@ export const generateMetadata = async ({ params, searchParams }: PageProps) => {
|
||||
};
|
||||
|
||||
const CachedTeamBooker = async ({ params, searchParams }: PageProps) => {
|
||||
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await _getOrgContext(await params);
|
||||
const { currentOrgDomain, isValidOrgDomain, teamSlug, meetingSlug } = await getOrgContext(await params);
|
||||
const legacyCtx = buildLegacyCtx(await headers(), await cookies(), await params, await searchParams);
|
||||
|
||||
// Handle org redirects
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getTeamEventType } from "@calcom/features/eventtypes/lib/getTeamEventTy
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { NEXTJS_CACHE_TTL } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import type { SchedulingType } from "@calcom/prisma/enums";
|
||||
@@ -158,3 +159,14 @@ export async function getCRMData(
|
||||
crmRecordId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTeamId(teamSlug: string, orgSlug: string | null): Promise<number | null> {
|
||||
const teamRepo = new TeamRepository(prisma);
|
||||
const team = await teamRepo.findFirstBySlugAndParentSlug({
|
||||
slug: teamSlug,
|
||||
parentSlug: orgSlug,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return team?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { ShellMainAppDir } from "app/(use-page-wrapper)/(main-nav)/ShellMainAppDir";
|
||||
import type { PageProps } from "app/_types";
|
||||
import { _generateMetadata, getTranslate } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { validStatuses } from "~/bookings/lib/validStatuses";
|
||||
import BookingsList from "~/bookings/views/bookings-listing-view";
|
||||
|
||||
@@ -26,10 +31,11 @@ const Page = async ({ params }: PageProps) => {
|
||||
redirect("/bookings/upcoming");
|
||||
}
|
||||
const t = await getTranslate();
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
return (
|
||||
<ShellMainAppDir heading={t("bookings")} subtitle={t("bookings_description")}>
|
||||
<BookingsList status={parsed.data.status} />
|
||||
<BookingsList status={parsed.data.status} userId={session?.user?.id} />
|
||||
</ShellMainAppDir>
|
||||
);
|
||||
};
|
||||
|
||||
+4
-1
@@ -5,6 +5,7 @@ import { unstable_cache } from "next/cache";
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
|
||||
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { viewerTeamsRouter } from "@calcom/trpc/server/routers/viewer/teams/_router";
|
||||
|
||||
import { TeamMembersView } from "~/teams/team-members-view";
|
||||
@@ -36,8 +37,10 @@ const getCachedTeamRoles = unstable_cache(
|
||||
const getCachedTeamAttributes = unstable_cache(
|
||||
async (organizationId?: number) => {
|
||||
if (!organizationId) return [];
|
||||
const attributeRepo = new PrismaAttributeRepository(prisma);
|
||||
|
||||
try {
|
||||
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId: organizationId });
|
||||
return await attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId });
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
+4
-1
@@ -4,6 +4,7 @@ import { unstable_cache } from "next/cache";
|
||||
|
||||
import { RoleManagementFactory } from "@calcom/features/pbac/services/role-management.factory";
|
||||
import { PrismaAttributeRepository } from "@calcom/lib/server/repository/PrismaAttributeRepository";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { viewerOrganizationsRouter } from "@calcom/trpc/server/routers/viewer/organizations/_router";
|
||||
|
||||
import { MembersView } from "~/members/members-view";
|
||||
@@ -19,7 +20,9 @@ export const generateMetadata = async () =>
|
||||
|
||||
const getCachedAttributes = unstable_cache(
|
||||
async (orgId: number) => {
|
||||
return await PrismaAttributeRepository.findAllByOrgIdWithOptions({ orgId });
|
||||
const attributeRepo = new PrismaAttributeRepository(prisma);
|
||||
|
||||
return await attributeRepo.findAllByOrgIdWithOptions({ orgId });
|
||||
},
|
||||
undefined,
|
||||
{ revalidate: 3600, tags: ["viewer.attributes.list"] } // Cache for 1 hour
|
||||
|
||||
@@ -87,7 +87,10 @@ async function handler(req: NextRequest) {
|
||||
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
@@ -116,7 +119,13 @@ async function handler(req: NextRequest) {
|
||||
});
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
@@ -144,7 +153,13 @@ async function handler(req: NextRequest) {
|
||||
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "image/png",
|
||||
"Cache-Control": "public, max-age=3600, stale-while-revalidate=86400",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
|
||||
@@ -2,9 +2,11 @@ import { useMemo } from "react";
|
||||
import { useFormContext } from "react-hook-form";
|
||||
import type { UseFormGetValues, UseFormSetValue, Control, FormState } from "react-hook-form";
|
||||
|
||||
import type { TLocationOptions } from "@calcom/features/eventtypes/components/Locations";
|
||||
import type { TEventTypeLocation } from "@calcom/features/eventtypes/components/Locations";
|
||||
import Locations from "@calcom/features/eventtypes/components/Locations";
|
||||
import type {
|
||||
TLocationOptions,
|
||||
TEventTypeLocation,
|
||||
} from "@calcom/features/eventtypes/components/locations/Locations";
|
||||
import Locations from "@calcom/features/eventtypes/components/locations/Locations";
|
||||
import type { LocationFormValues } from "@calcom/features/eventtypes/lib/types";
|
||||
import type { SingleValueLocationOption } from "@calcom/features/form/components/LocationSelect";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
@@ -910,7 +910,7 @@ export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: Rerout
|
||||
|
||||
return (
|
||||
<Dialog open={isOpenDialog} onOpenChange={setIsOpenDialog}>
|
||||
<DialogContent enableOverflow preventCloseOnOutsideClick>
|
||||
<DialogContent preventCloseOnOutsideClick>
|
||||
<DialogHeader title={t("reroute_booking")} subtitle={t("reroute_booking_description")} />
|
||||
<RerouteDialogContentAndFooter
|
||||
booking={teamEventTypeBooking}
|
||||
|
||||
@@ -3,42 +3,48 @@
|
||||
// https://docs.sentry.io/platforms/javascript/guides/nextjs/
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
Sentry.init({
|
||||
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN_CLIENT,
|
||||
|
||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
sampleRate: parseFloat(process.env.SENTRY_SAMPLE_RATE ?? "1.0") || 1.0,
|
||||
// Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
|
||||
tracesSampleRate: parseFloat(process.env.SENTRY_TRACES_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
// Define how likely Replay events are sampled.
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
// Define how likely Replay events are sampled.
|
||||
// This sets the sample rate to be 10%. You may want this to be 100% while
|
||||
// in development and sample at a lower rate in production
|
||||
// replaysSessionSampleRate: parseFloat(process.env.SENTRY_REPLAYS_SESSION_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
// Define how likely Replay events are sampled when an error occurs.
|
||||
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
// Define how likely Replay events are sampled when an error occurs.
|
||||
// replaysOnErrorSampleRate: parseFloat(process.env.SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE ?? "0.0") || 0.0,
|
||||
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: !!process.env.SENTRY_DEBUG,
|
||||
beforeSend(event) {
|
||||
if (
|
||||
event.exception?.values?.some(
|
||||
(e) =>
|
||||
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
|
||||
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
|
||||
e.value?.includes("Non-Error promise rejection captured with") ||
|
||||
e.value?.includes("Object Not Found Matching Id")
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Setting this option to true will print useful information to the console while you're setting up Sentry.
|
||||
debug: !!process.env.SENTRY_DEBUG,
|
||||
beforeSend(event) {
|
||||
if (
|
||||
event.exception?.values?.some(
|
||||
(e) =>
|
||||
// Ignore fake error "UnhandledRejection: Non-Error promise rejection captured with value: Object Not Found Matching Id:3, MethodName:update, ParamCount:4"
|
||||
// Raised GH issue: https://github.com/getsentry/sentry-javascript/issues/3440
|
||||
e.value?.includes("Non-Error promise rejection captured with") ||
|
||||
e.value?.includes("Object Not Found Matching Id")
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
errorSource: "client",
|
||||
};
|
||||
return event;
|
||||
},
|
||||
});
|
||||
event.tags = {
|
||||
...event.tags,
|
||||
errorSource: "client",
|
||||
};
|
||||
return event;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
|
||||
export function onRouterTransitionStart(url: string, navigationType: "push" | "replace" | "traverse") {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
Sentry.captureRouterTransitionStart(url, navigationType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import { type Instrumentation } from 'next'
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
export async function register() {
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config");
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "nodejs") {
|
||||
await import("./sentry.server.config");
|
||||
}
|
||||
if (process.env.NEXT_PUBLIC_SENTRY_DSN && process.env.NEXT_RUNTIME === "edge") {
|
||||
await import("./sentry.edge.config");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const onRequestError = Sentry.captureRequestError;
|
||||
export const onRequestError: Instrumentation.onRequestError = ( err,
|
||||
request,
|
||||
context) => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
Sentry.captureRequestError(err, request, context);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
darkBrandColor: true,
|
||||
email: true,
|
||||
timeZone: true,
|
||||
isPlatformManaged: true,
|
||||
};
|
||||
const eventType = await prisma.eventType.findUnique({
|
||||
where: {
|
||||
@@ -75,6 +76,7 @@ export const getEventTypesFromDB = async (id: number) => {
|
||||
hideBranding: true,
|
||||
},
|
||||
},
|
||||
createdByOAuthClientId: true,
|
||||
},
|
||||
},
|
||||
workflows: {
|
||||
|
||||
@@ -60,22 +60,6 @@ const middleware = async (req: NextRequest): Promise<NextResponse<unknown>> => {
|
||||
const reqWithEnrichedHeaders = enrichRequestWithHeaders({ req });
|
||||
const requestHeaders = new Headers(reqWithEnrichedHeaders.headers);
|
||||
|
||||
if (!url.pathname.startsWith("/api")) {
|
||||
//
|
||||
// NOTE: When tRPC hits an error a 500 is returned, when this is received
|
||||
// by the application the user is automatically redirected to /auth/login.
|
||||
//
|
||||
// - For this reason our matchers are sufficient for an app-wide maintenance page.
|
||||
//
|
||||
// Check whether the maintenance page should be shown
|
||||
const isInMaintenanceMode = await safeGet<boolean>("isInMaintenanceMode");
|
||||
// If is in maintenance mode, point the url pathname to the maintenance page
|
||||
if (isInMaintenanceMode) {
|
||||
reqWithEnrichedHeaders.nextUrl.pathname = `/maintenance`;
|
||||
return NextResponse.rewrite(reqWithEnrichedHeaders.nextUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const routingFormRewriteResponse = routingForms.handleRewrite(url);
|
||||
if (routingFormRewriteResponse) {
|
||||
return responseWithHeaders({ url, res: routingFormRewriteResponse, req: reqWithEnrichedHeaders });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useSearchParams, usePathname } from "next/navigation";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ZMultiSelectFilterValue,
|
||||
ZDateRangeFilterValue,
|
||||
ZTextFilterValue,
|
||||
type SystemFilterSegment,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -55,11 +56,45 @@ const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
|
||||
type BookingsProps = {
|
||||
status: (typeof validStatuses)[number];
|
||||
userId?: number;
|
||||
};
|
||||
|
||||
function useSystemSegments(userId?: number) {
|
||||
const { t } = useLocale();
|
||||
|
||||
const systemSegments: SystemFilterSegment[] = useMemo(() => {
|
||||
if (!userId) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
id: "my_bookings",
|
||||
name: t("my_bookings"),
|
||||
type: "system",
|
||||
activeFilters: [
|
||||
{
|
||||
f: "userId",
|
||||
v: {
|
||||
type: ColumnFilterType.MULTI_SELECT,
|
||||
data: [userId],
|
||||
},
|
||||
},
|
||||
],
|
||||
perPage: 10,
|
||||
},
|
||||
];
|
||||
}, [userId, t]);
|
||||
|
||||
return systemSegments;
|
||||
}
|
||||
|
||||
export default function Bookings(props: BookingsProps) {
|
||||
const pathname = usePathname();
|
||||
const systemSegments = useSystemSegments(props.userId);
|
||||
return (
|
||||
<DataTableProvider useSegments={useSegments}>
|
||||
<DataTableProvider
|
||||
useSegments={useSegments}
|
||||
systemSegments={systemSegments}
|
||||
tableIdentifier={pathname || undefined}>
|
||||
<BookingsContent {...props} />
|
||||
</DataTableProvider>
|
||||
);
|
||||
@@ -180,7 +215,7 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
columnHelper.accessor((row) => row.type === "data" && row.booking.user?.id, {
|
||||
id: "userId",
|
||||
header: t("member"),
|
||||
enableColumnFilter: true,
|
||||
enableColumnFilter: user?.isTeamAdminOrOwner ?? false,
|
||||
enableSorting: false,
|
||||
cell: () => null,
|
||||
meta: {
|
||||
|
||||
@@ -228,16 +228,20 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
? { ...previousBooking, rescheduledBy: bookingInfo.user?.name }
|
||||
: previousBooking;
|
||||
|
||||
const isPlatformBooking = eventType.users[0]?.isPlatformManaged || eventType.team?.createdByOAuthClientId;
|
||||
|
||||
return {
|
||||
props: {
|
||||
orgSlug: currentOrgDomain,
|
||||
themeBasis: eventType.team ? eventType.team.slug : eventType.users[0]?.username,
|
||||
hideBranding: await shouldHideBrandingForEvent({
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
hideBranding: isPlatformBooking
|
||||
? true
|
||||
: await shouldHideBrandingForEvent({
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
profile,
|
||||
eventType,
|
||||
recurringBookings: await getRecurringBookings(bookingInfo.recurringEventId),
|
||||
|
||||
@@ -138,7 +138,8 @@ const OnboardingPage = (props: PageProps) => {
|
||||
"dark:bg-brand dark:text-brand-contrast text-emphasis min-h-screen [--cal-brand:#111827] dark:[--cal-brand:#FFFFFF]",
|
||||
"[--cal-brand-emphasis:#101010] dark:[--cal-brand-emphasis:#e1e1e1]",
|
||||
"[--cal-brand-subtle:#9CA3AF]",
|
||||
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]"
|
||||
"[--cal-brand-text:#FFFFFF] dark:[--cal-brand-text:#000000]",
|
||||
"[--cal-brand-accent:#FFFFFF] dark:[--cal-brand-accent:#000000]"
|
||||
)}
|
||||
data-testid="onboarding"
|
||||
key={pathname}>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ColumnFilterType,
|
||||
type FilterableColumn,
|
||||
} from "@calcom/features/data-table";
|
||||
import { useSegments } from "@calcom/features/data-table/hooks/useSegments";
|
||||
import {
|
||||
AverageEventDurationChart,
|
||||
BookingKPICards,
|
||||
@@ -31,7 +32,7 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export default function InsightsPage({ timeZone }: { timeZone: string }) {
|
||||
return (
|
||||
<DataTableProvider timeZone={timeZone}>
|
||||
<DataTableProvider useSegments={useSegments} timeZone={timeZone}>
|
||||
<InsightsOrgTeamsProvider>
|
||||
<InsightsPageContent />
|
||||
</InsightsOrgTeamsProvider>
|
||||
|
||||
+490
-478
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.6.1",
|
||||
"version": "5.6.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -29,7 +29,7 @@
|
||||
"yarn": "3.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boxyhq/saml-jackson": "1.42.0",
|
||||
"@boxyhq/saml-jackson": "1.52.2",
|
||||
"@calcom/app-store": "*",
|
||||
"@calcom/app-store-cli": "*",
|
||||
"@calcom/dayjs": "*",
|
||||
@@ -77,7 +77,6 @@
|
||||
"@tanstack/react-query": "^5.17.15",
|
||||
"@team-plain/typescript-sdk": "^5.9.0",
|
||||
"@types/turndown": "^5.0.1",
|
||||
"@unkey/ratelimit": "^0.1.1",
|
||||
"@upstash/redis": "^1.35.2",
|
||||
"@vercel/edge-config": "^0.1.1",
|
||||
"@vercel/edge-functions-ui": "^0.2.1",
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { wrapApiHandlerWithSentry } from "@sentry/nextjs";
|
||||
|
||||
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
|
||||
import { highPerfRouter } from "@calcom/trpc/server/routers/viewer/highPerf/_router";
|
||||
|
||||
export default wrapApiHandlerWithSentry(createNextApiHandler(highPerfRouter), "/api/trpc/highPerf/[trpc]");
|
||||
@@ -479,7 +479,6 @@ test.describe("Bookings", () => {
|
||||
const filterItemsConfig = [
|
||||
{ key: "eventTypeId", name: "Event Type", testId: "add-filter-item-eventTypeId" },
|
||||
{ key: "teamId", name: "Team", testId: "add-filter-item-teamId" },
|
||||
{ key: "userId", name: "Member", testId: "add-filter-item-userId" },
|
||||
{ key: "attendeeName", name: "Attendees Name", testId: "add-filter-item-attendeeName" },
|
||||
{ key: "attendeeEmail", name: "Attendee Email", testId: "add-filter-item-attendeeEmail" },
|
||||
{ key: "dateRange", name: "Date Range", testId: "add-filter-item-dateRange" },
|
||||
@@ -488,8 +487,16 @@ test.describe("Bookings", () => {
|
||||
|
||||
const getFilterItemLocator = (page: Page, testId: string) => page.locator(`[data-testid="${testId}"]`);
|
||||
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
const setup = async ({
|
||||
isAdmin,
|
||||
page,
|
||||
users,
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
page: Page;
|
||||
users: Fixtures["users"];
|
||||
}) => {
|
||||
const user = isAdmin ? await users.create(undefined, { hasTeam: true }) : await users.create();
|
||||
await user.apiLogin();
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
@@ -498,9 +505,10 @@ test.describe("Bookings", () => {
|
||||
await bookingsGetResponse;
|
||||
await page.locator('[data-testid="add-filter-button"]').click();
|
||||
await expect(page.locator(searchInputSelector)).toBeVisible();
|
||||
});
|
||||
};
|
||||
|
||||
test("should show all filter items initially and after clearing search", async ({ page }) => {
|
||||
test("should show all filter items initially and after clearing search", async ({ page, users }) => {
|
||||
await setup({ isAdmin: false, page, users });
|
||||
const searchInput = page.locator(searchInputSelector);
|
||||
|
||||
// Initial check: all defined filter items should be visible
|
||||
@@ -524,7 +532,18 @@ test.describe("Bookings", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("search should be case-insensitive", async ({ page }) => {
|
||||
test("should show admin-only filter", async ({ page, users }) => {
|
||||
await setup({ isAdmin: true, page, users });
|
||||
|
||||
await expect(
|
||||
getFilterItemLocator(page, "add-filter-item-userId"),
|
||||
`Item "Member" should be visible initially`
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test("search should be case-insensitive", async ({ page, users }) => {
|
||||
await setup({ isAdmin: true, page, users });
|
||||
|
||||
const searchInput = page.locator(searchInputSelector);
|
||||
|
||||
// Search for "member" (lowercase)
|
||||
@@ -534,7 +553,8 @@ test.describe("Bookings", () => {
|
||||
await expect(getFilterItemLocator(page, "add-filter-item-teamId")).toBeHidden();
|
||||
});
|
||||
|
||||
test("should individually find each filter item by its full name", async ({ page }) => {
|
||||
test("should individually find each filter item by its full name", async ({ page, users }) => {
|
||||
await setup({ isAdmin: false, page, users });
|
||||
const searchInput = page.locator(searchInputSelector);
|
||||
|
||||
for (const targetItem of filterItemsConfig) {
|
||||
@@ -550,7 +570,8 @@ test.describe("Bookings", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should show no items for a non-matching search term", async ({ page }) => {
|
||||
test("should show no items for a non-matching search term", async ({ page, users }) => {
|
||||
await setup({ isAdmin: false, page, users });
|
||||
const searchInput = page.locator(searchInputSelector);
|
||||
const nonExistentTerm = "NonExistentFilterXYZ123";
|
||||
await searchInput.fill(nonExistentTerm);
|
||||
|
||||
@@ -31,6 +31,7 @@ export async function applySelectFilter(page: Page, columnId: string, value: str
|
||||
}
|
||||
|
||||
export async function selectOptionValue(page: Page, columnId: string, value: string) {
|
||||
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).waitFor();
|
||||
await page.getByTestId(`select-filter-options-${columnId}`).getByRole("option", { name: value }).click();
|
||||
}
|
||||
|
||||
@@ -176,3 +177,71 @@ export async function listSegments(page: Page): Promise<string[]> {
|
||||
export function locateSelectedSegmentName(page: Page, expectedName: string) {
|
||||
return page.locator('[data-testid="filter-segment-select"]').filter({ hasText: expectedName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a system segment is visible in the dropdown
|
||||
*/
|
||||
export async function expectSystemSegmentVisible(page: Page, segmentName: string) {
|
||||
await page.getByTestId("filter-segment-select").click();
|
||||
await expect(
|
||||
page.locator('[data-testid="filter-segment-select-content"]').getByText("Default")
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
|
||||
.filter({ hasText: segmentName })
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that no segment is currently selected
|
||||
*/
|
||||
export async function expectSegmentCleared(page: Page) {
|
||||
// Check that no segment is selected (button shows default text)
|
||||
const segmentSelect = page.getByTestId("filter-segment-select");
|
||||
const buttonText = await segmentSelect.textContent();
|
||||
expect(buttonText?.trim()).toEqual("Segment");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check that a specific segment is currently selected
|
||||
*/
|
||||
export async function expectSegmentSelected(page: Page, segmentName: string) {
|
||||
await expect(locateSelectedSegmentName(page, segmentName)).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate an existing segment
|
||||
*/
|
||||
export async function duplicateSegment(page: Page, originalName: string, newName: string) {
|
||||
await openSegmentSubmenu(page, originalName);
|
||||
await page.getByTestId("filter-segment-select-submenu-content").getByText("Duplicate").click();
|
||||
await page.getByTestId("duplicate-segment-name").fill(newName);
|
||||
await page.getByRole("button", { name: "Duplicate" }).click();
|
||||
await expect(page.getByText("Filter segment duplicated")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename an existing segment
|
||||
*/
|
||||
export async function renameSegment(page: Page, originalName: string, newName: string) {
|
||||
await openSegmentSubmenu(page, originalName);
|
||||
await page.getByTestId("filter-segment-select-submenu-content").getByText("Rename").click();
|
||||
await page.getByTestId("rename-segment-name").fill(newName);
|
||||
await page.getByRole("button", { name: "Save" }).click();
|
||||
await expect(page.getByText("Filter segment updated")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a segment group (like "Default" or "Personal") is visible
|
||||
*/
|
||||
export async function expectSegmentGroupVisible(page: Page, groupName: string) {
|
||||
await page.getByTestId("filter-segment-select").click();
|
||||
await expect(
|
||||
page.locator('[data-testid="filter-segment-select-content"]').getByText(groupName)
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { expect } from "@playwright/test";
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { addFilter, openFilter, clearFilters } from "./filter-helpers";
|
||||
import { clearFilters, applySelectFilter } from "./filter-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
@@ -202,25 +202,11 @@ test.describe("Insights", async () => {
|
||||
await page.locator('[data-testid="org-teams-filter-item"]').nth(1).click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
// Choose User filter item from dropdown
|
||||
await addFilter(page, "userId");
|
||||
|
||||
// Wait for the URL to include userId
|
||||
await page.waitForURL((url) => url.toString().includes("userId"));
|
||||
|
||||
// Click User filter to see a user list
|
||||
await openFilter(page, "userId");
|
||||
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(0).click();
|
||||
|
||||
await page.locator('[data-testid="select-filter-options-userId"]').getByRole("option").nth(1).click();
|
||||
|
||||
// press escape button to close the filter
|
||||
await page.keyboard.press("Escape");
|
||||
await applySelectFilter(page, "userId", member.username || "");
|
||||
|
||||
await clearFilters(page);
|
||||
|
||||
await expect(page.url()).not.toContain("userId");
|
||||
await expect(page).not.toHaveURL(/[?&]userId=/);
|
||||
});
|
||||
|
||||
test("should test download button", async ({ page, users }) => {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
import { addFilter, selectSegment, expectSegmentCleared, expectSegmentSelected } from "./filter-helpers";
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.afterEach(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
|
||||
/**
|
||||
* Navigate to bookings page with specific status
|
||||
*/
|
||||
async function navigateToBookings(page: Page, status = "upcoming") {
|
||||
// Wait for the bookings API response like existing tests do
|
||||
const bookingsGetResponse = page.waitForResponse((response) =>
|
||||
/\/api\/trpc\/bookings\/get.*/.test(response.url())
|
||||
);
|
||||
await page.goto(`/bookings/${status}`, { waitUntil: "domcontentloaded" });
|
||||
await bookingsGetResponse;
|
||||
}
|
||||
|
||||
test.describe("System Segments", () => {
|
||||
test.describe("Core Functionality", () => {
|
||||
test("My Bookings system segment filters to current user's bookings only", async ({ page, users }) => {
|
||||
const user1 = await users.create({ username: "user1" });
|
||||
|
||||
// Create some bookings for user (this would need booking setup)
|
||||
// For now, we'll test the filter application
|
||||
await user1.apiLogin();
|
||||
await navigateToBookings(page);
|
||||
|
||||
await selectSegment(page, "My Bookings");
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
|
||||
// Verify the userId filter is applied (check URL or filter indicators)
|
||||
const url = page.url();
|
||||
expect(url).toContain("activeFilters");
|
||||
});
|
||||
|
||||
test("System segments show only Duplicate option in submenu", async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
await user.apiLogin();
|
||||
|
||||
await navigateToBookings(page);
|
||||
|
||||
await page.getByTestId("filter-segment-select").click();
|
||||
await page
|
||||
.locator('[data-testid="filter-segment-select-content"] [role="menuitem"]')
|
||||
.filter({ hasText: "My Bookings" })
|
||||
.locator('[data-testid="filter-segment-select-submenu-button"]')
|
||||
.click();
|
||||
|
||||
const submenu = page.getByTestId("filter-segment-select-submenu-content");
|
||||
await expect(submenu.getByText("Duplicate")).toBeVisible();
|
||||
await expect(submenu.getByText("Rename")).toBeHidden();
|
||||
await expect(submenu.getByText("Delete")).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("State Persistence", () => {
|
||||
test("System segment selection persists across page visits", async ({ page, users }) => {
|
||||
const owner = await users.create(undefined, { hasTeam: true });
|
||||
await owner.apiLogin();
|
||||
|
||||
// Visit bookings page and select system segment
|
||||
await navigateToBookings(page);
|
||||
await selectSegment(page, "My Bookings");
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
|
||||
// Revisit the page - should preserve selection
|
||||
await navigateToBookings(page);
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
});
|
||||
|
||||
test("Cleared system segment state persists (no default selection after manual clear)", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
const owner = await users.create(undefined, { hasTeam: true });
|
||||
await owner.apiLogin();
|
||||
|
||||
await navigateToBookings(page);
|
||||
|
||||
// Select system segment
|
||||
await selectSegment(page, "My Bookings");
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
|
||||
// Unselect system segment
|
||||
await selectSegment(page, "My Bookings");
|
||||
|
||||
// Revisit the page - should NOT have any segment selected
|
||||
await navigateToBookings(page);
|
||||
await expectSegmentCleared(page);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Auto-clear Behavior", () => {
|
||||
test("Manual filter clears system segment selection", async ({ page, users }) => {
|
||||
const user = await users.create(undefined, { hasTeam: true });
|
||||
await user.apiLogin();
|
||||
|
||||
await navigateToBookings(page);
|
||||
|
||||
// Select system segment
|
||||
await selectSegment(page, "My Bookings");
|
||||
await expectSegmentSelected(page, "My Bookings");
|
||||
|
||||
// Apply manual filter - this should clear system segment
|
||||
await addFilter(page, "eventTypeId");
|
||||
await page.keyboard.press("Escape");
|
||||
await expectSegmentCleared(page);
|
||||
|
||||
// Verify filter is still applied
|
||||
await expect(page.getByTestId("filter-popover-trigger-eventTypeId")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
module.exports = {
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
config.plugins.autoprefixer = {};
|
||||
}
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -3472,5 +3472,7 @@
|
||||
"webhook_metadata": "Metadata",
|
||||
"stats": "Stats",
|
||||
"booking_status": "Booking status",
|
||||
"my_bookings": "My Bookings",
|
||||
"phone": "Phone",
|
||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -41,18 +41,6 @@
|
||||
},
|
||||
"pages/api/trpc/workflows/[trpc].ts": {
|
||||
"maxDuration": 800
|
||||
},
|
||||
"pages/api/trpc/highPerf/[trpc].ts": {
|
||||
"memory": 3008
|
||||
},
|
||||
"pages/api/trpc/appRoutingForms/[trpc].ts": {
|
||||
"memory": 2048
|
||||
},
|
||||
"pages/router/embed.tsx": {
|
||||
"memory": 2048
|
||||
},
|
||||
"pages/api/book/event.ts": {
|
||||
"memory": 2048
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| roundRobinHideOrgAndTeam | No | Boolean indicating if the organization and team should be hidden in the booker atom sidebar for round robin scheduling type, defaults to false. |
|
||||
| showNoAvailabilityDialog | No | Boolean indicating if the no availability dialog should be shown, defaults to true. |
|
||||
| silentlyHandleCalendarFailures | No | Boolean when true the booker still displays slots when the third party calendars credentials are invalid or expired, Booker may show stale availability when enabled |
|
||||
| hideEventMetadata | No | Boolean that controls the visibility of the event metadata sidebar. When `true`, hides the left sidebar containing event details like title, description, duration, and host information. Defaults to `false`. |
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
"@daily-co/daily-js": "^0.76.0",
|
||||
"@evyweb/ioctopus": "^1.2.0",
|
||||
"@next/third-parties": "^14.2.5",
|
||||
"@unkey/ratelimit": "^2.1.2",
|
||||
"@vercel/functions": "^1.4.0",
|
||||
"city-timezones": "^1.2.1",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { FreeBusyArgs } from "@calcom/features/calendar-cache/calendar-cach
|
||||
import { getTimeMax, getTimeMin } from "@calcom/features/calendar-cache/lib/datesForCache";
|
||||
import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser";
|
||||
import { uniqueBy } from "@calcom/lib/array";
|
||||
import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar";
|
||||
@@ -85,10 +86,16 @@ export default class GoogleCalendarService implements Calendar {
|
||||
const selectedHostDestinationCalendar = event.destinationCalendar?.find(
|
||||
(cal) => cal.credentialId === this.credential.id
|
||||
);
|
||||
|
||||
const isOrganizerExempt = ORGANIZER_EMAIL_EXEMPT_DOMAINS?.split(",")
|
||||
.filter((domain) => domain.trim() !== "")
|
||||
.some((domain) => event.organizer.email.toLowerCase().endsWith(domain.toLowerCase()));
|
||||
|
||||
const eventAttendees = event.attendees.map(({ id: _id, ...rest }) => ({
|
||||
...rest,
|
||||
responseStatus: "accepted",
|
||||
}));
|
||||
|
||||
const attendees: calendar_v3.Schema$EventAttendee[] = [
|
||||
{
|
||||
...event.organizer,
|
||||
@@ -100,7 +107,7 @@ export default class GoogleCalendarService implements Calendar {
|
||||
// We use || instead of ?? here to handle empty strings
|
||||
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
|
||||
},
|
||||
...eventAttendees,
|
||||
...(event.hideOrganizerEmail && !isOrganizerExempt ? [] : eventAttendees),
|
||||
];
|
||||
|
||||
if (event.team?.members) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* TODO: Consolidate this file with BookingLocationService and add tests
|
||||
*/
|
||||
import type { TFunction } from "i18next";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -58,7 +61,7 @@ export type EventLocationTypeFromApp = Ensure<
|
||||
export type EventLocationType = DefaultEventLocationType | EventLocationTypeFromApp;
|
||||
|
||||
export const DailyLocationType = "integrations:daily";
|
||||
|
||||
export const CalVideoLocationType = DailyLocationType;
|
||||
export const MeetLocationType = "integrations:google:meet";
|
||||
|
||||
export const MSTeamsLocationType = "integrations:office365_video";
|
||||
@@ -380,7 +383,7 @@ export const getLocationValueForDB = (
|
||||
eventLocations: LocationObject[]
|
||||
) => {
|
||||
let bookingLocation = bookingLocationTypeOrValue;
|
||||
let conferenceCredentialId = undefined;
|
||||
let conferenceCredentialId: number | undefined = undefined;
|
||||
|
||||
eventLocations.forEach((location) => {
|
||||
if (location.type === bookingLocationTypeOrValue) {
|
||||
|
||||
@@ -661,22 +661,30 @@ export default class Office365CalendarService implements Calendar {
|
||||
);
|
||||
};
|
||||
|
||||
private handleErrorJsonOffice365Calendar = <Type>(response: Response): Promise<Type | string> => {
|
||||
private async handleErrorJsonOffice365Calendar<Type>(response: Response): Promise<Type | string> {
|
||||
if (response.headers.get("content-encoding") === "gzip") {
|
||||
return response.text();
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return new Promise((resolve) => resolve({} as Type));
|
||||
return {} as Type;
|
||||
}
|
||||
|
||||
if (!response.ok && response.status < 200 && response.status >= 300) {
|
||||
response.json().then(console.log);
|
||||
throw Error(response.statusText);
|
||||
if (!response.ok) {
|
||||
let errorBody: string | object;
|
||||
try {
|
||||
errorBody = await response.json();
|
||||
} catch (e) {
|
||||
errorBody = await response.text();
|
||||
}
|
||||
this.log.error(
|
||||
`handleErrorJsonOffice365Calendar: Office365 API request failed with status ${response.status}`,
|
||||
errorBody
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
};
|
||||
}
|
||||
|
||||
async getMainTimeZone(): Promise<string> {
|
||||
try {
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
import { describe, expect, it, afterEach, vi } from "vitest";
|
||||
|
||||
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("jsonLogicToPrisma(Reporting)", () => {
|
||||
describe("Text Operand", () => {
|
||||
it("should support 'Equals' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "A"] }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "A",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Not Equals' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "!=": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "abc",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Contains' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ in: ["A", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
string_contains: "A",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Not Contains' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "!": { in: ["a", { var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }] } }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
string_contains: "a",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe("Number Type", () => {
|
||||
it("should support 'greater than' operator", () => {
|
||||
let prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
">": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// Giving a string here to test that it is converted to a number
|
||||
"100",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
|
||||
});
|
||||
|
||||
prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
">": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// A number would also work
|
||||
100,
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gt: 100 } }],
|
||||
});
|
||||
});
|
||||
it("should support 'greater than or equal to' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
">=": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// Giving a string here to test that it is converted to a number
|
||||
"100",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], gte: 100 } }],
|
||||
});
|
||||
});
|
||||
it("should support 'less than' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
"<": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// Giving a string here to test that it is converted to a number
|
||||
"100",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lt: 100 } }],
|
||||
});
|
||||
});
|
||||
it("should support 'less than or equal to' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{
|
||||
"<=": [
|
||||
{
|
||||
var: "a0d113a8-8e40-49b7-87b1-7f4ab57d226f",
|
||||
},
|
||||
// Giving a string here to test that it is converted to a number
|
||||
"100",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [{ response: { path: ["a0d113a8-8e40-49b7-87b1-7f4ab57d226f", "value"], lte: 100 } }],
|
||||
});
|
||||
});
|
||||
it("'Equals' operator should query with string as well as number", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "1"] }] },
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("MultiSelect", () => {
|
||||
it("should support 'Equals' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{ all: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, { in: [{ var: "" }, ["C", "D"]] }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
response: { path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"], array_contains: ["C", "D"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Any In' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ in: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, ["C", "D"]] }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
|
||||
equals: "C",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
|
||||
equals: "D",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support 'Not In' operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: { and: [{ "!": { in: [{ var: "267c7817-81a5-4bef-9d5b-d0faa4cd0d71" }, ["C", "D"]] } }] },
|
||||
});
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
NOT: {
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
|
||||
equals: "C",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["267c7817-81a5-4bef-9d5b-d0faa4cd0d71", "value"],
|
||||
equals: "D",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support where All Match ['Equals', 'Equals'] operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
and: [
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
AND: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "a",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "b",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support where Any Match ['Equals', 'Equals'] operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
or: [
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "a"] },
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "b"] },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "a",
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"],
|
||||
equals: "b",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should support where None Match ['Equals', 'Equals'] operator", () => {
|
||||
const prismaWhere = jsonLogicToPrisma({
|
||||
logic: {
|
||||
"!": {
|
||||
or: [
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abc"] },
|
||||
{ "==": [{ var: "505d3c3c-aa71-4220-93a9-6fd1e1087939" }, "abcd"] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(prismaWhere).toEqual({
|
||||
NOT: {
|
||||
OR: [
|
||||
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abc" } },
|
||||
{ response: { path: ["505d3c3c-aa71-4220-93a9-6fd1e1087939", "value"], equals: "abcd" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,276 +0,0 @@
|
||||
// It can have many shapes, so just use any and we rely on unit tests to test all those scenarios.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type LogicData = Partial<Record<keyof typeof OPERATOR_MAP, any>>;
|
||||
type NegatedLogicData = {
|
||||
"!": LogicData;
|
||||
};
|
||||
|
||||
export type JsonLogicQuery = {
|
||||
logic: {
|
||||
and?: LogicData[];
|
||||
or?: LogicData[];
|
||||
"!"?: {
|
||||
and?: LogicData[];
|
||||
or?: LogicData[];
|
||||
};
|
||||
} | null;
|
||||
};
|
||||
|
||||
type PrismaWhere = {
|
||||
AND?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
|
||||
OR?: ReturnType<typeof convertQueriesToPrismaWhereClause>[];
|
||||
NOT?: PrismaWhere;
|
||||
};
|
||||
|
||||
const OPERATOR_MAP = {
|
||||
"==": {
|
||||
operator: "equals",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
in: {
|
||||
operator: "string_contains",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
"!=": {
|
||||
operator: "NOT.equals",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
"!": {
|
||||
operator: "equals",
|
||||
secondaryOperand: "",
|
||||
},
|
||||
"!!": {
|
||||
operator: "NOT.equals",
|
||||
secondaryOperand: "",
|
||||
},
|
||||
">": {
|
||||
operator: "gt",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
">=": {
|
||||
operator: "gte",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
"<": {
|
||||
operator: "lt",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
"<=": {
|
||||
operator: "lte",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
all: {
|
||||
operator: "array_contains",
|
||||
secondaryOperand: null,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Operators supported on array of basic queries
|
||||
*/
|
||||
const GROUP_OPERATOR_MAP = {
|
||||
and: "AND",
|
||||
or: "OR",
|
||||
"!": "NOT",
|
||||
} as const;
|
||||
|
||||
const NumberOperators = [">", ">=", "<", "<="];
|
||||
|
||||
const negatePrismaWhereClauseIfNeeded = (data: any, isNegation: boolean) => {
|
||||
if (!isNegation) {
|
||||
return data;
|
||||
}
|
||||
return {
|
||||
NOT: {
|
||||
...data,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const convertSingleQueryToPrismaWhereClause = (
|
||||
operatorName: keyof typeof OPERATOR_MAP,
|
||||
logicData: LogicData,
|
||||
isNegation: boolean
|
||||
) => {
|
||||
const mappedOperator = OPERATOR_MAP[operatorName].operator;
|
||||
const staticSecondaryOperand = OPERATOR_MAP[operatorName].secondaryOperand;
|
||||
isNegation = isNegation || mappedOperator.startsWith("NOT.");
|
||||
const prismaOperator = mappedOperator.replace("NOT.", "");
|
||||
const operands =
|
||||
logicData[operatorName] instanceof Array ? logicData[operatorName] : [logicData[operatorName]];
|
||||
|
||||
if (operatorName === "in" && operands[0]?.var && Array.isArray(operands[1])) {
|
||||
// Case A: Item "in" array
|
||||
// operands[0]: { var: ... }
|
||||
// operands[1]: items to test against
|
||||
|
||||
// Convert 'in' operator to union of OR clauses
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
OR: (operands[1] ?? []).map((value) => ({
|
||||
response: {
|
||||
path: [operands[0].var, "value"],
|
||||
[`${OPERATOR_MAP["=="].operator}`]: value,
|
||||
},
|
||||
})),
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
}
|
||||
|
||||
if (operatorName === "in" && typeof operands[0] === "string" && operands[1]?.var) {
|
||||
// Case B: String "in" string
|
||||
// operands[0]: string to test against
|
||||
// operands[1]: { var: ... }
|
||||
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
response: {
|
||||
path: [operands[1].var, "value"],
|
||||
[`${prismaOperator}`]: operands[0],
|
||||
},
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
}
|
||||
|
||||
const mainOperand = operands[0].var;
|
||||
let secondaryOperand;
|
||||
|
||||
if (operatorName === "all") {
|
||||
secondaryOperand = operands[1].in[1];
|
||||
} else {
|
||||
secondaryOperand = staticSecondaryOperand || operands[1] || "";
|
||||
}
|
||||
|
||||
const isNumberOperator = NumberOperators.includes(operatorName);
|
||||
const secondaryOperandAsNumber = typeof secondaryOperand === "string" ? Number(secondaryOperand) : null;
|
||||
|
||||
if (secondaryOperandAsNumber && isNumberOperator) {
|
||||
// We know that it's number operator so Prisma should query number
|
||||
// Note that if we get string values in DB(e.g. '100'), those values can't be filtered with number operators.
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
response: {
|
||||
path: [mainOperand, "value"],
|
||||
[`${prismaOperator}`]: secondaryOperandAsNumber,
|
||||
},
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
}
|
||||
|
||||
if (secondaryOperandAsNumber && !isNumberOperator) {
|
||||
// We know that it's not number operator but the input field might have been a number and thus stored value in DB as number.
|
||||
// Also, even for input type=number we might accidentally get string value(e.g. '100'). So, let reporting do it's best job with both number and string.
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
response: {
|
||||
path: [mainOperand, "value"],
|
||||
// Query as string e.g. equals '100'
|
||||
[`${prismaOperator}`]: secondaryOperand,
|
||||
},
|
||||
},
|
||||
{
|
||||
response: {
|
||||
path: [mainOperand, "value"],
|
||||
// Query as number e.g. equals 100
|
||||
[`${prismaOperator}`]: secondaryOperandAsNumber,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
}
|
||||
|
||||
return negatePrismaWhereClauseIfNeeded(
|
||||
{
|
||||
response: {
|
||||
path: [mainOperand, "value"],
|
||||
[`${prismaOperator}`]: secondaryOperand,
|
||||
},
|
||||
},
|
||||
isNegation
|
||||
);
|
||||
};
|
||||
|
||||
const isNegation = (logicData: LogicData | NegatedLogicData) => {
|
||||
if ("!" in logicData) {
|
||||
const negatedLogicData = logicData["!"];
|
||||
|
||||
for (const [operatorName] of Object.entries(OPERATOR_MAP)) {
|
||||
if (negatedLogicData[operatorName]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const convertQueriesToPrismaWhereClause = (logicData: LogicData) => {
|
||||
const _isNegation = isNegation(logicData);
|
||||
if (_isNegation) {
|
||||
logicData = logicData["!"];
|
||||
}
|
||||
for (const [key] of Object.entries(OPERATOR_MAP)) {
|
||||
const operatorName = key as keyof typeof OPERATOR_MAP;
|
||||
if (logicData[operatorName]) {
|
||||
return convertSingleQueryToPrismaWhereClause(operatorName, logicData, _isNegation);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const jsonLogicToPrisma = (query: JsonLogicQuery) => {
|
||||
try {
|
||||
let logic = query.logic;
|
||||
if (!logic) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let prismaWhere: PrismaWhere = {};
|
||||
let negateLogic = false;
|
||||
|
||||
// Case: Negation of "Any of these"
|
||||
// Example: {"logic":{"!":{"or":[{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]},{"==":[{"var":"505d3c3c-aa71-4220-93a9-6fd1e1087939"},"1"]}]}}}
|
||||
if (logic["!"]) {
|
||||
logic = logic["!"];
|
||||
negateLogic = true;
|
||||
}
|
||||
|
||||
// Case: All of these
|
||||
if (logic.and) {
|
||||
const where: PrismaWhere["AND"] = (prismaWhere[GROUP_OPERATOR_MAP["and"]] = []);
|
||||
logic.and.forEach((and) => {
|
||||
const res = convertQueriesToPrismaWhereClause(and);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
where.push(res);
|
||||
});
|
||||
}
|
||||
// Case: Any of these
|
||||
else if (logic.or) {
|
||||
const where: PrismaWhere["OR"] = (prismaWhere[GROUP_OPERATOR_MAP["or"]] = []);
|
||||
|
||||
logic.or.forEach((or) => {
|
||||
const res = convertQueriesToPrismaWhereClause(or);
|
||||
if (!res) {
|
||||
return;
|
||||
}
|
||||
where.push(res);
|
||||
});
|
||||
}
|
||||
|
||||
if (negateLogic) {
|
||||
prismaWhere = { NOT: { ...prismaWhere } };
|
||||
}
|
||||
|
||||
return prismaWhere;
|
||||
} catch (e) {
|
||||
console.log("Error converting to prisma `where`", JSON.stringify(query), "Error is ", e);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
@@ -84,7 +84,7 @@ export function getQueryBuilderConfigForFormFields(form: Pick<RoutingForm, "fiel
|
||||
delete initialConfigCopy.operators.is_empty;
|
||||
delete initialConfigCopy.operators.is_not_empty;
|
||||
|
||||
// Between and Not between aren't directly supported by prisma. So, we need to update jsonLogicToPrisma to generate gte and lte query for between. It can be implemented later.
|
||||
// These operators can be implemented later if needed.
|
||||
delete initialConfigCopy.operators.between;
|
||||
delete initialConfigCopy.operators.not_between;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import { ZFormQueryInputSchema } from "./formQuery.schema";
|
||||
import { ZGetAttributesForTeamInputSchema } from "./getAttributesForTeam.schema";
|
||||
import { ZGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema";
|
||||
import { forms } from "./procedures/forms";
|
||||
import { ZReportInputSchema } from "./report.schema";
|
||||
import { ZSaveIncompleteBookingSettingsInputSchema } from "./saveIncompleteBookingSettings.schema";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
@@ -76,11 +75,6 @@ const appRoutingForms = router({
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
|
||||
report: authedProcedure.input(ZReportInputSchema).query(async ({ ctx, input }) => {
|
||||
const handler = await getHandler("report", () => import("./report.handler"));
|
||||
return handler({ ctx, input });
|
||||
}),
|
||||
|
||||
getAttributesForTeam: authedProcedure
|
||||
.input(ZGetAttributesForTeamInputSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildResponsesForReporting } from "./report.handler";
|
||||
|
||||
describe("buildResponsesForReporting", () => {
|
||||
it("for fields with options, it should return the labels of the options", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["option1", "option2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("for fields with options having null id, it should return what DB provided(as that would be labels only).", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("for fields with no options but having array value, it should return the value as is", () => {
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["Option 1, Option 2"]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 2"]);
|
||||
});
|
||||
|
||||
it("should correctly handle numeric responses converting them to strings", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: 1 },
|
||||
[field2Id]: { value: [1, 2] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
options: [
|
||||
{ id: null, label: "Option 1" },
|
||||
{ id: null, label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const expectedResponses = [["1", "1, 2"]];
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1", "Field 2"]);
|
||||
});
|
||||
|
||||
it("should handle empty responses", () => {
|
||||
const field1Id = uuidv4();
|
||||
const responsesFromDb = [{}];
|
||||
|
||||
const fields = [{ id: field1Id, label: "Field 1" }];
|
||||
|
||||
const expectedResponses = [[""]];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(["Field 1"]);
|
||||
});
|
||||
|
||||
it("should show correct header for deleted fields", () => {
|
||||
const field1Id = uuidv4();
|
||||
const field2Id = uuidv4();
|
||||
const responsesFromDb = [
|
||||
{
|
||||
[field1Id]: { value: "value1" },
|
||||
[field2Id]: { value: ["Option 1", "Option 2"] },
|
||||
},
|
||||
];
|
||||
|
||||
const fields = [
|
||||
{ id: field1Id, label: "Field 1" },
|
||||
{
|
||||
id: field2Id,
|
||||
label: "Field 2",
|
||||
deleted: true,
|
||||
options: [
|
||||
{ id: "option1", label: "Option 1" },
|
||||
{ id: "option2", label: "Option 2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedResponses = [["value1", "Option 1, Option 2"]];
|
||||
const expectedHeaders = ["Field 1", "Field 2(Deleted)"];
|
||||
|
||||
const { responses, headers } = buildResponsesForReporting({ responsesFromDb, fields });
|
||||
expect(responses).toEqual(expectedResponses);
|
||||
expect(headers).toEqual(expectedHeaders);
|
||||
});
|
||||
});
|
||||
@@ -1,182 +0,0 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { App_RoutingForms_FormResponse } from "@calcom/prisma/client";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { jsonLogicToPrisma } from "../jsonLogicToPrisma";
|
||||
import { getSerializableForm } from "../lib/getSerializableForm";
|
||||
import { ensureStringOrStringArray, getLabelsFromOptionIds } from "../lib/reportingUtils";
|
||||
import type { FormResponse } from "../types/types";
|
||||
import type { zodFieldView } from "../zod";
|
||||
import type { TReportInputSchema } from "./report.schema";
|
||||
|
||||
interface ReportHandlerOptions {
|
||||
ctx: {
|
||||
prisma: PrismaClient;
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TReportInputSchema;
|
||||
}
|
||||
|
||||
const makeFormatDate = (locale: string, timeZone: string) => {
|
||||
const formatDate = (date: Date): string => {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
timeZone,
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
};
|
||||
return formatDate;
|
||||
};
|
||||
|
||||
const getRows = async ({ ctx: { prisma }, input }: ReportHandlerOptions) => {
|
||||
// Can be any prisma `where` clause
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const prismaWhere: Record<string, any> = input.jsonLogicQuery
|
||||
? jsonLogicToPrisma(input.jsonLogicQuery)
|
||||
: {};
|
||||
const skip = input.cursor ?? 0;
|
||||
const take = input.limit ? input.limit + 1 : 50;
|
||||
|
||||
logger.debug(
|
||||
`Built Prisma where ${JSON.stringify(prismaWhere)} from jsonLogicQuery ${JSON.stringify(
|
||||
input.jsonLogicQuery
|
||||
)}`
|
||||
);
|
||||
const rows = await prisma.app_RoutingForms_FormResponse.findMany({
|
||||
where: {
|
||||
formId: input.formId,
|
||||
...prismaWhere,
|
||||
},
|
||||
include: {
|
||||
routedToBooking: {
|
||||
select: {
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: { id: true, name: true, email: true },
|
||||
},
|
||||
assignmentReason: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take,
|
||||
skip,
|
||||
});
|
||||
return { skip, take, rows };
|
||||
};
|
||||
|
||||
export const reportHandler = async (options: ReportHandlerOptions) => {
|
||||
const {
|
||||
ctx: { prisma },
|
||||
input,
|
||||
} = options;
|
||||
const form = await prisma.app_RoutingForms_Form.findUnique({
|
||||
where: {
|
||||
id: input.formId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!form) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Form not found",
|
||||
});
|
||||
}
|
||||
// TODO: Second argument is required to return deleted operators.
|
||||
const serializedForm = await getSerializableForm({ form, withDeletedFields: true });
|
||||
const { rows, skip, take } = await getRows(options);
|
||||
|
||||
const fields = serializedForm?.fields || [];
|
||||
const { responses, headers } = buildResponsesForReporting({
|
||||
responsesFromDb: rows.map((r) => r.response),
|
||||
fields,
|
||||
});
|
||||
|
||||
const areThereNoResultsOrLessThanAskedFor = !rows.length || rows.length < take;
|
||||
return presenter({
|
||||
rows,
|
||||
options,
|
||||
headers,
|
||||
responses,
|
||||
nextCursor: areThereNoResultsOrLessThanAskedFor ? null : skip + rows.length,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This is a temporary solution to make the report work. It should be incorporated into the report data itself.
|
||||
* Right now we cannot filter by Routed To and Booked At because they are not part of the response data.
|
||||
*/
|
||||
function presenter(args: {
|
||||
rows: Awaited<ReturnType<typeof getRows>>["rows"];
|
||||
options: ReportHandlerOptions;
|
||||
headers: string[];
|
||||
responses: string[][];
|
||||
nextCursor: number | null;
|
||||
}) {
|
||||
const { headers, responses, nextCursor, options, rows } = args;
|
||||
const { ctx } = options;
|
||||
const formatDate = makeFormatDate(ctx.user.locale, ctx.user.timeZone);
|
||||
return {
|
||||
nextCursor,
|
||||
headers: [...headers, "Routed To", "Assignment Reason", "Booked At", "Submitted At"],
|
||||
responses: responses.map((r, i) => {
|
||||
const currentRow = rows[i];
|
||||
return [
|
||||
...r,
|
||||
currentRow.routedToBooking?.user?.email || "",
|
||||
currentRow.routedToBooking?.assignmentReason.length
|
||||
? currentRow.routedToBooking.assignmentReason[0].reasonString
|
||||
: "",
|
||||
currentRow.routedToBooking?.createdAt ? formatDate(currentRow.routedToBooking.createdAt) : "",
|
||||
formatDate(currentRow.createdAt),
|
||||
];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export default reportHandler;
|
||||
|
||||
export function buildResponsesForReporting({
|
||||
responsesFromDb,
|
||||
fields,
|
||||
}: {
|
||||
responsesFromDb: App_RoutingForms_FormResponse["response"][];
|
||||
fields: Pick<z.infer<typeof zodFieldView>, "id" | "options" | "label" | "deleted">[];
|
||||
}) {
|
||||
const headers = fields.map((f) => f.label + (f.deleted ? "(Deleted)" : ""));
|
||||
const responses: string[][] = [];
|
||||
responsesFromDb.forEach((r) => {
|
||||
const rowResponses: string[] = [];
|
||||
responses.push(rowResponses);
|
||||
fields.forEach((field) => {
|
||||
if (!r) {
|
||||
return;
|
||||
}
|
||||
const response = r as FormResponse;
|
||||
const value = response[field.id]?.value || "";
|
||||
if (field.options) {
|
||||
const optionIds = ensureStringOrStringArray(value);
|
||||
const labels = getLabelsFromOptionIds({ options: field.options, optionIds });
|
||||
rowResponses.push(labels.join(", "));
|
||||
} else {
|
||||
const arrayOfValues = value instanceof Array ? value : [value];
|
||||
const transformedValue = arrayOfValues.join(", ");
|
||||
rowResponses.push(transformedValue);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return { responses, headers };
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import z from "zod";
|
||||
|
||||
export const ZReportInputSchema = z.object({
|
||||
limit: z.number().default(50),
|
||||
formId: z.string(),
|
||||
jsonLogicQuery: z.object({
|
||||
logic: z.union([z.record(z.any()), z.null()]),
|
||||
}),
|
||||
cursor: z.number().nullish(), // <-- "cursor" needs to exist when using useInfiniteQuery, but can be any type
|
||||
});
|
||||
|
||||
export type TReportInputSchema = z.infer<typeof ZReportInputSchema>;
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/${config.slug}/callback`,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`,
|
||||
scope: "spark:kms meeting:schedules_read meeting:schedules_write", //should be "A space-separated list of scopes being requested by your integration"
|
||||
state: "",
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
|
||||
/** @link https://developer.webex.com/docs/integrations#getting-an-access-token **/
|
||||
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/${config.slug}/callback`);
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/${config.slug}/callback`);
|
||||
const params = new URLSearchParams([
|
||||
["grant_type", "authorization_code"],
|
||||
["client_id", client_id],
|
||||
|
||||
@@ -404,23 +404,25 @@ const BookerComponent = ({
|
||||
src={orgBannerUrl}
|
||||
/>
|
||||
)}
|
||||
<EventMeta
|
||||
selectedTimeslot={selectedTimeslot}
|
||||
classNames={{
|
||||
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
|
||||
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
|
||||
eventMetaTimezoneSelect:
|
||||
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
|
||||
}}
|
||||
event={event.data}
|
||||
isPending={event.isPending}
|
||||
isPlatform={isPlatform}
|
||||
isPrivateLink={!!hashedLink}
|
||||
locale={userLocale}
|
||||
timeZones={timeZones}
|
||||
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
|
||||
{eventMetaChildren}
|
||||
</EventMeta>
|
||||
{!hideEventTypeDetails && (
|
||||
<EventMeta
|
||||
selectedTimeslot={selectedTimeslot}
|
||||
classNames={{
|
||||
eventMetaContainer: customClassNames?.eventMetaCustomClassNames?.eventMetaContainer,
|
||||
eventMetaTitle: customClassNames?.eventMetaCustomClassNames?.eventMetaTitle,
|
||||
eventMetaTimezoneSelect:
|
||||
customClassNames?.eventMetaCustomClassNames?.eventMetaTimezoneSelect,
|
||||
}}
|
||||
event={event.data}
|
||||
isPending={event.isPending}
|
||||
isPlatform={isPlatform}
|
||||
isPrivateLink={!!hashedLink}
|
||||
locale={userLocale}
|
||||
timeZones={timeZones}
|
||||
roundRobinHideOrgAndTeam={roundRobinHideOrgAndTeam}>
|
||||
{eventMetaChildren}
|
||||
</EventMeta>
|
||||
)}
|
||||
{layout !== BookerLayouts.MONTH_VIEW &&
|
||||
!(layout === "mobile" && bookerState === "booking") && (
|
||||
<div className="mt-auto px-5 py-3">
|
||||
|
||||
@@ -13,6 +13,7 @@ export const getCalEventResponses = ({
|
||||
bookingFields,
|
||||
booking,
|
||||
responses,
|
||||
seatsEnabled,
|
||||
}: {
|
||||
// If the eventType has been deleted and a booking is Accepted later on, then bookingFields will be null and we can't know the label of fields. So, we should store the label as well in the DB
|
||||
// Also, it is no longer straightforward to identify if a field is system field or not
|
||||
@@ -32,6 +33,7 @@ export const getCalEventResponses = ({
|
||||
};
|
||||
}>;
|
||||
responses?: z.infer<typeof bookingResponsesDbSchema>;
|
||||
seatsEnabled?: boolean;
|
||||
}) => {
|
||||
const calEventUserFieldsResponses = {} as NonNullable<CalendarEvent["userFieldsResponses"]>;
|
||||
const calEventResponses = {} as NonNullable<CalendarEvent["responses"]>;
|
||||
@@ -61,8 +63,7 @@ export const getCalEventResponses = ({
|
||||
throw new Error(`Missing label for booking field "${field.name}"`);
|
||||
}
|
||||
|
||||
// If the guests field is hidden (disableGuests is set on the event type) don't try and infer guests from attendees list
|
||||
if (field.name == "guests" && field.hidden) {
|
||||
if (field.name == "guests" && !!seatsEnabled) {
|
||||
backwardCompatibleResponses[field.name] = [];
|
||||
}
|
||||
|
||||
|
||||
@@ -1919,7 +1919,7 @@ async function handler(
|
||||
|
||||
if (!isDryRun) {
|
||||
sendRoundRobinRescheduledEmailsAndSMS(
|
||||
copyEventAdditionalInfo,
|
||||
{ ...copyEventAdditionalInfo, iCalUID },
|
||||
rescheduledMembers,
|
||||
eventType.metadata
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ const _getBookingData = async <T extends z.ZodType>({
|
||||
getCalEventResponses({
|
||||
bookingFields: eventType.bookingFields,
|
||||
responses,
|
||||
seatsEnabled: !!eventType.seatsPerTimeSlot,
|
||||
});
|
||||
return {
|
||||
...parsedBody,
|
||||
|
||||
@@ -3,9 +3,13 @@
|
||||
import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import debounce from "lodash/debounce";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import isEqual from "lodash/isEqual";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useQueryState } from "nuqs";
|
||||
import { createContext, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
import { useState, createContext, useCallback, useEffect, useRef, useMemo } from "react";
|
||||
|
||||
import { useElementByClassName } from "@calcom/lib/hooks/useElementByClassName";
|
||||
|
||||
import { useSegmentsNoop } from "./hooks/useSegmentsNoop";
|
||||
import {
|
||||
@@ -22,8 +26,12 @@ import {
|
||||
import {
|
||||
type FilterValue,
|
||||
type FilterSegmentOutput,
|
||||
type SystemFilterSegment,
|
||||
type CombinedFilterSegment,
|
||||
type SegmentIdentifier,
|
||||
type ActiveFilters,
|
||||
type UseSegments,
|
||||
SYSTEM_SEGMENT_PREFIX,
|
||||
} from "./lib/types";
|
||||
import { CTA_CONTAINER_CLASS_NAME } from "./lib/utils";
|
||||
|
||||
@@ -55,10 +63,10 @@ export type DataTableContextType = {
|
||||
offset: number;
|
||||
limit: number;
|
||||
|
||||
segments: FilterSegmentOutput[];
|
||||
selectedSegment: FilterSegmentOutput | undefined;
|
||||
segmentId: number | undefined;
|
||||
setSegmentId: (id: number | null) => void;
|
||||
segments: CombinedFilterSegment[];
|
||||
selectedSegment: CombinedFilterSegment | undefined;
|
||||
segmentId: SegmentIdentifier | null;
|
||||
setSegmentId: (id: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => void;
|
||||
canSaveSegment: boolean;
|
||||
isSegmentEnabled: boolean;
|
||||
|
||||
@@ -78,7 +86,8 @@ interface DataTableProviderProps {
|
||||
defaultPageSize?: number;
|
||||
segments?: FilterSegmentOutput[];
|
||||
timeZone?: string;
|
||||
preferredSegmentId?: number | null;
|
||||
preferredSegmentId?: SegmentIdentifier | null;
|
||||
systemSegments?: SystemFilterSegment[];
|
||||
}
|
||||
|
||||
export function DataTableProvider({
|
||||
@@ -90,8 +99,18 @@ export function DataTableProvider({
|
||||
segments: providedSegments,
|
||||
timeZone,
|
||||
preferredSegmentId,
|
||||
systemSegments,
|
||||
}: DataTableProviderProps) {
|
||||
const pathname = usePathname() as string | null;
|
||||
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
|
||||
if (!tableIdentifier) {
|
||||
throw new Error("tableIdentifier is required");
|
||||
}
|
||||
|
||||
const filterToOpen = useRef<string | undefined>(undefined);
|
||||
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
|
||||
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
|
||||
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
|
||||
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
|
||||
const [sorting, setSorting] = useQueryState("sorting", sortingParser);
|
||||
const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>(
|
||||
@@ -99,34 +118,172 @@ export function DataTableProvider({
|
||||
columnVisibilityParser
|
||||
);
|
||||
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
|
||||
const [segmentId, setSegmentId] = useQueryState(
|
||||
"segment",
|
||||
segmentIdParser.withDefault(preferredSegmentId ?? -1)
|
||||
const initialSegmentId = useMemo(
|
||||
() => (preferredSegmentId ? String(preferredSegmentId.id) : null),
|
||||
[preferredSegmentId]
|
||||
);
|
||||
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
|
||||
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
|
||||
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
|
||||
const [segmentId, _setSegmentId] = useQueryState(
|
||||
"segment",
|
||||
initialSegmentId ? segmentIdParser.withDefault(initialSegmentId) : segmentIdParser
|
||||
);
|
||||
const {
|
||||
segments,
|
||||
preferredSegmentId: fetchedPreferredSegmentId,
|
||||
setPreference: setSegmentPreference,
|
||||
isSegmentEnabled,
|
||||
isSuccess: isSegmentFetchedSuccessfully,
|
||||
} = useSegments({
|
||||
tableIdentifier,
|
||||
providedSegments,
|
||||
systemSegments,
|
||||
});
|
||||
|
||||
const findSelectedSegment = useCallback(
|
||||
(segmentId: string) => {
|
||||
return segments.find((segment) => {
|
||||
if (
|
||||
segment.type === "system" &&
|
||||
segmentId &&
|
||||
segmentId.startsWith(SYSTEM_SEGMENT_PREFIX) &&
|
||||
segment.id === segmentId
|
||||
) {
|
||||
return true;
|
||||
} else if (segment.type === "user") {
|
||||
const segmentIdNumber = parseInt(segmentId, 10);
|
||||
return segment.id === segmentIdNumber;
|
||||
}
|
||||
});
|
||||
},
|
||||
[segments]
|
||||
);
|
||||
|
||||
const segmentIdObject = useMemo(() => {
|
||||
if (segmentId && segmentId.startsWith(SYSTEM_SEGMENT_PREFIX)) {
|
||||
return {
|
||||
id: segmentId,
|
||||
type: "system" as const,
|
||||
};
|
||||
} else {
|
||||
const segmentIdNumber = parseInt(segmentId, 10);
|
||||
if (!Number.isNaN(segmentIdNumber)) {
|
||||
return {
|
||||
id: segmentIdNumber,
|
||||
type: "user" as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [segmentId]);
|
||||
|
||||
const [selectedSegment, setSelectedSegment] = useState<CombinedFilterSegment | undefined>(
|
||||
findSelectedSegment(segmentId)
|
||||
);
|
||||
|
||||
const setSegmentId = useCallback(
|
||||
(segmentId: SegmentIdentifier | null, providedSegment?: CombinedFilterSegment) => {
|
||||
if (!segmentId) {
|
||||
_setSegmentId(null);
|
||||
setSelectedSegment(undefined);
|
||||
setSegmentPreference({
|
||||
tableIdentifier,
|
||||
segmentId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const segment = providedSegment || findSelectedSegment(String(segmentId.id));
|
||||
if (!segment) {
|
||||
// If segmentId is invalid (or not found), clear the segmentId from the query params,
|
||||
// but we still keep all the other states like activeFilters, etc.
|
||||
// This is useful when someone shares a URL that is inaccessible to someone else.
|
||||
_setSegmentId(null);
|
||||
setSelectedSegment(undefined);
|
||||
setSegmentPreference({
|
||||
tableIdentifier,
|
||||
segmentId: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_setSegmentId(String(segmentId.id));
|
||||
setSelectedSegment(segment);
|
||||
setSegmentPreference({
|
||||
tableIdentifier,
|
||||
segmentId,
|
||||
});
|
||||
|
||||
// apply the segment
|
||||
setActiveFilters(segment.activeFilters);
|
||||
if (segment.sorting) {
|
||||
setSorting(segment.sorting);
|
||||
}
|
||||
if (segment.columnVisibility) {
|
||||
setColumnVisibility(segment.columnVisibility);
|
||||
}
|
||||
if (segment.columnSizing) {
|
||||
setColumnSizing(segment.columnSizing);
|
||||
}
|
||||
if (segment.perPage !== undefined) {
|
||||
setPageSize(segment.perPage);
|
||||
}
|
||||
if (segment.searchTerm !== undefined) {
|
||||
setSearchTerm(segment.searchTerm);
|
||||
}
|
||||
setPageIndex(0);
|
||||
},
|
||||
[
|
||||
_setSegmentId,
|
||||
setSelectedSegment,
|
||||
setSegmentPreference,
|
||||
tableIdentifier,
|
||||
findSelectedSegment,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setSearchTerm,
|
||||
setPageIndex,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSegmentFetchedSuccessfully) {
|
||||
return;
|
||||
}
|
||||
// If the preferred segment id has been fetched
|
||||
// and no segment id has been selected yet,
|
||||
// then we set it.
|
||||
if (fetchedPreferredSegmentId && !segmentId) {
|
||||
setSegmentId(fetchedPreferredSegmentId);
|
||||
} else if (segmentId) {
|
||||
setSelectedSegment(findSelectedSegment(segmentId));
|
||||
}
|
||||
// We intentionally have only `isSegmentFetchedSuccessfully`
|
||||
// in the dependency array.
|
||||
}, [isSegmentFetchedSuccessfully]);
|
||||
|
||||
const clearSystemSegmentSelectionIfExists = useCallback(() => {
|
||||
if (selectedSegment?.type === "system") {
|
||||
setSegmentId(null);
|
||||
}
|
||||
}, [selectedSegment, setSegmentId]);
|
||||
|
||||
const setDebouncedSearchTerm = useMemo(
|
||||
() => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500),
|
||||
[setSearchTerm]
|
||||
);
|
||||
|
||||
const pathname = usePathname() as string | null;
|
||||
const tableIdentifier = _tableIdentifier ?? pathname ?? undefined;
|
||||
if (!tableIdentifier) {
|
||||
throw new Error("tableIdentifier is required");
|
||||
}
|
||||
|
||||
const addFilter = useCallback(
|
||||
(columnId: string) => {
|
||||
if (!activeFilters?.some((filter) => filter.f === columnId)) {
|
||||
// do not reset the page to 0 here,
|
||||
// because we don't have the filter value yet (`v: undefined`)
|
||||
setActiveFilters([...activeFilters, { f: columnId, v: undefined }]);
|
||||
clearSystemSegmentSelectionIfExists();
|
||||
}
|
||||
},
|
||||
[activeFilters, setActiveFilters]
|
||||
[activeFilters, setActiveFilters, clearSystemSegmentSelectionIfExists]
|
||||
);
|
||||
|
||||
const setPageIndexWrapper = useCallback(
|
||||
@@ -151,8 +308,9 @@ export function DataTableProvider({
|
||||
}
|
||||
return newFilters;
|
||||
});
|
||||
clearSystemSegmentSelectionIfExists();
|
||||
},
|
||||
[setActiveFilters, setPageIndex]
|
||||
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
|
||||
);
|
||||
|
||||
const removeFilter = useCallback(
|
||||
@@ -162,61 +320,72 @@ export function DataTableProvider({
|
||||
const remainingFilters = prev.filter((filter) => filter.f !== columnId);
|
||||
return remainingFilters.length === 0 ? null : remainingFilters;
|
||||
});
|
||||
clearSystemSegmentSelectionIfExists();
|
||||
},
|
||||
[setActiveFilters, setPageIndex]
|
||||
[setActiveFilters, setPageIndex, clearSystemSegmentSelectionIfExists]
|
||||
);
|
||||
|
||||
const setPageSizeAndGoToFirstPage = useCallback(
|
||||
(newPageSize: number | null) => {
|
||||
setPageSize(newPageSize === defaultPageSize ? null : newPageSize);
|
||||
setPageIndex(null);
|
||||
clearSystemSegmentSelectionIfExists();
|
||||
},
|
||||
[setPageSize, setPageIndex, defaultPageSize]
|
||||
);
|
||||
|
||||
const { segments, selectedSegment, canSaveSegment, setAndPersistSegmentId, isSegmentEnabled } = useSegments(
|
||||
{
|
||||
tableIdentifier,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
defaultPageSize,
|
||||
segmentId,
|
||||
setSegmentId,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
setSearchTerm,
|
||||
segments: providedSegments,
|
||||
preferredSegmentId,
|
||||
}
|
||||
[setPageSize, setPageIndex, defaultPageSize, clearSystemSegmentSelectionIfExists]
|
||||
);
|
||||
|
||||
const clearAll = useCallback(
|
||||
(exclude?: string[]) => {
|
||||
setAndPersistSegmentId(null);
|
||||
setSegmentId(null);
|
||||
setPageIndex(null);
|
||||
setActiveFilters((prev) => {
|
||||
const remainingFilters = prev.filter((filter) => exclude?.includes(filter.f));
|
||||
return remainingFilters.length === 0 ? null : remainingFilters;
|
||||
});
|
||||
},
|
||||
[setActiveFilters, setPageIndex, setAndPersistSegmentId]
|
||||
[setActiveFilters, setPageIndex, setSegmentId]
|
||||
);
|
||||
|
||||
const ctaContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (ctaContainerClassName) {
|
||||
const element = document.getElementsByClassName(ctaContainerClassName)[0] as HTMLDivElement;
|
||||
ctaContainerRef.current = element;
|
||||
// Check if current state differs from selected segment
|
||||
const hasStateChanged = useMemo(() => {
|
||||
if (!selectedSegment) return false;
|
||||
|
||||
return (
|
||||
!isEqual(activeFilters, selectedSegment.activeFilters) ||
|
||||
!isEqual(sorting, selectedSegment.sorting) ||
|
||||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
|
||||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
|
||||
!isEqual(pageSize, selectedSegment.perPage) ||
|
||||
!isEqual(searchTerm || "", selectedSegment.searchTerm || "")
|
||||
);
|
||||
}, [selectedSegment, activeFilters, sorting, columnVisibility, columnSizing, pageSize, searchTerm]);
|
||||
|
||||
const canSaveSegment = useMemo(() => {
|
||||
if (selectedSegment) {
|
||||
return hasStateChanged;
|
||||
}
|
||||
}, [ctaContainerClassName]);
|
||||
// if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
|
||||
return (
|
||||
activeFilters.length > 0 ||
|
||||
sorting.length > 0 ||
|
||||
Object.keys(columnVisibility).length > 0 ||
|
||||
Object.keys(columnSizing).length > 0 ||
|
||||
pageSize !== defaultPageSize ||
|
||||
searchTerm?.length > 0
|
||||
);
|
||||
}, [
|
||||
selectedSegment,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
defaultPageSize,
|
||||
hasStateChanged,
|
||||
]);
|
||||
|
||||
const ctaContainerRef = useElementByClassName<HTMLDivElement>(ctaContainerClassName);
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider
|
||||
@@ -243,8 +412,8 @@ export function DataTableProvider({
|
||||
offset: pageIndex * pageSize,
|
||||
segments,
|
||||
selectedSegment,
|
||||
segmentId: segmentId || undefined,
|
||||
setSegmentId: setAndPersistSegmentId,
|
||||
segmentId: segmentIdObject,
|
||||
setSegmentId,
|
||||
canSaveSegment,
|
||||
isSegmentEnabled,
|
||||
searchTerm,
|
||||
|
||||
@@ -21,7 +21,7 @@ export function DeleteSegmentDialog({
|
||||
onSuccess: ({ id }) => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_deleted"), "success");
|
||||
if (segmentId === id) {
|
||||
if (segmentId && segmentId.type === "user" && segmentId.id === id) {
|
||||
setSegmentId(null);
|
||||
}
|
||||
onClose();
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import type { FilterSegmentOutput } from "../../lib/types";
|
||||
import type { CombinedFilterSegment } from "../../lib/types";
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
@@ -20,7 +20,7 @@ export function DuplicateSegmentDialog({
|
||||
segment,
|
||||
onClose,
|
||||
}: {
|
||||
segment: FilterSegmentOutput;
|
||||
segment: CombinedFilterSegment;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
@@ -38,7 +38,7 @@ export function DuplicateSegmentDialog({
|
||||
onSuccess: ({ id }) => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_duplicated"), "success");
|
||||
setSegmentId(id);
|
||||
setSegmentId({ id, type: "user" });
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
@@ -50,16 +50,24 @@ export function DuplicateSegmentDialog({
|
||||
if (!segment) {
|
||||
return;
|
||||
}
|
||||
const { id: _id, name: _name, team: _team, teamId, ...rest } = segment;
|
||||
|
||||
if (segment.scope === "TEAM" && isAdminOrOwner) {
|
||||
createSegment({
|
||||
...rest,
|
||||
teamId: teamId ?? 0,
|
||||
scope: "TEAM",
|
||||
name: data.name,
|
||||
});
|
||||
} else {
|
||||
if (segment.type === "user") {
|
||||
const { type: _type, id: _id, name: _name, team: _team, teamId, ...rest } = segment;
|
||||
if (segment.scope === "TEAM" && isAdminOrOwner) {
|
||||
createSegment({
|
||||
...rest,
|
||||
teamId: teamId ?? 0,
|
||||
scope: "TEAM",
|
||||
name: data.name,
|
||||
});
|
||||
} else {
|
||||
createSegment({
|
||||
...rest,
|
||||
scope: "USER",
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
} else if (segment.type === "system") {
|
||||
const { type: _type, ...rest } = segment;
|
||||
createSegment({
|
||||
...rest,
|
||||
scope: "USER",
|
||||
@@ -80,7 +88,13 @@ export function DuplicateSegmentDialog({
|
||||
<DialogHeader title={t("duplicate_segment")} />
|
||||
<Form form={form} handleSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<TextField required type="text" label={t("name")} {...form.register("name")} />
|
||||
<TextField
|
||||
required
|
||||
data-testid="duplicate-segment-name"
|
||||
type="text"
|
||||
label={t("name")}
|
||||
{...form.register("name")}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button color="minimal" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
|
||||
@@ -16,7 +16,12 @@ import {
|
||||
import { Icon, type IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useDataTable } from "../../hooks";
|
||||
import type { FilterSegmentOutput } from "../../lib/types";
|
||||
import type {
|
||||
FilterSegmentOutput,
|
||||
CombinedFilterSegment,
|
||||
SystemFilterSegmentInternal,
|
||||
UserFilterSegment,
|
||||
} from "../../lib/types";
|
||||
import { DeleteSegmentDialog } from "./DeleteSegmentDialog";
|
||||
import { DuplicateSegmentDialog } from "./DuplicateSegmentDialog";
|
||||
import { RenameSegmentDialog } from "./RenameSegmentDialog";
|
||||
@@ -24,23 +29,33 @@ import { RenameSegmentDialog } from "./RenameSegmentDialog";
|
||||
type SubmenuItem = {
|
||||
iconName: IconName;
|
||||
labelKey: string;
|
||||
onClick: (segment: FilterSegmentOutput) => void;
|
||||
onClick: (segment: CombinedFilterSegment) => void;
|
||||
isDestructive?: boolean;
|
||||
adminOnly: boolean;
|
||||
enabledForSystemSegment?: boolean;
|
||||
};
|
||||
|
||||
export function FilterSegmentSelect() {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
||||
const { segments, selectedSegment, segmentId, setSegmentId, isSegmentEnabled } = useDataTable();
|
||||
const [segmentToRename, setSegmentToRename] = useState<FilterSegmentOutput | undefined>();
|
||||
const [segmentToDuplicate, setSegmentToDuplicate] = useState<FilterSegmentOutput | undefined>();
|
||||
const [segmentToDuplicate, setSegmentToDuplicate] = useState<CombinedFilterSegment | undefined>();
|
||||
const [segmentToDelete, setSegmentToDelete] = useState<FilterSegmentOutput | undefined>();
|
||||
|
||||
const submenuItems: SubmenuItem[] = [
|
||||
{
|
||||
iconName: "square-pen",
|
||||
labelKey: "rename",
|
||||
onClick: (segment) => setSegmentToRename(segment),
|
||||
onClick: (segment) => {
|
||||
if (segment.type === "system") {
|
||||
setSegmentToRename(undefined);
|
||||
} else {
|
||||
const { type: _type, ...rest } = segment;
|
||||
setSegmentToRename(rest);
|
||||
}
|
||||
},
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
@@ -48,55 +63,79 @@ export function FilterSegmentSelect() {
|
||||
labelKey: "duplicate",
|
||||
onClick: (segment) => setSegmentToDuplicate(segment),
|
||||
adminOnly: false,
|
||||
enabledForSystemSegment: true,
|
||||
},
|
||||
{
|
||||
iconName: "trash-2",
|
||||
labelKey: "delete",
|
||||
onClick: (segment) => setSegmentToDelete(segment),
|
||||
onClick: (segment) => {
|
||||
if (segment.type === "system") {
|
||||
setSegmentToDelete(undefined);
|
||||
} else {
|
||||
const { type: _type, ...rest } = segment;
|
||||
setSegmentToDelete(rest);
|
||||
}
|
||||
},
|
||||
isDestructive: true,
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
|
||||
const segmentGroups = useMemo(() => {
|
||||
const sortFn = (a: FilterSegmentOutput, b: FilterSegmentOutput) => a.name.localeCompare(b.name);
|
||||
const sortFn = (a: CombinedFilterSegment, b: CombinedFilterSegment) => a.name.localeCompare(b.name);
|
||||
|
||||
const personalSegments = segments?.filter((segment) => !segment.team) || [];
|
||||
const systemSegments =
|
||||
segments?.filter((s): s is SystemFilterSegmentInternal => s.type === "system") || [];
|
||||
const personalSegments =
|
||||
segments?.filter((s): s is UserFilterSegment => s.type === "user" && !s.team) || [];
|
||||
const teamSegments =
|
||||
segments?.filter(
|
||||
(segment): segment is FilterSegmentOutput & { team: NonNullable<FilterSegmentOutput["team"]> } =>
|
||||
segment.team !== null
|
||||
(s): s is UserFilterSegment & { team: NonNullable<FilterSegmentOutput["team"]> } =>
|
||||
s.type === "user" && s.team !== null
|
||||
) || [];
|
||||
|
||||
// Group team segments by team name
|
||||
const teamSegmentsByTeam = teamSegments.reduce<{ [teamName: string]: FilterSegmentOutput[] }>(
|
||||
(acc, segment) => {
|
||||
const teamName = segment.team.name;
|
||||
if (!acc[teamName]) {
|
||||
acc[teamName] = [];
|
||||
}
|
||||
acc[teamName].push(segment);
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
const groups = [];
|
||||
|
||||
return [
|
||||
...(personalSegments.length > 0
|
||||
? [
|
||||
{
|
||||
label: t("personal"),
|
||||
segments: personalSegments.sort(sortFn),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
if (systemSegments.length > 0) {
|
||||
groups.push({
|
||||
label: t("default"),
|
||||
segments: systemSegments.sort(sortFn),
|
||||
isSystem: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Personal segments
|
||||
if (personalSegments.length > 0) {
|
||||
groups.push({
|
||||
label: t("personal"),
|
||||
segments: personalSegments.sort(sortFn),
|
||||
isSystem: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Team segments (existing grouping logic)
|
||||
const teamSegmentsByTeam = teamSegments.reduce<{
|
||||
[teamName: string]: UserFilterSegment[];
|
||||
}>((acc, segment) => {
|
||||
const teamName = segment.team!.name;
|
||||
if (!acc[teamName]) {
|
||||
acc[teamName] = [];
|
||||
}
|
||||
acc[teamName].push(segment);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
groups.push(
|
||||
...Object.entries(teamSegmentsByTeam)
|
||||
.map(([teamName, segments]) => ({
|
||||
label: teamName,
|
||||
segments: segments.sort(sortFn),
|
||||
isSystem: false,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
];
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
);
|
||||
|
||||
return groups;
|
||||
}, [segments, t]);
|
||||
|
||||
if (!isSegmentEnabled) {
|
||||
@@ -128,22 +167,43 @@ export function FilterSegmentSelect() {
|
||||
{group.label && (
|
||||
<DropdownMenuLabel className={index === 0 ? "" : "mt-2"}>{group.label}</DropdownMenuLabel>
|
||||
)}
|
||||
{group.segments.map((segment) => (
|
||||
<DropdownItemWithSubmenu
|
||||
key={segment.id}
|
||||
submenuItems={submenuItems}
|
||||
segment={segment}
|
||||
onSelect={() => {
|
||||
if (segmentId === segment.id) {
|
||||
setSegmentId(null);
|
||||
} else {
|
||||
setSegmentId(segment.id);
|
||||
}
|
||||
}}>
|
||||
{segment.id === segmentId && <Icon name="check" className="ml-3 h-4 w-4" />}
|
||||
<span className="ml-3">{segment.name}</span>
|
||||
</DropdownItemWithSubmenu>
|
||||
))}
|
||||
{group.segments.map((segment) => {
|
||||
let items: SubmenuItem[] = [];
|
||||
if (segment.type === "system") {
|
||||
items = submenuItems.filter((item) => item.enabledForSystemSegment);
|
||||
} else if (segment.type === "user" && segment.scope === "USER") {
|
||||
items = submenuItems;
|
||||
} else if (segment.type === "user" && segment.scope === "TEAM") {
|
||||
items = submenuItems.filter(
|
||||
(item) =>
|
||||
// Team segments: show if not admin-only or if user is admin/owner
|
||||
isAdminOrOwner || !item.adminOnly
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownItemWithSubmenu
|
||||
key={segment.id}
|
||||
submenuItems={items}
|
||||
segment={segment}
|
||||
onSelect={() => {
|
||||
if (segmentId && segmentId.type === segment.type && segmentId.id === segment.id) {
|
||||
setSegmentId(null);
|
||||
} else {
|
||||
if (segment.type === "system") {
|
||||
setSegmentId({ id: segment.id, type: "system" });
|
||||
} else {
|
||||
setSegmentId({ id: segment.id, type: "user" });
|
||||
}
|
||||
}
|
||||
}}>
|
||||
{segmentId && segmentId.type === segment.type && segmentId.id === segment.id && (
|
||||
<Icon name="check" className="ml-3 h-4 w-4" />
|
||||
)}
|
||||
<span className="ml-3">{segment.name}</span>
|
||||
</DropdownItemWithSubmenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -175,25 +235,12 @@ function DropdownItemWithSubmenu({
|
||||
children,
|
||||
}: {
|
||||
onSelect: () => void;
|
||||
segment: FilterSegmentOutput;
|
||||
segment: CombinedFilterSegment;
|
||||
submenuItems: SubmenuItem[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const session = useSession();
|
||||
const isAdminOrOwner = checkAdminOrOwner(session.data?.user?.org?.role);
|
||||
|
||||
// Filter submenu items based on segment type and user role
|
||||
const filteredSubmenuItems = submenuItems.filter((item) => {
|
||||
if (!segment.team) {
|
||||
// Personal segments: show all actions
|
||||
return true;
|
||||
}
|
||||
|
||||
// Team segments: show if not admin-only or if user is admin/owner
|
||||
return !item.adminOnly || isAdminOrOwner;
|
||||
});
|
||||
|
||||
return (
|
||||
<DropdownMenuItem className="cursor-pointer" onSelect={onSelect}>
|
||||
@@ -220,7 +267,7 @@ function DropdownItemWithSubmenu({
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
data-testid="filter-segment-select-submenu-content">
|
||||
{filteredSubmenuItems.map((item, index) => (
|
||||
{submenuItems.map((item, index) => (
|
||||
<DropdownMenuItem key={index}>
|
||||
<DropdownItem
|
||||
color={item.isDestructive ? "destructive" : undefined}
|
||||
|
||||
@@ -71,7 +71,13 @@ export function RenameSegmentDialog({
|
||||
<DialogHeader title={t("rename_segment")} />
|
||||
<Form form={form} handleSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<TextField required type="text" label={t("name")} {...form.register("name")} />
|
||||
<TextField
|
||||
required
|
||||
data-testid="rename-segment-name"
|
||||
type="text"
|
||||
label={t("name")}
|
||||
{...form.register("name")}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="submit" loading={isPending}>
|
||||
{t("save")}
|
||||
|
||||
@@ -57,7 +57,7 @@ export function SaveFilterSegmentButton() {
|
||||
} = useDataTable();
|
||||
|
||||
const [saveMode, setSaveMode] = useState<"create" | "update">(() =>
|
||||
selectedSegment ? "update" : "create"
|
||||
selectedSegment && selectedSegment.type === "user" ? "update" : "create"
|
||||
);
|
||||
|
||||
// When the dialog is not open,
|
||||
@@ -66,16 +66,16 @@ export function SaveFilterSegmentButton() {
|
||||
if (isOpen) {
|
||||
return;
|
||||
}
|
||||
setSaveMode(selectedSegment ? "update" : "create");
|
||||
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
|
||||
}, [selectedSegment, isOpen]);
|
||||
|
||||
const { data: teams } = trpc.viewer.teams.list.useQuery();
|
||||
|
||||
const { mutate: createSegment } = trpc.viewer.filterSegments.create.useMutation({
|
||||
onSuccess: ({ id }) => {
|
||||
onSuccess: (segment) => {
|
||||
utils.viewer.filterSegments.list.invalidate();
|
||||
showToast(t("filter_segment_saved"), "success");
|
||||
setSegmentId(id);
|
||||
setSegmentId({ id: segment.id, type: "user" }, { type: "user", ...segment });
|
||||
setIsOpen(false);
|
||||
},
|
||||
onError: () => {
|
||||
@@ -110,12 +110,7 @@ export function SaveFilterSegmentButton() {
|
||||
searchTerm,
|
||||
};
|
||||
|
||||
if (saveMode === "update") {
|
||||
if (!selectedSegment) {
|
||||
// theoretically this should never happen
|
||||
showToast(t("segment_not_found"), "error");
|
||||
return;
|
||||
}
|
||||
if (saveMode === "update" && selectedSegment && selectedSegment.type === "user") {
|
||||
const scope = selectedSegment.scope;
|
||||
if (scope === "TEAM") {
|
||||
updateSegment({
|
||||
@@ -156,7 +151,7 @@ export function SaveFilterSegmentButton() {
|
||||
// Reset form state when dialog closes
|
||||
setIsTeamSegment(false);
|
||||
setSelectedTeamId(undefined);
|
||||
setSaveMode(selectedSegment ? "update" : "create");
|
||||
setSaveMode(selectedSegment && selectedSegment.type === "user" ? "update" : "create");
|
||||
form.reset();
|
||||
}
|
||||
setIsOpen(open);
|
||||
@@ -184,7 +179,7 @@ export function SaveFilterSegmentButton() {
|
||||
<DialogContent data-testid="save-filter-segment-dialog">
|
||||
<DialogHeader title={t("save_segment")} />
|
||||
<Form form={form} handleSubmit={onSubmit}>
|
||||
{selectedSegment ? (
|
||||
{selectedSegment && selectedSegment.type === "user" ? (
|
||||
<div className="mb-4">
|
||||
<RadioGroup
|
||||
defaultValue="update"
|
||||
|
||||
@@ -1,35 +1,13 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { isEqual } from "lodash";
|
||||
import { useCallback, useMemo, useEffect } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import { recalculateDateRange } from "../lib/dateRange";
|
||||
import { type UseSegments } from "../lib/types";
|
||||
import { type UseSegments, SYSTEM_SEGMENT_PREFIX } from "../lib/types";
|
||||
import { isDateRangeFilterValue } from "../lib/utils";
|
||||
|
||||
export const useSegments: UseSegments = ({
|
||||
tableIdentifier,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
defaultPageSize,
|
||||
segmentId,
|
||||
setSegmentId,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
setSearchTerm,
|
||||
segments: providedSegments,
|
||||
preferredSegmentId,
|
||||
}) => {
|
||||
const { data: rawSegments, isFetching: isFetchingSegments } = trpc.viewer.filterSegments.list.useQuery(
|
||||
export const useSegments: UseSegments = ({ tableIdentifier, providedSegments, systemSegments }) => {
|
||||
const { data: rawSegments, isSuccess } = trpc.viewer.filterSegments.list.useQuery(
|
||||
{
|
||||
tableIdentifier,
|
||||
},
|
||||
@@ -37,16 +15,19 @@ export const useSegments: UseSegments = ({
|
||||
enabled: !providedSegments, // Only fetch if segments are not provided
|
||||
}
|
||||
);
|
||||
|
||||
const { mutate: setPreference } = trpc.viewer.filterSegments.setPreference.useMutation();
|
||||
|
||||
// Recalculate date ranges based on the current timestamp
|
||||
const segments = useMemo(() => {
|
||||
const segmentsSource = providedSegments || rawSegments?.segments;
|
||||
if (!segmentsSource) return [];
|
||||
const preferredSegmentId = useMemo(() => rawSegments?.preferredSegmentId || null, [rawSegments]);
|
||||
|
||||
return segmentsSource.map((segment) => ({
|
||||
const segments = useMemo(() => {
|
||||
const userSegments = providedSegments || rawSegments?.segments || [];
|
||||
|
||||
const processedSystemSegments = (systemSegments || []).map((segment) => ({
|
||||
perPage: 10,
|
||||
tableIdentifier,
|
||||
...segment,
|
||||
id: `${SYSTEM_SEGMENT_PREFIX}${segment.id}`,
|
||||
type: "system" as const,
|
||||
activeFilters: segment.activeFilters.map((filter) => {
|
||||
if (isDateRangeFilterValue(filter.v)) {
|
||||
return {
|
||||
@@ -57,106 +38,29 @@ export const useSegments: UseSegments = ({
|
||||
return filter;
|
||||
}),
|
||||
}));
|
||||
}, [rawSegments, providedSegments]);
|
||||
|
||||
const selectedSegment = useMemo(() => {
|
||||
return segments?.find((segment) => segment.id === segmentId);
|
||||
}, [segments, segmentId]);
|
||||
const processedUserSegments = userSegments.map((segment) => ({
|
||||
...segment,
|
||||
type: "user" as const,
|
||||
activeFilters: segment.activeFilters.map((filter) => {
|
||||
if (isDateRangeFilterValue(filter.v)) {
|
||||
return {
|
||||
...filter,
|
||||
v: recalculateDateRange(filter.v),
|
||||
};
|
||||
}
|
||||
return filter;
|
||||
}),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (segments && segmentId > 0 && !isFetchingSegments) {
|
||||
const segment = segments.find((segment) => segment.id === segmentId);
|
||||
if (!segment) {
|
||||
// If segmentId is invalid (or not found), clear the segmentId from the query params,
|
||||
// but we still keep all the other states like activeFilters, etc.
|
||||
// This is useful when someone shares a URL that is inaccessible to someone else.
|
||||
setSegmentId(null);
|
||||
}
|
||||
}
|
||||
}, [segments, segmentId, setSegmentId, isFetchingSegments]);
|
||||
|
||||
const memoizedPreferredSegmentId = useMemo(
|
||||
() => preferredSegmentId ?? rawSegments?.preferredSegmentId,
|
||||
[preferredSegmentId, rawSegments]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (memoizedPreferredSegmentId) {
|
||||
setSegmentId(memoizedPreferredSegmentId);
|
||||
}
|
||||
}, [memoizedPreferredSegmentId, setSegmentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSegment) {
|
||||
// segment is selected, so we apply the filters, sorting, etc. from the segment
|
||||
setActiveFilters(selectedSegment.activeFilters);
|
||||
setSorting(selectedSegment.sorting);
|
||||
setColumnVisibility(selectedSegment.columnVisibility);
|
||||
setColumnSizing(selectedSegment.columnSizing);
|
||||
setPageSize(selectedSegment.perPage);
|
||||
setSearchTerm(selectedSegment.searchTerm);
|
||||
setPageIndex(0);
|
||||
}
|
||||
}, [
|
||||
selectedSegment,
|
||||
setActiveFilters,
|
||||
setSorting,
|
||||
setColumnVisibility,
|
||||
setColumnSizing,
|
||||
setPageSize,
|
||||
setPageIndex,
|
||||
setSearchTerm,
|
||||
]);
|
||||
|
||||
const canSaveSegment = useMemo(() => {
|
||||
if (!selectedSegment) {
|
||||
// if no segment is selected, we can save the segment if there are any active filters, sorting, etc.
|
||||
return (
|
||||
activeFilters.length > 0 ||
|
||||
sorting.length > 0 ||
|
||||
Object.keys(columnVisibility).length > 0 ||
|
||||
Object.keys(columnSizing).length > 0 ||
|
||||
pageSize !== defaultPageSize ||
|
||||
searchTerm?.length > 0
|
||||
);
|
||||
} else {
|
||||
// if a segment is selected, we can save the segment if the active filters, sorting, etc. are different from the segment
|
||||
return (
|
||||
!isEqual(activeFilters, selectedSegment.activeFilters) ||
|
||||
!isEqual(sorting, selectedSegment.sorting) ||
|
||||
!isEqual(columnVisibility, selectedSegment.columnVisibility) ||
|
||||
!isEqual(columnSizing, selectedSegment.columnSizing) ||
|
||||
!isEqual(pageSize, selectedSegment.perPage) ||
|
||||
!isEqual(searchTerm, selectedSegment.searchTerm)
|
||||
);
|
||||
}
|
||||
}, [
|
||||
selectedSegment,
|
||||
activeFilters,
|
||||
sorting,
|
||||
columnVisibility,
|
||||
columnSizing,
|
||||
pageSize,
|
||||
searchTerm,
|
||||
defaultPageSize,
|
||||
]);
|
||||
|
||||
const setAndPersistSegmentId = useCallback(
|
||||
(segmentId: number | null) => {
|
||||
setSegmentId(segmentId);
|
||||
setPreference({
|
||||
tableIdentifier,
|
||||
segmentId,
|
||||
});
|
||||
},
|
||||
[tableIdentifier, setSegmentId, setPreference]
|
||||
);
|
||||
return [...processedSystemSegments, ...processedUserSegments];
|
||||
}, [rawSegments, providedSegments, systemSegments, tableIdentifier]);
|
||||
|
||||
return {
|
||||
segments: segments ?? [],
|
||||
selectedSegment,
|
||||
canSaveSegment,
|
||||
setAndPersistSegmentId,
|
||||
segments,
|
||||
preferredSegmentId,
|
||||
isSuccess: Boolean(providedSegments) || isSuccess,
|
||||
setPreference,
|
||||
isSegmentEnabled: true,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,9 +6,9 @@ import { type UseSegments } from "../lib/types";
|
||||
export const useSegmentsNoop: UseSegments = ({}) => {
|
||||
return {
|
||||
segments: [],
|
||||
selectedSegment: undefined,
|
||||
canSaveSegment: false,
|
||||
setAndPersistSegmentId: noop,
|
||||
preferredSegmentId: null,
|
||||
isSuccess: false,
|
||||
setPreference: noop,
|
||||
isSegmentEnabled: false,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,7 +18,7 @@ export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withD
|
||||
DEFAULT_COLUMN_VISIBILITY
|
||||
);
|
||||
export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING);
|
||||
export const segmentIdParser = parseAsInteger.withDefault(-1);
|
||||
export const segmentIdParser = parseAsString.withDefault("");
|
||||
export const pageIndexParser = parseAsInteger.withDefault(0);
|
||||
export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE);
|
||||
export const searchTermParser = parseAsString.withDefault("");
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { SortingState, ColumnSort, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
|
||||
import type { SortingState, ColumnSort } from "@tanstack/react-table";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { IconName } from "@calcom/ui/components/icon";
|
||||
|
||||
export type { SortingState } from "@tanstack/react-table";
|
||||
|
||||
export const SYSTEM_SEGMENT_PREFIX = "system_";
|
||||
|
||||
export enum ColumnFilterType {
|
||||
SINGLE_SELECT = "ss",
|
||||
MULTI_SELECT = "ms",
|
||||
@@ -255,11 +257,31 @@ export type FilterSegmentOutput = {
|
||||
team: { id: number; name: string } | null;
|
||||
};
|
||||
|
||||
export type FilterSegmentsListResponse = {
|
||||
segments: FilterSegmentOutput[];
|
||||
preferredSegmentId: number | null;
|
||||
export type SystemFilterSegment = {
|
||||
id: string;
|
||||
name: string;
|
||||
activeFilters: ActiveFilters;
|
||||
sorting?: SortingState;
|
||||
columnVisibility?: Record<string, boolean>;
|
||||
columnSizing?: Record<string, number>;
|
||||
perPage?: number;
|
||||
searchTerm?: string | null;
|
||||
type: "system";
|
||||
};
|
||||
|
||||
export type SystemFilterSegmentInternal = Omit<SystemFilterSegment, "perPage"> & {
|
||||
tableIdentifier: string;
|
||||
perPage: number;
|
||||
};
|
||||
|
||||
export type UserFilterSegment = FilterSegmentOutput & {
|
||||
type: "user";
|
||||
};
|
||||
|
||||
export type CombinedFilterSegment = SystemFilterSegmentInternal | UserFilterSegment;
|
||||
|
||||
export type SegmentIdentifier = { id: string; type: "system" } | { id: number; type: "user" };
|
||||
|
||||
export type SegmentStorage = {
|
||||
[tableIdentifier: string]: {
|
||||
segmentId: number;
|
||||
@@ -277,30 +299,20 @@ export type UseSegments = (props: UseSegmentsProps) => UseSegmentsReturn;
|
||||
|
||||
export type UseSegmentsProps = {
|
||||
tableIdentifier: string;
|
||||
activeFilters: ActiveFilters;
|
||||
sorting: SortingState;
|
||||
columnVisibility: VisibilityState;
|
||||
columnSizing: ColumnSizingState;
|
||||
pageSize: number;
|
||||
searchTerm: string;
|
||||
defaultPageSize: number;
|
||||
segmentId: number;
|
||||
setSegmentId: (segmentId: number | null) => void;
|
||||
setActiveFilters: (activeFilters: ActiveFilters) => void;
|
||||
setSorting: (sorting: SortingState) => void;
|
||||
setColumnVisibility: (columnVisibility: VisibilityState) => void;
|
||||
setColumnSizing: (columnSizing: ColumnSizingState) => void;
|
||||
setPageSize: (pageSize: number) => void;
|
||||
setPageIndex: (pageIndex: number) => void;
|
||||
setSearchTerm: (searchTerm: string | null) => void;
|
||||
segments?: FilterSegmentOutput[];
|
||||
preferredSegmentId?: number | null;
|
||||
providedSegments?: FilterSegmentOutput[];
|
||||
systemSegments?: SystemFilterSegment[];
|
||||
};
|
||||
|
||||
export type UseSegmentsReturn = {
|
||||
segments: FilterSegmentOutput[];
|
||||
selectedSegment: FilterSegmentOutput | undefined;
|
||||
canSaveSegment: boolean;
|
||||
setAndPersistSegmentId: (segmentId: number | null) => void;
|
||||
segments: CombinedFilterSegment[];
|
||||
preferredSegmentId: SegmentIdentifier | null;
|
||||
isSuccess: boolean;
|
||||
setPreference: ({
|
||||
tableIdentifier,
|
||||
segmentId,
|
||||
}: {
|
||||
tableIdentifier: string;
|
||||
segmentId: SegmentIdentifier | null;
|
||||
}) => void;
|
||||
isSegmentEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -73,6 +73,17 @@ export const handleRescheduleEventManager = async ({
|
||||
|
||||
const results = updateManager.results ?? [];
|
||||
|
||||
const calVideoResult = results.find((result) => result.type === "daily_video");
|
||||
// Check if Cal Video Creation Failed - That is the fallback for Cal.com and is expected to always work
|
||||
if (calVideoResult && !calVideoResult.success) {
|
||||
handleRescheduleEventManager.error("Cal Video creation failed", {
|
||||
error: calVideoResult.error,
|
||||
bookingLocation,
|
||||
});
|
||||
// This happens only when Cal Video is down
|
||||
throw new Error("Failed to set video conferencing link, but the meeting has been rescheduled");
|
||||
}
|
||||
|
||||
const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({
|
||||
results: results,
|
||||
});
|
||||
@@ -80,7 +91,6 @@ export const handleRescheduleEventManager = async ({
|
||||
let videoCallUrl = _videoCallUrl;
|
||||
let metadata: AdditionalInformation = {};
|
||||
metadata = videoMetadata;
|
||||
|
||||
if (results.length) {
|
||||
// Handle Google Meet results
|
||||
if (bookingLocation === MeetLocationType) {
|
||||
@@ -154,6 +164,7 @@ export const handleRescheduleEventManager = async ({
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
}
|
||||
|
||||
const newReferencesToCreate = structuredClone(updateManager.referencesToCreate);
|
||||
|
||||
await BookingReferenceRepository.replaceBookingReferences({
|
||||
|
||||
@@ -17,65 +17,238 @@ import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAn
|
||||
|
||||
import { describe, vi, expect } from "vitest";
|
||||
|
||||
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
|
||||
import { BookingRepository } from "@calcom/lib/server/repository/booking";
|
||||
import { SchedulingType, BookingStatus, WorkflowMethods } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
|
||||
vi.mock("@calcom/lib/EventManager");
|
||||
vi.mock("@calcom/app-store/utils", () => ({
|
||||
getAppFromSlug: vi.fn(),
|
||||
}));
|
||||
|
||||
const testDestinationCalendar = {
|
||||
integration: "test-calendar",
|
||||
externalId: "test-calendar",
|
||||
// Type definitions
|
||||
type TestUserMetadata = {
|
||||
defaultConferencingApp?: {
|
||||
appSlug: string;
|
||||
appLink?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const testUsers = [
|
||||
{
|
||||
id: 1,
|
||||
name: "user-1",
|
||||
timeZone: "Asia/Kolkata",
|
||||
username: "host-1",
|
||||
email: "host1@test.com",
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
destinationCalendar: testDestinationCalendar,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "user-2",
|
||||
timeZone: "Asia/Kolkata",
|
||||
username: "host-2",
|
||||
email: "host2@test.com",
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "user-3",
|
||||
timeZone: "Asia/Kolkata",
|
||||
username: "host-3",
|
||||
email: "host3@test.com",
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
},
|
||||
];
|
||||
type TestUser = {
|
||||
id: number;
|
||||
name: string;
|
||||
timeZone: string;
|
||||
username: string;
|
||||
email: string;
|
||||
schedules: (typeof TestData.schedules.IstWorkHours)[];
|
||||
destinationCalendar?: { integration: string; externalId: string };
|
||||
metadata?: TestUserMetadata;
|
||||
};
|
||||
|
||||
type MockBookingAttendee = ReturnType<typeof getMockBookingAttendee>;
|
||||
|
||||
// Test Data Builders
|
||||
const createTestUser = (overrides?: {
|
||||
id?: number;
|
||||
name?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
destinationCalendar?: { integration: string; externalId: string };
|
||||
metadata?: TestUserMetadata;
|
||||
}) => ({
|
||||
id: overrides?.id ?? 1,
|
||||
name: overrides?.name ?? `user-${overrides?.id ?? 1}`,
|
||||
timeZone: "Asia/Kolkata",
|
||||
username: overrides?.username ?? `host-${overrides?.id ?? 1}`,
|
||||
email: overrides?.email ?? `host${overrides?.id ?? 1}@test.com`,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
destinationCalendar: overrides?.destinationCalendar,
|
||||
metadata: overrides?.metadata,
|
||||
defaultScheduleId: 1,
|
||||
credentials: [],
|
||||
selectedCalendars: [],
|
||||
weekStart: "Monday" as const,
|
||||
teams: [],
|
||||
availability: [],
|
||||
profiles: [],
|
||||
outOfOffice: undefined,
|
||||
smsLockState: undefined,
|
||||
completedOnboarding: true,
|
||||
locked: false,
|
||||
organizationId: null,
|
||||
});
|
||||
|
||||
const createTestDestinationCalendar = (overrides?: { integration?: string; externalId?: string }) => ({
|
||||
integration: overrides?.integration ?? "test-calendar",
|
||||
externalId: overrides?.externalId ?? "test-calendar",
|
||||
});
|
||||
|
||||
const createTestBooking = async (params: {
|
||||
eventTypeId: number;
|
||||
userId: number;
|
||||
bookingId: number;
|
||||
bookingUid: string;
|
||||
attendees?: MockBookingAttendee[];
|
||||
location?: string;
|
||||
}) => {
|
||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||
|
||||
return {
|
||||
id: params.bookingId,
|
||||
eventTypeId: params.eventTypeId,
|
||||
userId: params.userId,
|
||||
uid: params.bookingUid,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
||||
location: params.location ?? null,
|
||||
attendees: params.attendees ?? [
|
||||
getMockBookingAttendee({
|
||||
id: 2,
|
||||
name: "attendee",
|
||||
email: "attendee@test.com",
|
||||
locale: "en",
|
||||
timeZone: "Asia/Kolkata",
|
||||
}),
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
const createRoundRobinEventType = (params: {
|
||||
id: number;
|
||||
slug: string;
|
||||
users: TestUser[];
|
||||
hosts?: { userId: number; isFixed: boolean }[];
|
||||
locations?: { type: string; credentialId?: number }[];
|
||||
teamId?: number;
|
||||
schedulingType?: SchedulingType;
|
||||
parentId?: number;
|
||||
}) => ({
|
||||
id: params.id,
|
||||
slug: params.slug,
|
||||
schedulingType: params.schedulingType ?? SchedulingType.ROUND_ROBIN,
|
||||
length: 45,
|
||||
users: params.users.map((user) => ({ id: user.id })),
|
||||
hosts: params.hosts ?? params.users.map((user) => ({ userId: user.id, isFixed: false })),
|
||||
locations: params.locations,
|
||||
teamId: params.teamId,
|
||||
parentId: params.parentId,
|
||||
});
|
||||
|
||||
// Assertion Helpers
|
||||
const expectEventManagerCalledWith = (
|
||||
spy: ReturnType<typeof vi.spyOn>,
|
||||
expectedParams: {
|
||||
location?: string;
|
||||
uid: string;
|
||||
changedOrganizer: boolean;
|
||||
destinationCalendars?: Array<{ integration: string; externalId: string }>;
|
||||
}
|
||||
) => {
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
expect(spy).toHaveBeenCalledWith(
|
||||
expectedParams.location
|
||||
? expect.objectContaining({ location: expectedParams.location })
|
||||
: expect.any(Object),
|
||||
expectedParams.uid,
|
||||
undefined,
|
||||
expectedParams.changedOrganizer,
|
||||
expectedParams.destinationCalendars ?? expect.any(Array)
|
||||
);
|
||||
};
|
||||
|
||||
// Mock Helpers
|
||||
type MockEventManagerConfig = {
|
||||
failConferencing?: boolean;
|
||||
};
|
||||
|
||||
type ConferenceResult = {
|
||||
type: string;
|
||||
success: boolean;
|
||||
uid: string;
|
||||
originalEvent: Record<string, unknown>;
|
||||
error?: string;
|
||||
updatedEvent?: {
|
||||
url: string;
|
||||
};
|
||||
};
|
||||
|
||||
const mockEventManagerReschedule = async (config?: MockEventManagerConfig) => {
|
||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const spy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
spy.mockImplementation(async (event: any) => {
|
||||
const location = event.location;
|
||||
const results: ConferenceResult[] = [];
|
||||
|
||||
// Check if location is a static link (starts with http:// or https://)
|
||||
const isStaticLink = location?.startsWith("http://") || location?.startsWith("https://");
|
||||
|
||||
if (isStaticLink) {
|
||||
// For static links, no conferencing result is generated
|
||||
return { referencesToCreate: [] };
|
||||
}
|
||||
|
||||
// Handle conferencing based on location type
|
||||
if (location?.includes("integrations:")) {
|
||||
const conferenceType = location.split(":")[1];
|
||||
|
||||
if (config?.failConferencing) {
|
||||
results.push({
|
||||
type: `${conferenceType}_video`,
|
||||
success: false,
|
||||
error: `Failed to create ${conferenceType} room`,
|
||||
uid: event.uid || "test-uid",
|
||||
originalEvent: {},
|
||||
});
|
||||
} else {
|
||||
results.push({
|
||||
type: `${conferenceType}_video`,
|
||||
success: true,
|
||||
uid: event.uid || "test-uid",
|
||||
originalEvent: {},
|
||||
updatedEvent: {
|
||||
url:
|
||||
conferenceType === "zoom"
|
||||
? "https://zoom.us/j/123456789"
|
||||
: conferenceType === "google"
|
||||
? "https://meet.google.com/test-meeting"
|
||||
: `https://${conferenceType}.co/test-room`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { results, referencesToCreate: [] };
|
||||
});
|
||||
|
||||
return spy;
|
||||
};
|
||||
|
||||
const mockGetAppFromSlug = async () => {
|
||||
const { getAppFromSlug } = await import("@calcom/app-store/utils");
|
||||
return vi.mocked(getAppFromSlug);
|
||||
};
|
||||
|
||||
describe("roundRobinManualReassignment test", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
test("manually reassign round robin organizer", async ({ emails }) => {
|
||||
test("should reassign round robin booking to new host and update workflows", async ({ emails }) => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
||||
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
|
||||
const testDestinationCalendar = createTestDestinationCalendar();
|
||||
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||
const newHost = createTestUser({ id: 2 });
|
||||
const users = [originalHost, newHost, createTestUser({ id: 3 })];
|
||||
|
||||
const users = testUsers;
|
||||
const originalHost = users[0];
|
||||
const newHost = users[1];
|
||||
|
||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||
const { dateString: dateStringPlusTwo } = getDate({ dateIncrement: 2 });
|
||||
|
||||
const bookingToReassignUid = "booking-to-reassign";
|
||||
|
||||
const bookingData = await createBookingScenario(
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
workflows: [
|
||||
{
|
||||
@@ -83,48 +256,34 @@ describe("roundRobinManualReassignment test", () => {
|
||||
trigger: "NEW_EVENT",
|
||||
action: "EMAIL_HOST",
|
||||
template: "REMINDER",
|
||||
activeEventTypeId: 1,
|
||||
activeOn: [1],
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
length: 45,
|
||||
users: users.map((user) => ({ id: user.id })),
|
||||
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
|
||||
},
|
||||
users,
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
id: 123,
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: originalHost.id,
|
||||
uid: bookingToReassignUid,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
||||
attendees: [
|
||||
getMockBookingAttendee({
|
||||
id: 2,
|
||||
name: "attendee",
|
||||
email: "attendee@test.com",
|
||||
locale: "en",
|
||||
timeZone: "Asia/Kolkata",
|
||||
}),
|
||||
],
|
||||
},
|
||||
bookingId: 123,
|
||||
bookingUid: bookingToReassignUid,
|
||||
}),
|
||||
],
|
||||
organizer: originalHost,
|
||||
usersApartFromOrganizer: users.slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await addWorkflowReminders([
|
||||
{
|
||||
bookingUid: bookingToReassignUid,
|
||||
method: WorkflowMethods.EMAIL,
|
||||
scheduledDate: dateStringPlusTwo,
|
||||
scheduledDate: new Date(dateStringPlusTwo),
|
||||
scheduled: true,
|
||||
workflowStepId: 1,
|
||||
workflowId: 1,
|
||||
@@ -135,16 +294,14 @@ describe("roundRobinManualReassignment test", () => {
|
||||
bookingId: 123,
|
||||
newUserId: newHost.id,
|
||||
orgId: null,
|
||||
reassignedById: 1,
|
||||
});
|
||||
|
||||
expect(eventManagerSpy).toBeCalledTimes(1);
|
||||
expect(eventManagerSpy).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
bookingToReassignUid,
|
||||
undefined,
|
||||
true,
|
||||
expect.arrayContaining([expect.objectContaining(testDestinationCalendar)])
|
||||
);
|
||||
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
|
||||
uid: bookingToReassignUid,
|
||||
changedOrganizer: true,
|
||||
destinationCalendars: expect.arrayContaining([expect.objectContaining(testDestinationCalendar)]),
|
||||
});
|
||||
|
||||
expectBookingToBeInDatabase({
|
||||
uid: bookingToReassignUid,
|
||||
@@ -160,21 +317,73 @@ describe("roundRobinManualReassignment test", () => {
|
||||
expectWorkflowToBeTriggered({ emailsToReceive: [newHost.email], emails });
|
||||
});
|
||||
|
||||
test("Manually reassign round robin host with fixed host as organizer", async () => {
|
||||
test("should include conferenceCredentialId when reassigning booking with with fixed host as organizer", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
||||
const testDestinationCalendar = createTestDestinationCalendar();
|
||||
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||
const newHost = createTestUser({ id: 2 });
|
||||
const users = [originalHost, newHost];
|
||||
|
||||
const users = testUsers;
|
||||
const bookingToReassignUid = "booking-with-conference-credential";
|
||||
const conferenceCredentialId = 999;
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
users,
|
||||
locations: [
|
||||
{
|
||||
type: "integrations:zoom",
|
||||
credentialId: conferenceCredentialId,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: originalHost.id,
|
||||
bookingId: 123,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:zoom",
|
||||
}),
|
||||
],
|
||||
organizer: originalHost,
|
||||
usersApartFromOrganizer: users.slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 123,
|
||||
newUserId: newHost.id,
|
||||
orgId: null,
|
||||
reassignedById: 1,
|
||||
});
|
||||
|
||||
// Verify that EventManager.reschedule was called with an event containing conferenceCredentialId
|
||||
expect(eventManagerRescheduleSpy).toHaveBeenCalledTimes(1);
|
||||
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((calledEvent as any).conferenceCredentialId).toBe(conferenceCredentialId);
|
||||
});
|
||||
|
||||
test("should reassign round robin attendee while keeping fixed host as organizer", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
const testDestinationCalendar = createTestDestinationCalendar();
|
||||
const fixedHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||
const currentRRHost = createTestUser({ id: 2 });
|
||||
const newHost = createTestUser({ id: 3 });
|
||||
const users = [fixedHost, currentRRHost, newHost];
|
||||
|
||||
const bookingToReassignUid = "booking-to-reassign";
|
||||
|
||||
const fixedHost = users[0];
|
||||
const currentRRHost = users[1];
|
||||
const newHost = users[2];
|
||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
workflows: [
|
||||
@@ -186,27 +395,22 @@ describe("roundRobinManualReassignment test", () => {
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
length: 45,
|
||||
users: users.map((user) => ({ id: user.id })),
|
||||
users,
|
||||
hosts: users.map((user) => ({
|
||||
userId: user.id,
|
||||
isFixed: user.id === fixedHost.id,
|
||||
})),
|
||||
},
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
id: 123,
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: fixedHost.id,
|
||||
uid: bookingToReassignUid,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
||||
bookingId: 123,
|
||||
bookingUid: bookingToReassignUid,
|
||||
attendees: [
|
||||
getMockBookingAttendee({
|
||||
id: 1,
|
||||
@@ -223,7 +427,7 @@ describe("roundRobinManualReassignment test", () => {
|
||||
timeZone: currentRRHost.timeZone,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
organizer: fixedHost,
|
||||
usersApartFromOrganizer: users.slice(1),
|
||||
@@ -234,65 +438,59 @@ describe("roundRobinManualReassignment test", () => {
|
||||
bookingId: 123,
|
||||
newUserId: newHost.id,
|
||||
orgId: null,
|
||||
reassignedById: 1,
|
||||
});
|
||||
|
||||
expect(eventManagerSpy).toBeCalledTimes(1);
|
||||
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
|
||||
uid: bookingToReassignUid,
|
||||
changedOrganizer: false,
|
||||
});
|
||||
|
||||
// Ensure organizer stays the same
|
||||
expectBookingToBeInDatabase({
|
||||
uid: bookingToReassignUid,
|
||||
userId: fixedHost.id,
|
||||
});
|
||||
|
||||
const bookingRepo = new BookingRepository(prismaMock);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const bookingRepo = new BookingRepository(prismaMock as any);
|
||||
const attendees = await bookingRepo.getBookingAttendees(123);
|
||||
|
||||
expect(attendees.some((attendee) => attendee.email === currentRRHost.email)).toBe(false);
|
||||
expect(attendees.some((attendee) => attendee.email === newHost.email)).toBe(true);
|
||||
});
|
||||
|
||||
test("sends cancellation email to previous RR host when reassigning", async ({ emails }) => {
|
||||
test("should send cancellation email to previous round robin attendee when reassigning", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const EventManager = (await import("@calcom/lib/EventManager")).default;
|
||||
|
||||
const eventManagerSpy = vi.spyOn(EventManager.prototype as any, "reschedule");
|
||||
eventManagerSpy.mockResolvedValue({ referencesToCreate: [] });
|
||||
await mockEventManagerReschedule();
|
||||
|
||||
const sendRoundRobinCancelledEmailsAndSMSSpy = vi.spyOn(
|
||||
await import("@calcom/emails"),
|
||||
"sendRoundRobinCancelledEmailsAndSMS"
|
||||
);
|
||||
|
||||
const users = testUsers;
|
||||
const originalHost = users[0];
|
||||
const previousRRHost = users[1];
|
||||
const newHost = users[2];
|
||||
|
||||
const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 });
|
||||
const testDestinationCalendar = createTestDestinationCalendar();
|
||||
const originalHost = createTestUser({ id: 1, destinationCalendar: testDestinationCalendar });
|
||||
const previousRRHost = createTestUser({ id: 2 });
|
||||
const newHost = createTestUser({ id: 3 });
|
||||
const users = [originalHost, previousRRHost, newHost];
|
||||
|
||||
const bookingToReassignUid = "booking-to-reassign-with-previous-rr-host";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
{
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
length: 45,
|
||||
users: users.map((user) => ({ id: user.id })),
|
||||
hosts: users.map((user) => ({ userId: user.id, isFixed: false })),
|
||||
},
|
||||
users,
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
{
|
||||
id: 124,
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: originalHost.id,
|
||||
uid: bookingToReassignUid,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
startTime: `${dateStringPlusOne}T05:00:00.000Z`,
|
||||
endTime: `${dateStringPlusOne}T05:15:00.000Z`,
|
||||
bookingId: 124,
|
||||
bookingUid: bookingToReassignUid,
|
||||
attendees: [
|
||||
getMockBookingAttendee({
|
||||
id: 2,
|
||||
@@ -309,7 +507,7 @@ describe("roundRobinManualReassignment test", () => {
|
||||
timeZone: previousRRHost.timeZone,
|
||||
}),
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
organizer: originalHost,
|
||||
usersApartFromOrganizer: users.slice(1),
|
||||
@@ -320,8 +518,358 @@ describe("roundRobinManualReassignment test", () => {
|
||||
bookingId: 124,
|
||||
newUserId: newHost.id,
|
||||
orgId: null,
|
||||
reassignedById: 1,
|
||||
});
|
||||
|
||||
expect(sendRoundRobinCancelledEmailsAndSMSSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("roundRobinManualReassignment - Location Changes", () => {
|
||||
setupAndTeardown();
|
||||
|
||||
const createConferencingUsers = () => {
|
||||
return {
|
||||
googleMeetHost: createTestUser({
|
||||
id: 1,
|
||||
destinationCalendar: {
|
||||
integration: "google_calendar",
|
||||
externalId: "test-calendar",
|
||||
},
|
||||
metadata: {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "google-meet",
|
||||
},
|
||||
},
|
||||
}),
|
||||
zoomHost: createTestUser({
|
||||
id: 2,
|
||||
metadata: {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "zoom",
|
||||
},
|
||||
},
|
||||
}),
|
||||
noDefaultConferencingAppHost: createTestUser({
|
||||
id: 3,
|
||||
metadata: {},
|
||||
}),
|
||||
staticVideoLinkHost: createTestUser({
|
||||
id: 4,
|
||||
metadata: {
|
||||
defaultConferencingApp: {
|
||||
appSlug: "custom-video",
|
||||
appLink: "https://custom-video.com/room123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const mockAppStore = async (config?: { googleMeet?: boolean; zoom?: boolean; customVideo?: boolean }) => {
|
||||
const getAppFromSlug = await mockGetAppFromSlug();
|
||||
getAppFromSlug.mockImplementation((slug?: string) => {
|
||||
if (slug === "google-meet" && config?.googleMeet !== false) {
|
||||
return {
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:google:meet",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
if (slug === "zoom" && config?.zoom !== false) {
|
||||
return {
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:zoom",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
if (slug === "custom-video" && config?.customVideo !== false) {
|
||||
return {
|
||||
appData: {
|
||||
location: {
|
||||
type: "integrations:custom-video",
|
||||
},
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
return getAppFromSlug;
|
||||
};
|
||||
|
||||
describe("when event has Organizer's Default Conferencing App location", () => {
|
||||
test("should update location from Google Meet to Zoom when reassigning between hosts with different default conferencing apps", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
await mockAppStore({ googleMeet: true, zoom: true });
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
const zoomHost = users.zoomHost;
|
||||
|
||||
const bookingToReassignUid = "booking-location-change-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 1,
|
||||
slug: "round-robin-event",
|
||||
teamId: 1,
|
||||
users: Object.values(users),
|
||||
locations: [{ type: OrganizerDefaultConferencingAppType }, { type: "integrations:daily" }],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 1,
|
||||
userId: googleMeetHost.id,
|
||||
bookingId: 125,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:google:meet",
|
||||
}),
|
||||
],
|
||||
organizer: googleMeetHost,
|
||||
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 125,
|
||||
newUserId: zoomHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
});
|
||||
|
||||
expectEventManagerCalledWith(eventManagerRescheduleSpy, {
|
||||
location: "integrations:zoom",
|
||||
uid: bookingToReassignUid,
|
||||
changedOrganizer: true,
|
||||
destinationCalendars: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
integration: "google_calendar",
|
||||
externalId: "test-calendar",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
expectBookingToBeInDatabase({
|
||||
uid: bookingToReassignUid,
|
||||
userId: zoomHost.id,
|
||||
});
|
||||
});
|
||||
|
||||
test("should fallback to first available event location in Event Type when new host has no default conferencing app", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
// (await mockGetAppFromSlug()).mockReturnValue(undefined);
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
|
||||
|
||||
const bookingToReassignUid = "booking-fallback-location-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 2,
|
||||
slug: "round-robin-event-fallback",
|
||||
users: Object.values(users),
|
||||
locations: [
|
||||
{ type: OrganizerDefaultConferencingAppType },
|
||||
{ type: "integrations:first-available" },
|
||||
],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 2,
|
||||
userId: googleMeetHost.id,
|
||||
bookingId: 126,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:google:meet",
|
||||
}),
|
||||
],
|
||||
organizer: googleMeetHost,
|
||||
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 126,
|
||||
newUserId: noDefaultConferencingAppHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
});
|
||||
|
||||
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((calledEvent as any).location).toBe("integrations:first-available");
|
||||
});
|
||||
|
||||
test("should update location from Google Meet to static video link when new host has static video link app", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
await mockAppStore({ customVideo: true });
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
const staticVideoLinkHost = users.staticVideoLinkHost;
|
||||
|
||||
const bookingToReassignUid = "booking-static-link-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 4,
|
||||
slug: "managed-event",
|
||||
// This is a managed event type child which allows Organizer's Default Conferencing App
|
||||
parentId: 1,
|
||||
users: Object.values(users),
|
||||
locations: [{ type: OrganizerDefaultConferencingAppType }],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 4,
|
||||
userId: googleMeetHost.id,
|
||||
bookingId: 128,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:google:meet",
|
||||
}),
|
||||
],
|
||||
organizer: googleMeetHost,
|
||||
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 128,
|
||||
newUserId: staticVideoLinkHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
});
|
||||
|
||||
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((calledEvent as any).location).toBe("https://custom-video.com/room123");
|
||||
});
|
||||
|
||||
test("should update location from Static Video Link to Google Meet when new host has Google Meet app", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
const eventManagerRescheduleSpy = await mockEventManagerReschedule();
|
||||
|
||||
await mockAppStore({ googleMeet: true });
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const staticVideoLinkHost = users.staticVideoLinkHost;
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
|
||||
const bookingToReassignUid = "booking-static-link-to-google-meet-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 3,
|
||||
slug: "round-robin-event-error",
|
||||
users: Object.values(users),
|
||||
teamId: 1,
|
||||
locations: [{ type: OrganizerDefaultConferencingAppType }],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 3,
|
||||
userId: staticVideoLinkHost.id,
|
||||
bookingId: 127,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "https://custom-video.com/room123",
|
||||
}),
|
||||
],
|
||||
organizer: staticVideoLinkHost,
|
||||
usersApartFromOrganizer: Object.values(users).filter((user) => user.id !== staticVideoLinkHost.id),
|
||||
})
|
||||
);
|
||||
|
||||
await roundRobinManualReassignment({
|
||||
bookingId: 127,
|
||||
newUserId: googleMeetHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
});
|
||||
|
||||
const calledEvent = eventManagerRescheduleSpy.mock.calls[0][0];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((calledEvent as any).location).toBe("integrations:google:meet");
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
uid: bookingToReassignUid,
|
||||
userId: googleMeetHost.id,
|
||||
location: "integrations:google:meet",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error when Cal Video fallback fails", async () => {
|
||||
const roundRobinManualReassignment = (await import("./roundRobinManualReassignment")).default;
|
||||
await mockEventManagerReschedule({ failConferencing: true });
|
||||
|
||||
const users = createConferencingUsers();
|
||||
const googleMeetHost = users.googleMeetHost;
|
||||
const noDefaultConferencingAppHost = users.noDefaultConferencingAppHost;
|
||||
|
||||
const bookingToReassignUid = "booking-calvideo-error-test";
|
||||
|
||||
await createBookingScenario(
|
||||
getScenarioData({
|
||||
eventTypes: [
|
||||
createRoundRobinEventType({
|
||||
id: 3,
|
||||
slug: "round-robin-event-error",
|
||||
users: Object.values(users),
|
||||
locations: [{ type: OrganizerDefaultConferencingAppType }],
|
||||
}),
|
||||
],
|
||||
bookings: [
|
||||
await createTestBooking({
|
||||
eventTypeId: 3,
|
||||
userId: googleMeetHost.id,
|
||||
bookingId: 127,
|
||||
bookingUid: bookingToReassignUid,
|
||||
location: "integrations:google:meet",
|
||||
}),
|
||||
],
|
||||
organizer: googleMeetHost,
|
||||
usersApartFromOrganizer: Object.values(users).slice(1),
|
||||
})
|
||||
);
|
||||
|
||||
await expect(
|
||||
roundRobinManualReassignment({
|
||||
bookingId: 127,
|
||||
newUserId: noDefaultConferencingAppHost.id,
|
||||
orgId: null,
|
||||
reassignReason: "Host unavailable",
|
||||
reassignedById: 999,
|
||||
})
|
||||
).rejects.toThrow("Failed to set video conferencing link, but the meeting has been rescheduled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
|
||||
import { OrganizerDefaultConferencingAppType, getLocationValueForDB } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import {
|
||||
sendRoundRobinCancelledEmailsAndSMS,
|
||||
@@ -28,10 +27,10 @@ import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKey
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { BookingLocationService } from "@calcom/lib/server/service/bookingLocationService";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
@@ -126,7 +125,6 @@ export const roundRobinManualReassignment = async ({
|
||||
|
||||
const originalOrganizer = booking.user;
|
||||
const hasOrganizerChanged = !fixedHost && booking.userId !== newUserId;
|
||||
|
||||
const newUser = newUserHost.user;
|
||||
const newUserT = await getTranslation(newUser.locale || "en", "common");
|
||||
const originalOrganizerT = await getTranslation(originalOrganizer.locale || "en", "common");
|
||||
@@ -149,7 +147,7 @@ export const roundRobinManualReassignment = async ({
|
||||
|
||||
const previousRRHostT = await getTranslation(previousRRHost?.locale || "en", "common");
|
||||
let bookingLocation = booking.location;
|
||||
|
||||
let conferenceCredentialId: number | null = null;
|
||||
if (hasOrganizerChanged) {
|
||||
const bookingResponses = booking.responses;
|
||||
const responseSchema = getBookingResponsesSchema({
|
||||
@@ -159,15 +157,20 @@ export const roundRobinManualReassignment = async ({
|
||||
const responseSafeParse = await responseSchema.safeParseAsync(bookingResponses);
|
||||
const responses = responseSafeParse.success ? responseSafeParse.data : undefined;
|
||||
|
||||
if (eventType.locations.some((location) => location.type === OrganizerDefaultConferencingAppType)) {
|
||||
const newUserMetadataSafeParse = userMetadataSchema.safeParse(newUser.metadata);
|
||||
const defaultLocationUrl = newUserMetadataSafeParse.success
|
||||
? newUserMetadataSafeParse?.data?.defaultConferencingApp?.appLink
|
||||
: undefined;
|
||||
const currentBookingLocation = booking.location || "integrations:daily";
|
||||
bookingLocation =
|
||||
defaultLocationUrl ||
|
||||
getLocationValueForDB(currentBookingLocation, eventType.locations).bookingLocation;
|
||||
// Determine the location for the new host
|
||||
const isManagedEventType = !!eventType.parentId;
|
||||
const isTeamEventType = !!eventType.teamId;
|
||||
|
||||
const locationResult = BookingLocationService.getLocationForHost({
|
||||
hostMetadata: newUser.metadata,
|
||||
eventTypeLocations: eventType.locations,
|
||||
isManagedEventType,
|
||||
isTeamEventType,
|
||||
});
|
||||
|
||||
bookingLocation = locationResult.bookingLocation;
|
||||
if (locationResult.requiresActualLink) {
|
||||
conferenceCredentialId = locationResult.conferenceCredentialId;
|
||||
}
|
||||
|
||||
const newBookingTitle = getEventName({
|
||||
@@ -219,6 +222,18 @@ export const roundRobinManualReassignment = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// When organizer hasn't changed, still extract conferenceCredentialId from event type locations
|
||||
if (!hasOrganizerChanged && bookingLocation) {
|
||||
const { conferenceCredentialId: extractedCredentialId } =
|
||||
BookingLocationService.getLocationDetailsFromType({
|
||||
locationType: bookingLocation,
|
||||
eventTypeLocations: eventType.locations || [],
|
||||
});
|
||||
if (extractedCredentialId) {
|
||||
conferenceCredentialId = extractedCredentialId;
|
||||
}
|
||||
}
|
||||
|
||||
const destinationCalendar = await getDestinationCalendar({
|
||||
eventType,
|
||||
booking,
|
||||
@@ -290,6 +305,7 @@ export const roundRobinManualReassignment = async ({
|
||||
customReplyToEmail: eventType?.customReplyToEmail,
|
||||
location: bookingLocation,
|
||||
...(platformClientParams ? platformClientParams : {}),
|
||||
conferenceCredentialId: conferenceCredentialId ?? undefined,
|
||||
};
|
||||
|
||||
const credentials = await prisma.credential.findMany({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user